mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
Integrate reviewed maintenance verification evidence presentation
Preserve exact Web candidate 067d845244 and its parent-bound browser proof. Existing reports now expose recorded check age and truthful loading, unavailable, denied and empty states; no action or public contract change.
Change-source: pulse-maintainer
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
// Read-only browser acceptance of the changed report presentation/query flow.
|
||||
// Explicit review/rerun business calls are covered by the runtime component
|
||||
// suite. Every POST is refused here, so a clock-induced operation fails proof.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = '/workspace/tmp/maintenance-evidence/browser-read-only';
|
||||
const origin = 'http://127.0.0.1:5347';
|
||||
const start = Date.parse('2026-10-11T04:00:00Z');
|
||||
const iso = (offset) => new Date(start + offset).toISOString();
|
||||
const reports = ['healthy', 'pending', 'needs_review', 'failed_verification'].map((status) => ({
|
||||
id: 'report:' + status,
|
||||
resourceId: 'pbs-a',
|
||||
status,
|
||||
trigger: 'maintenance_window_ended',
|
||||
startedAt: iso(-30_000),
|
||||
completedAt: status === 'pending' ? '0001-01-01T00:00:00Z' : iso(-30_000),
|
||||
windowStartedAt: iso(-90_000_000),
|
||||
windowEndedAt: iso(-86_400_000),
|
||||
evidence: {
|
||||
activeCriticalAlerts: status === 'failed_verification' ? 1 : 0,
|
||||
activeWarningAlerts: 2,
|
||||
activeCriticalFindings: 0,
|
||||
activeWarningFindings: 1,
|
||||
failedActionsSinceWindowStart: 0,
|
||||
metricRecovery: { samplesAfterEnd: 3, trend: 'improving' },
|
||||
},
|
||||
linkedFindingIds: [],
|
||||
linkedAlertIds: [],
|
||||
linkedActionIds: [],
|
||||
recommendation: 'Recorded evidence, not a fresh health observation.',
|
||||
...(status === 'healthy'
|
||||
? {
|
||||
userOutcome: 'reviewed',
|
||||
reviewedAt: iso(-10_000),
|
||||
reviewedBy: 'fixture-admin',
|
||||
reviewNote: 'Earlier check retained. A completed backup does not prove guest recovery.',
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
async function bounded(promise, label) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error('Fixture observation deadline: ' + label)),
|
||||
10_000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const binding = JSON.parse(
|
||||
fs.readFileSync('/workspace/tmp/maintenance-evidence/source-binding.json'),
|
||||
);
|
||||
for (const [file, digest] of Object.entries(binding.content_sha256)) {
|
||||
assert.equal(
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync('/workspace/' + file))
|
||||
.digest('hex'),
|
||||
digest,
|
||||
file,
|
||||
);
|
||||
}
|
||||
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 result = {
|
||||
source: binding,
|
||||
playwright,
|
||||
cases: [],
|
||||
captures: [],
|
||||
completed: false,
|
||||
browsersClosed: false,
|
||||
serverClosed: false,
|
||||
};
|
||||
const { createServer } = await import(root + '/node_modules/vite/dist/node/index.js');
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: root + '/vite.config.ts',
|
||||
cacheDir: output + '/vite-cache',
|
||||
server: { host: '127.0.0.1', port: 5347, strictPort: true, watch: null },
|
||||
});
|
||||
let browser, context;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, height, dark] of [
|
||||
['desktop', chromium, 1365, 900, false],
|
||||
['phone', webkit, 390, 844, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
isMobile: dark,
|
||||
hasTouch: dark,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10_000);
|
||||
page.setDefaultNavigationTimeout(45_000);
|
||||
await page.clock.install({ time: new Date(start - 1000) });
|
||||
await page.clock.pauseAt(new Date(start));
|
||||
await page.addInitScript((dark) => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
document.addEventListener(
|
||||
'DOMContentLoaded',
|
||||
() => document.documentElement.classList.toggle('dark', dark),
|
||||
{ once: true },
|
||||
);
|
||||
}, dark);
|
||||
const record = {
|
||||
name,
|
||||
browserVersion: browser.version(),
|
||||
viewport: { width, height },
|
||||
dark,
|
||||
checks: [],
|
||||
reads: [],
|
||||
writes: [],
|
||||
errors: [],
|
||||
unexpected: [],
|
||||
offOrigin: [],
|
||||
completed: false,
|
||||
contextClosed: false,
|
||||
};
|
||||
result.cases.push(record);
|
||||
let mode = 'pending',
|
||||
releaseRead,
|
||||
observeRead;
|
||||
const readStarted = new Promise((resolve) => {
|
||||
observeRead = resolve;
|
||||
});
|
||||
page.on('pageerror', (error) => record.errors.push(error.stack || error.message));
|
||||
await page.route('**/*', async (route) => {
|
||||
const req = route.request(),
|
||||
url = new URL(req.url());
|
||||
if (url.origin !== origin) {
|
||||
record.offOrigin.push(url.origin);
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (req.method() !== 'GET') {
|
||||
record.writes.push(url.pathname);
|
||||
return route.fulfill({
|
||||
status: 403,
|
||||
json: { error: 'Read-only fixture refuses operations' },
|
||||
});
|
||||
}
|
||||
record.reads.push(url.pathname);
|
||||
let data;
|
||||
if (url.pathname.endsWith('/maintenance-verifications')) {
|
||||
if (url.pathname.includes('/pending-resource/')) {
|
||||
if (mode === 'pending') {
|
||||
await bounded(
|
||||
new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
observeRead();
|
||||
}),
|
||||
'held new-resource read',
|
||||
);
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
json: { error: 'Synthetic unavailable reader' },
|
||||
});
|
||||
}
|
||||
if (mode === 'denied')
|
||||
return route.fulfill({ status: 403, json: { error: 'Synthetic access denial' } });
|
||||
return route.fulfill({
|
||||
json: { data: [], meta: { resourceId: 'pending-resource', limit: 25, total: 0 } },
|
||||
});
|
||||
}
|
||||
data = { data: reports, meta: { resourceId: 'pbs-a', limit: 25, total: reports.length } };
|
||||
} else if (url.pathname.endsWith('/operator-state'))
|
||||
data = {
|
||||
configured: true,
|
||||
state: {
|
||||
canonicalId: 'pbs-a',
|
||||
neverAutoRemediate: true,
|
||||
monitoringMode: 'expected_offline',
|
||||
lifecycleState: 'retired',
|
||||
note: 'Keep the saved protection',
|
||||
setAt: iso(-60_000),
|
||||
},
|
||||
};
|
||||
else if (url.pathname.endsWith('/facets'))
|
||||
data = { capabilities: [], relationships: [], recentChanges: [], counts: {} };
|
||||
else if (url.pathname === '/api/ai/intelligence')
|
||||
data = { resourceId: 'pbs-a', findings: [], investigations: [] };
|
||||
else if (url.pathname === '/api/audit/actions')
|
||||
data = { audits: [], count: 0, available: false };
|
||||
else if (url.pathname === '/api/license/runtime-capabilities')
|
||||
data = {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
};
|
||||
else if (url.pathname === '/api/ai/settings') data = { discovery_enabled: false };
|
||||
else if (url.pathname === '/api/security/status')
|
||||
data = { hasAuthentication: true, requiresAuth: false, sessionCapabilities: {} };
|
||||
else {
|
||||
record.unexpected.push(url.pathname);
|
||||
return route.fulfill({ status: 404, json: { error: 'Unspecified synthetic read' } });
|
||||
}
|
||||
return route.fulfill({ json: data });
|
||||
});
|
||||
const check = async (label, action) => {
|
||||
result.phase = name + ': ' + label;
|
||||
console.log(result.phase);
|
||||
await action();
|
||||
record.checks.push(label);
|
||||
};
|
||||
const capture = async (label) => {
|
||||
const file = output + '/' + name + '-' + label + '.png';
|
||||
await page.screenshot({ path: file, fullPage: true, animations: 'disabled' });
|
||||
result.captures.push(file.replace('/workspace/', ''));
|
||||
};
|
||||
result.phase = name + ': mount full drawer';
|
||||
await page.goto(origin + '/browser-tests/maintenance-evidence.html');
|
||||
await page.getByRole('tab', { name: 'Manage', exact: true }).click();
|
||||
const section = page.getByTestId('maintenance-verification-section');
|
||||
await section.getByText('Checked 30s ago', { exact: true }).first().waitFor();
|
||||
await page.getByLabel('Operator note', { exact: true }).fill('Unsaved protected note');
|
||||
await check(
|
||||
'separate recorded check/window/review ages, unknown pending completion and all four verdicts',
|
||||
async () => {
|
||||
assert.equal(
|
||||
await section.getByText('Window ended 1 day ago', { exact: true }).count(),
|
||||
4,
|
||||
);
|
||||
assert.equal(await section.getByText('Checked unknown', { exact: true }).count(), 1);
|
||||
assert.equal(
|
||||
await section.getByText('Reviewed 10s ago by fixture-admin', { exact: true }).count(),
|
||||
1,
|
||||
);
|
||||
assert.match(await section.innerText(), /Reports record past checks, not current health/);
|
||||
for (const status of ['healthy', 'pending', 'needs_review', 'failed_verification'])
|
||||
assert.equal(
|
||||
await section.getByTestId('maintenance-verification-status-' + status).count(),
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
await capture('recorded');
|
||||
const reads = record.reads.length;
|
||||
await page.clock.fastForward(60_000);
|
||||
await check(
|
||||
'held events age past a minute without reads/writes, changing verdicts or discarding edits',
|
||||
async () => {
|
||||
await section.getByText('Checked 1 min ago', { exact: true }).first().waitFor();
|
||||
assert.equal(
|
||||
await section.getByText('Reviewed 1 min ago by fixture-admin', { exact: true }).count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel('Operator note', { exact: true }).inputValue(),
|
||||
'Unsaved protected note',
|
||||
);
|
||||
assert.equal(record.reads.length, reads);
|
||||
assert.equal(record.writes.length, 0);
|
||||
},
|
||||
);
|
||||
await page.clock.fastForward(3_600_000);
|
||||
await check(
|
||||
'an hour-old verdict stays historical with its evidence, review admission and saved restrictions',
|
||||
async () => {
|
||||
await section.getByText('Checked 1 hour ago', { exact: true }).first().waitFor();
|
||||
assert.equal(
|
||||
await section
|
||||
.getByText('Reviewed 1 hour ago by fixture-admin', { exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(await section.getByTestId('maintenance-verification-review').count(), 3);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Never auto-remediate this resource', { exact: true })
|
||||
.getAttribute('aria-pressed'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel('Monitoring', { exact: true }).inputValue(),
|
||||
'expected_offline',
|
||||
);
|
||||
assert.equal(await page.getByLabel('Lifecycle', { exact: true }).inputValue(), 'retired');
|
||||
assert.equal(record.reads.length, reads);
|
||||
assert.equal(record.writes.length, 0);
|
||||
},
|
||||
);
|
||||
await capture('hour-old');
|
||||
await page.evaluate(() => window.__maintenanceEvidence.select('pending-resource'));
|
||||
// The real drawer deliberately remounts at a different canonical ID and
|
||||
// starts on Overview. Open Manage as an operator would; do not assume a
|
||||
// held tab or misattribute that existing protection to this repair.
|
||||
await page.getByRole('tab', { name: 'Manage', exact: true }).click();
|
||||
await bounded(readStarted, 'new-resource GET observed');
|
||||
await check(
|
||||
'changing resource immediately withdraws prior reports while its own read waits',
|
||||
async () => {
|
||||
await section.getByText('Loading verification reports…', { exact: true }).waitFor();
|
||||
assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0);
|
||||
assert.equal(await section.getByText(/No verification reports yet/).count(), 0);
|
||||
await page.clock.fastForward(60_000);
|
||||
assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0);
|
||||
},
|
||||
);
|
||||
await capture('other-resource-pending');
|
||||
releaseRead();
|
||||
await check(
|
||||
'a failed first read is unavailable, not empty history or another resource evidence',
|
||||
async () => {
|
||||
await section.getByTestId('maintenance-verification-load-error').waitFor();
|
||||
assert.equal(await section.getByText(/No verification reports yet/).count(), 0);
|
||||
assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0);
|
||||
const before = record.reads.length;
|
||||
await page.clock.fastForward(60_000);
|
||||
assert.equal(record.reads.length, before);
|
||||
},
|
||||
);
|
||||
await capture('unavailable');
|
||||
mode = 'denied';
|
||||
await section.getByRole('button', { name: 'Retry reports', exact: true }).focus();
|
||||
await section.getByRole('button', { name: 'Retry reports', exact: true }).press('Enter');
|
||||
await check(
|
||||
'explicit keyboard read retry shows bounded denied guidance without reviving reports',
|
||||
async () => {
|
||||
await section
|
||||
.getByText('Access denied. Check your permissions and license plan.', { exact: true })
|
||||
.waitFor();
|
||||
const before = record.reads.length;
|
||||
await page.clock.fastForward(60_000);
|
||||
assert.equal(record.reads.length, before);
|
||||
assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0);
|
||||
assert.equal(record.writes.length, 0);
|
||||
},
|
||||
);
|
||||
await capture('denied');
|
||||
mode = 'empty';
|
||||
await section.getByRole('button', { name: 'Retry reports', exact: true }).click();
|
||||
await check(
|
||||
'only a successful empty read is empty history, with no subsequent clock retry',
|
||||
async () => {
|
||||
await section.getByText(/No verification reports yet/).waitFor();
|
||||
assert.equal(await section.getByTestId('maintenance-verification-load-error').count(), 0);
|
||||
const before = record.reads.length;
|
||||
await page.clock.fastForward(60_000);
|
||||
assert.equal(record.reads.length, before);
|
||||
assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0);
|
||||
},
|
||||
);
|
||||
await capture('empty');
|
||||
await check(
|
||||
'no page error, unspecified/off-origin request, operation or horizontal overflow',
|
||||
async () => {
|
||||
assert.deepEqual(record.errors, []);
|
||||
assert.deepEqual(record.unexpected, []);
|
||||
assert.deepEqual(record.offOrigin, []);
|
||||
assert.deepEqual(record.writes, []);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
record.completed = true;
|
||||
await context.close();
|
||||
record.contextClosed = true;
|
||||
context = undefined;
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
result.completed = true;
|
||||
} catch (error) {
|
||||
result.error = { name: error.name, message: error.message };
|
||||
throw error;
|
||||
} finally {
|
||||
if (context) await context.close();
|
||||
if (browser) await browser.close();
|
||||
result.browsersClosed = true;
|
||||
await server.close();
|
||||
result.serverClosed = true;
|
||||
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
completed: result.completed,
|
||||
phase: result.phase,
|
||||
checks: result.cases.map((c) => ({
|
||||
name: c.name,
|
||||
count: c.checks.length,
|
||||
errors: c.errors,
|
||||
})),
|
||||
captures: result.captures.length,
|
||||
serverClosed: result.serverClosed,
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
@@ -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>Recorded maintenance evidence</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/maintenance-evidence.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
// The full production resource drawer, transport/query, shared clock and CSS.
|
||||
// Only snapshots and HTTP responses are synthetic. No real operation runs.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
|
||||
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
|
||||
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import '../src/index.css';
|
||||
|
||||
syncAIRuntimeSettings({ discovery_enabled: false });
|
||||
const [resource, setResource] = createSignal<Resource>({
|
||||
id: 'pbs-a',
|
||||
name: 'Recorded backup server',
|
||||
displayName: 'Recorded backup server',
|
||||
type: 'pbs',
|
||||
platformId: 'pbs-a',
|
||||
platformType: 'proxmox-pbs',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
cpu: { current: 12 },
|
||||
lastSeen: Date.parse('2026-10-11T04:00:00Z'),
|
||||
});
|
||||
(window as any).__maintenanceEvidence = {
|
||||
withdraw: () =>
|
||||
setResource((current) => ({ ...current, id: 'empty-resource', name: 'No reports' })),
|
||||
select: (id: string) =>
|
||||
setResource((current) => ({
|
||||
...current,
|
||||
id,
|
||||
name: 'Different resource',
|
||||
displayName: 'Different resource',
|
||||
})),
|
||||
};
|
||||
|
||||
render(
|
||||
() => (
|
||||
<WebSocketContext.Provider
|
||||
value={{ state: { pmg: [] }, activeAlerts: {}, connected: () => true } as any}
|
||||
>
|
||||
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
|
||||
<main class="mx-auto min-h-screen max-w-5xl space-y-3 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Recorded maintenance evidence</h1>
|
||||
<p class="text-xs text-muted">
|
||||
Synthetic local responses. No operation reaches a server.
|
||||
</p>
|
||||
<ResourceDetailDrawer resource={resource()} />
|
||||
</main>
|
||||
</DarkModeContext.Provider>
|
||||
</WebSocketContext.Provider>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "80ef7a2923b5bee25d3aa48ce678f2d4b286725f",
|
||||
"verified_at": "2026-10-11T04:29:14.340458Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx": "af1eac63570ab102272f25fb1ec5cade2ee3508284600a131ff71a9c860efa87"
|
||||
},
|
||||
"routes": ["/browser-tests/maintenance-evidence.html"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Full production ResourceDetailDrawer Manage, actual report component, query/transport, CSS and shared clock; synthetic local-only snapshots/HTTP. Desktop Chromium 141.0.7390.37/light and phone WebKit 26.0/dark. Playwright 1.56.1 matches integration lock.",
|
||||
"Checked, Window ended and Reviewed remain distinct historical events through minute/hour boundaries; all four verdicts, evidence, operator locks and unsaved note preserved. Go zero-time completion stays unknown.",
|
||||
"Loading, 503 unavailable, 403 denied and successful empty read remain distinct. Explicit keyboard read-only retry; no unsolicited retry or mutation on a tick. The full drawer already remounts at a different ID; opening Manage there shows only its own report state. Component-level held-prop isolation and same-resource failed-refresh retention are separately covered by runtime tests.",
|
||||
"Two completed cases / 16 controls / twelve inspected screenshots, no page error, unspecified/off-origin request, operation or horizontal overflow. No full App, actual authentication, backend review/rerun, appliance, guest recovery, native notification, installed or released acceptance."
|
||||
],
|
||||
"interactions": [
|
||||
"Advance only the page clock with held reports. Observe stable verdict/counts and saved restrictions; preserve unsaved operator note.",
|
||||
"Synthetic resource selection uses existing keyed drawer reset, then explicitly open Manage. Held GET returns 503, keyboard Retry reports returns 403, another explicit GET returns empty; reports stay absent through ticks.",
|
||||
"All twelve resulting captures visually inspected. Both contexts, browsers and Vite closed; host descendant cleanup is not independently reported. Earlier failed/cancelled/unknown attempts are retained, not rebound or counted here."
|
||||
],
|
||||
"control_count": 16
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
||||
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { Button } from '@/components/shared/Button';
|
||||
import { InlineNotice } from '@/components/shared/InlineNotice';
|
||||
import {
|
||||
listMaintenanceVerificationsForResource,
|
||||
rerunMaintenanceVerification,
|
||||
@@ -9,6 +11,8 @@ import {
|
||||
type MaintenanceVerificationStatus,
|
||||
} from '@/api/maintenanceVerification';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError';
|
||||
|
||||
/**
|
||||
* MaintenanceVerificationSection surfaces Maintenance Verification
|
||||
@@ -50,22 +54,23 @@ const STATUS_CLASSES: Record<MaintenanceVerificationStatus, string> = {
|
||||
export const MaintenanceVerificationSection: Component<MaintenanceVerificationSectionProps> = (
|
||||
props,
|
||||
) => {
|
||||
const [refreshTick, setRefreshTick] = createSignal(0);
|
||||
|
||||
const now = useRelativeTimeNow();
|
||||
// Age only the recorded events. Ticks neither refresh the report nor change
|
||||
// its verdict, and a rerun's completion time is not the window's end time.
|
||||
const reportAge = (timestamp?: string) => {
|
||||
if (!timestamp || timestamp.startsWith('0001-01-01')) return 'unknown';
|
||||
return formatRelativeTime(timestamp, { now: now() }) || 'unknown';
|
||||
};
|
||||
const query = createNonSuspendingQuery<MaintenanceVerificationReport[], string>({
|
||||
source: () => (props.resourceId ? `${props.resourceId}:${refreshTick()}` : null),
|
||||
fetcher: async () => {
|
||||
try {
|
||||
const res = await listMaintenanceVerificationsForResource(props.resourceId);
|
||||
return res.data ?? [];
|
||||
} catch (err) {
|
||||
notificationStore.error(
|
||||
err instanceof Error ? err.message : 'Failed to load Maintenance Verification Reports',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
source: () => props.resourceId || null,
|
||||
fetcher: async (resourceId) => {
|
||||
const res = await listMaintenanceVerificationsForResource(resourceId);
|
||||
return res.data ?? [];
|
||||
},
|
||||
initialValue: [] as MaintenanceVerificationReport[],
|
||||
// A held report belongs only to its resource. The query withdraws denied
|
||||
// reads, but retains last-loaded records on a same-resource transient error.
|
||||
retainPreviousValueOnSourceChange: false,
|
||||
cacheKey: (key: string) => `maintenance-verifications:${key}`,
|
||||
});
|
||||
|
||||
@@ -75,7 +80,7 @@ export const MaintenanceVerificationSection: Component<MaintenanceVerificationSe
|
||||
const [rerunning, setRerunning] = createSignal(false);
|
||||
const [reviewingId, setReviewingId] = createSignal<string | null>(null);
|
||||
|
||||
const refresh = () => setRefreshTick((n) => n + 1);
|
||||
const refresh = () => void query.refetch();
|
||||
|
||||
const handleRerun = async () => {
|
||||
if (rerunning() || !props.resourceId) return;
|
||||
@@ -121,8 +126,8 @@ export const MaintenanceVerificationSection: Component<MaintenanceVerificationSe
|
||||
Maintenance verification
|
||||
</h3>
|
||||
<p class="text-xs text-muted">
|
||||
Pulse runs deterministic checks each time a maintenance window ends and writes a
|
||||
Maintenance Verification Report. The result sticks here for review.
|
||||
Pulse records the result when a maintenance window ends. Reports record past checks, not
|
||||
current health.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -136,13 +141,47 @@ export const MaintenanceVerificationSection: Component<MaintenanceVerificationSe
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Show when={query.error()}>
|
||||
<InlineNotice
|
||||
tone="warning"
|
||||
role="alert"
|
||||
class="mt-3"
|
||||
data-testid="maintenance-verification-load-error"
|
||||
>
|
||||
<p class="font-medium">Verification reports unavailable</p>
|
||||
<p>
|
||||
{getAPIReadAccessErrorMessage(query.error()) ?? 'Could not load verification reports.'}
|
||||
</p>
|
||||
<Show when={hasReports()}>
|
||||
<p>Last loaded reports remain below.</p>
|
||||
</Show>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={query.loading()}
|
||||
onClick={() => {
|
||||
if (!query.loading()) refresh();
|
||||
}}
|
||||
>
|
||||
Retry reports
|
||||
</Button>
|
||||
</InlineNotice>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={hasReports()}
|
||||
fallback={
|
||||
<div class="mt-3 rounded-sm border border-dashed border-border bg-surface-hover p-3 text-xs text-muted">
|
||||
No verification reports yet. A report is written automatically the next time this
|
||||
resource exits a maintenance window.
|
||||
</div>
|
||||
<Show when={!query.error()}>
|
||||
<div
|
||||
class="mt-3 rounded-sm border border-dashed border-border bg-surface-hover p-3 text-xs text-muted"
|
||||
role={query.loading() ? 'status' : undefined}
|
||||
>
|
||||
{query.loading()
|
||||
? 'Loading verification reports…'
|
||||
: 'No verification reports yet. A report is written automatically the next time this resource exits a maintenance window.'}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ul class="mt-3 space-y-2">
|
||||
@@ -160,9 +199,9 @@ export const MaintenanceVerificationSection: Component<MaintenanceVerificationSe
|
||||
>
|
||||
{STATUS_LABELS[report.status]}
|
||||
</span>
|
||||
<span class="text-xs text-muted">Checked {reportAge(report.completedAt)}</span>
|
||||
<span class="text-xs text-muted">
|
||||
Window ended{' '}
|
||||
{report.windowEndedAt ? formatRelativeTime(report.windowEndedAt) : 'unknown'}
|
||||
Window ended {reportAge(report.windowEndedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={!report.userOutcome}>
|
||||
@@ -178,7 +217,7 @@ export const MaintenanceVerificationSection: Component<MaintenanceVerificationSe
|
||||
</Show>
|
||||
<Show when={report.userOutcome === 'reviewed'}>
|
||||
<span class="text-[11px] text-muted">
|
||||
Reviewed {report.reviewedAt ? formatRelativeTime(report.reviewedAt) : ''}
|
||||
Reviewed {reportAge(report.reviewedAt)}
|
||||
{report.reviewedBy ? ` by ${report.reviewedBy}` : ''}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { MaintenanceVerificationReport } from '@/api/maintenanceVerification';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
|
||||
|
||||
const api = vi.hoisted(() => ({ apiFetchJSON: vi.fn() }));
|
||||
vi.mock('@/utils/apiClient', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/utils/apiClient')>()),
|
||||
apiFetchJSON: api.apiFetchJSON,
|
||||
}));
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import { MaintenanceVerificationSection } from '../MaintenanceVerificationSection';
|
||||
|
||||
const start = Date.parse('2026-10-11T04:00:00Z');
|
||||
const iso = (offset: number) => new Date(start + offset).toISOString();
|
||||
const resourceId = 'cluster:pve-a:101';
|
||||
const report = (
|
||||
overrides: Partial<MaintenanceVerificationReport> = {},
|
||||
): MaintenanceVerificationReport => ({
|
||||
id: 'report:window',
|
||||
resourceId,
|
||||
trigger: 'maintenance_window_ended',
|
||||
status: 'healthy',
|
||||
startedAt: iso(-30_000),
|
||||
completedAt: iso(-30_000),
|
||||
windowStartedAt: iso(-3_600_000),
|
||||
windowEndedAt: iso(-30_000),
|
||||
evidence: {
|
||||
activeCriticalAlerts: 0,
|
||||
activeWarningAlerts: 2,
|
||||
activeCriticalFindings: 0,
|
||||
activeWarningFindings: 1,
|
||||
failedActionsSinceWindowStart: 0,
|
||||
metricRecovery: { samplesAfterEnd: 3, trend: 'improving' },
|
||||
},
|
||||
linkedFindingIds: [],
|
||||
linkedAlertIds: [],
|
||||
linkedActionIds: [],
|
||||
recommendation: 'Recorded recovery evidence',
|
||||
...overrides,
|
||||
});
|
||||
const response = (reports: MaintenanceVerificationReport[]) => ({ data: reports });
|
||||
const mount = async (reports: MaintenanceVerificationReport[]) => {
|
||||
api.apiFetchJSON.mockResolvedValue(response(reports));
|
||||
const rendered = render(() => <MaintenanceVerificationSection resourceId={resourceId} />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
return rendered;
|
||||
};
|
||||
const mutations = () =>
|
||||
api.apiFetchJSON.mock.calls.filter(([, options]) => options?.method === 'POST');
|
||||
|
||||
describe('durable maintenance verification evidence', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
vi.setSystemTime(start);
|
||||
api.apiFetchJSON.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ages held window and review events without fetching, rewriting or rerunning the report', async () => {
|
||||
const saved = report({
|
||||
userOutcome: 'reviewed',
|
||||
reviewedAt: iso(-30_000),
|
||||
reviewedBy: 'fixture-admin',
|
||||
});
|
||||
const original = JSON.stringify(saved);
|
||||
await mount([saved]);
|
||||
expect(screen.getByText('Window ended 30s ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reviewed 30s ago by fixture-admin')).toBeInTheDocument();
|
||||
await vi.advanceTimersByTimeAsync(RELATIVE_TIME_TICK_MS);
|
||||
expect(screen.getByText('Window ended 1 min ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reviewed 1 min ago by fixture-admin')).toBeInTheDocument();
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
expect(screen.getByText('Window ended 1 hour ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reviewed 1 hour ago by fixture-admin')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent(
|
||||
'Healthy',
|
||||
);
|
||||
expect(screen.queryByTestId('maintenance-verification-review')).not.toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(1);
|
||||
expect(mutations()).toEqual([]);
|
||||
expect(JSON.stringify(saved)).toBe(original);
|
||||
});
|
||||
|
||||
it('shows when a check completed, separately from the earlier maintenance window and review', async () => {
|
||||
await mount([
|
||||
report({
|
||||
windowEndedAt: iso(-86_400_000),
|
||||
userOutcome: 'reviewed',
|
||||
reviewedAt: iso(-10_000),
|
||||
}),
|
||||
]);
|
||||
expect(screen.getByText('Checked 30s ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Window ended 1 day ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reviewed 10s ago')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Reports record past checks, not current health\./),
|
||||
).toBeInTheDocument();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.getByText('Checked 1 min ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reviewed 1 min ago')).toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps every recorded verdict and its counts unchanged as several reports age', async () => {
|
||||
const states = ['pending', 'healthy', 'needs_review', 'failed_verification'] as const;
|
||||
const records = states.map((status) => report({ id: `report:${status}`, status }));
|
||||
const original = JSON.stringify(records);
|
||||
await mount(records);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
for (const status of states) {
|
||||
const row = screen.getByTestId(`maintenance-verification-status-${status}`).closest('li')!;
|
||||
expect(within(row).getByText('Window ended 2 mins ago')).toBeInTheDocument();
|
||||
expect(within(row).getByText('Checked 2 mins ago')).toBeInTheDocument();
|
||||
expect(within(row).getByText('Warning alerts').nextElementSibling).toHaveTextContent('2');
|
||||
expect(within(row).getByText('Metric samples').nextElementSibling).toHaveTextContent('3');
|
||||
expect(within(row).getByTestId('maintenance-verification-review')).toBeEnabled();
|
||||
}
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.stringify(records)).toBe(original);
|
||||
});
|
||||
|
||||
it.each(['', 'invalid', '0001-01-01T00:00:00Z'])(
|
||||
'keeps absent or malformed event times unknown (%j)',
|
||||
async (time) => {
|
||||
await mount([
|
||||
report({
|
||||
windowEndedAt: time,
|
||||
completedAt: time,
|
||||
userOutcome: 'reviewed',
|
||||
reviewedAt: time,
|
||||
}),
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.getByText('Window ended unknown')).toBeInTheDocument();
|
||||
expect(screen.getByText('Checked unknown')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reviewed unknown')).toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps clock-skewed past events as ages rather than inventing a scheduled operation', async () => {
|
||||
await mount([report({ completedAt: iso(30_000), windowEndedAt: iso(30_000) })]);
|
||||
expect(screen.getByText('Checked 0s ago')).toBeInTheDocument();
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
expect(screen.getByText('Checked 1 min ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Window ended 1 min ago')).toBeInTheDocument();
|
||||
expect(mutations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('withdraws the old resource reports and ignores a late old-resource response across ticks', async () => {
|
||||
let finishOld!: (value: ReturnType<typeof response>) => void;
|
||||
api.apiFetchJSON.mockImplementation((path: string) =>
|
||||
path.includes('old-resource')
|
||||
? new Promise((resolve) => {
|
||||
finishOld = resolve;
|
||||
})
|
||||
: Promise.resolve(response([])),
|
||||
);
|
||||
const [id, setId] = createSignal('old-resource');
|
||||
render(() => <MaintenanceVerificationSection resourceId={id()} />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
setId('empty-resource');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
finishOld(response([report()]));
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.queryByTestId('maintenance-verification-report')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/No verification reports yet/)).toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not revive withdrawn reports after an access-denied resource read', async () => {
|
||||
api.apiFetchJSON
|
||||
.mockResolvedValueOnce(response([report()]))
|
||||
.mockRejectedValueOnce(new Error('Access denied'));
|
||||
const [id, setId] = createSignal(resourceId);
|
||||
render(() => <MaintenanceVerificationSection resourceId={id()} />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.getByTestId('maintenance-verification-report')).toBeInTheDocument();
|
||||
setId('denied-resource');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.queryByTestId('maintenance-verification-report')).not.toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('requires an explicit review, keeps pending admission disabled and uses the returned review fields', async () => {
|
||||
const saved = report();
|
||||
await mount([saved]);
|
||||
let finishReview!: (value: MaintenanceVerificationReport) => void;
|
||||
api.apiFetchJSON.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishReview = resolve;
|
||||
}),
|
||||
);
|
||||
const button = screen.getByTestId('maintenance-verification-review');
|
||||
fireEvent.click(button);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveTextContent('Marking…');
|
||||
fireEvent.click(button);
|
||||
expect(mutations()).toHaveLength(1);
|
||||
expect(mutations()[0][0]).toBe('/api/maintenance-verifications/report%3Awindow/review');
|
||||
expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent(
|
||||
'Healthy',
|
||||
);
|
||||
expect(screen.queryByText(/Reviewed/)).not.toBeInTheDocument();
|
||||
const reviewed = {
|
||||
...saved,
|
||||
userOutcome: 'reviewed' as const,
|
||||
reviewedAt: iso(60_000),
|
||||
reviewedBy: 'fixture-admin',
|
||||
};
|
||||
api.apiFetchJSON.mockResolvedValue(response([reviewed]));
|
||||
finishReview(reviewed);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.queryByTestId('maintenance-verification-review')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Reviewed 0s ago by fixture-admin')).toBeInTheDocument();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.getByText('Reviewed 1 min ago by fixture-admin')).toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('reruns only after a click, keeps the pending action disabled and retains the old report beside the new check', async () => {
|
||||
const saved = report();
|
||||
await mount([saved]);
|
||||
let finishRerun!: (value: MaintenanceVerificationReport) => void;
|
||||
api.apiFetchJSON.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishRerun = resolve;
|
||||
}),
|
||||
);
|
||||
const button = screen.getByTestId('maintenance-verification-rerun');
|
||||
fireEvent.click(button);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(button).toBeDisabled();
|
||||
fireEvent.click(button);
|
||||
expect(mutations()).toHaveLength(1);
|
||||
expect(mutations()[0][0]).toBe(
|
||||
'/api/resources/cluster%3Apve-a%3A101/maintenance-verifications/rerun',
|
||||
);
|
||||
const rerun = report({
|
||||
id: 'report:window-rerun-1',
|
||||
completedAt: iso(60_000),
|
||||
status: 'needs_review',
|
||||
});
|
||||
api.apiFetchJSON.mockResolvedValue(response([rerun, saved]));
|
||||
finishRerun(rerun);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(button).toBeEnabled();
|
||||
expect(screen.getAllByTestId('maintenance-verification-report')).toHaveLength(2);
|
||||
expect(screen.getByText('Checked 0s ago')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent(
|
||||
'Healthy',
|
||||
);
|
||||
expect(screen.getByTestId('maintenance-verification-status-needs_review')).toHaveTextContent(
|
||||
'Needs review',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(mutations()).toHaveLength(1);
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each(['review', 'rerun'])(
|
||||
'does not retry a refused %s on a clock tick or alter the report',
|
||||
async (action) => {
|
||||
await mount([report()]);
|
||||
api.apiFetchJSON.mockRejectedValueOnce(new Error('Synthetic write refusal'));
|
||||
fireEvent.click(screen.getByTestId(`maintenance-verification-${action}`));
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.getByTestId(`maintenance-verification-${action}`)).toBeEnabled();
|
||||
expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent(
|
||||
'Healthy',
|
||||
);
|
||||
expect(screen.getByText('Window ended 1 min ago')).toBeInTheDocument();
|
||||
expect(mutations()).toHaveLength(1);
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not describe a pending first read as an empty report history', async () => {
|
||||
let finishRead!: (value: ReturnType<typeof response>) => void;
|
||||
api.apiFetchJSON.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishRead = resolve;
|
||||
}),
|
||||
);
|
||||
render(() => <MaintenanceVerificationSection resourceId={resourceId} />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.getByText('Loading verification reports…')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No verification reports yet/)).not.toBeInTheDocument();
|
||||
finishRead(response([]));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.getByText(/No verification reports yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('withdraws loaded reports immediately while another resource read is pending', async () => {
|
||||
api.apiFetchJSON
|
||||
.mockResolvedValueOnce(response([report()]))
|
||||
.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const [id, setId] = createSignal(resourceId);
|
||||
render(() => <MaintenanceVerificationSection resourceId={id()} />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.getByTestId('maintenance-verification-report')).toBeInTheDocument();
|
||||
setId('new-resource-pending');
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.queryByTestId('maintenance-verification-report')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Loading verification reports…')).toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('distinguishes a failed first read from empty history and retries only after an explicit read request', async () => {
|
||||
api.apiFetchJSON.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Synthetic server internals must not render'), { status: 503 }),
|
||||
);
|
||||
render(() => <MaintenanceVerificationSection resourceId={resourceId} />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.getByTestId('maintenance-verification-load-error')).toHaveTextContent(
|
||||
'Verification reports unavailable',
|
||||
);
|
||||
expect(screen.queryByText(/No verification reports yet/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Synthetic server internals/)).not.toBeInTheDocument();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(1);
|
||||
let finishRetry!: (value: ReturnType<typeof response>) => void;
|
||||
api.apiFetchJSON.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishRetry = resolve;
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry reports' }));
|
||||
expect(screen.getByRole('button', { name: 'Retry reports' })).toBeDisabled();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(2);
|
||||
finishRetry(response([report()]));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.queryByTestId('maintenance-verification-load-error')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('maintenance-verification-report')).toBeInTheDocument();
|
||||
expect(mutations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('retains qualified historical reports after a same-resource refresh fails, without inventing a successful review', async () => {
|
||||
const saved = report();
|
||||
await mount([saved]);
|
||||
api.apiFetchJSON
|
||||
.mockResolvedValueOnce(saved)
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Synthetic read failure'), { status: 503 }));
|
||||
fireEvent.click(screen.getByTestId('maintenance-verification-review'));
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.getByTestId('maintenance-verification-load-error')).toHaveTextContent(
|
||||
'Last loaded reports remain below.',
|
||||
);
|
||||
expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent(
|
||||
'Healthy',
|
||||
);
|
||||
expect(screen.getByText('Window ended 1 min ago')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Reviewed/)).not.toBeInTheDocument();
|
||||
expect(mutations()).toHaveLength(1);
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
'withdraws reports on a denied same-resource refresh (%s), rather than offering them as authorised history',
|
||||
async (status) => {
|
||||
const saved = report();
|
||||
await mount([saved]);
|
||||
api.apiFetchJSON
|
||||
.mockResolvedValueOnce(saved)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Synthetic access internals must not render'), { status }),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('maintenance-verification-rerun'));
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(screen.queryByTestId('maintenance-verification-report')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/No verification reports yet/)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('maintenance-verification-load-error')).toHaveTextContent(
|
||||
status === 401
|
||||
? 'Sign in again or check your API token.'
|
||||
: 'Access denied. Check your permissions and license plan.',
|
||||
);
|
||||
expect(screen.queryByText(/Synthetic access internals/)).not.toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(3);
|
||||
const reviewed = report({ userOutcome: 'reviewed', reviewedAt: iso(60_000) });
|
||||
api.apiFetchJSON.mockResolvedValueOnce(response([reviewed]));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry reports' }));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.getByText('Reviewed 0s ago')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('maintenance-verification-load-error')).not.toBeInTheDocument();
|
||||
expect(api.apiFetchJSON).toHaveBeenCalledTimes(4);
|
||||
expect(mutations()).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user