Use friendly UI labels and release feedback (Refs #120)
CI / checks (push) Successful in 6m39s

This commit is contained in:
2026-10-17 09:54:01 -04:00
parent d93a766578
commit 2e6eb0bdb0
8 changed files with 387 additions and 51 deletions
+117
View File
@@ -0,0 +1,117 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import MaintenancePage from '../../src/routes/maintenance/+page.svelte';
import type { MaintenanceOverview } from '../../src/lib/maintenance.js';
import type { PlutoniumRelease } from '../../src/lib/plutonium.js';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({
url: new URL('http://localhost/maintenance'),
data: { user: { role: 'admin' as UserRole } }
});
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('../../src/lib/ui/poll.js', () => ({
pollVisible: (task: () => Promise<void>) => {
void task();
return () => {};
}
}));
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
function overview(release: Partial<PlutoniumRelease> = {}): MaintenanceOverview {
return {
policies: { paused: false, health: true, drift: true, updates: false },
recovery: { enabled: true, lastScanAt: null, scanError: null, incidents: [] },
runtime: {
release: {
revision: null,
checkedAt: '2026-10-17T12:00:00Z',
error: 'Updater manifest request failed (HTTP 503).',
...release
},
schedule: { enabled: false, time: '06:00', timeZone: 'UTC', nextAt: null },
servers: [],
jobs: []
}
};
}
async function show(role: UserRole = 'admin') {
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
component = mount(MaintenancePage, { target: document.body });
flushSync();
await vi.waitFor(() => expect(document.querySelector('#updates')).not.toBeNull());
return document.querySelector<HTMLElement>('#updates')!;
}
const button = (panel: HTMLElement, label: string) =>
[...panel.querySelectorAll('button')].find((node) => node.textContent?.trim() === label);
it('shows unavailable release information on the first failed check and keeps diagnostics closed', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(overview())));
const panel = await show();
const alert = panel.querySelector('[role="alert"]')!;
expect(alert.textContent).toContain('Could not refresh the published Plutonium release.');
expect(alert.textContent).toContain('The published revision is unavailable.');
expect(alert.textContent).toContain('No previous release information is available.');
expect(panel.querySelector('p strong')?.textContent).toBe('Unavailable');
expect(panel.textContent).not.toMatch(/cached|Showing the last known release/);
const details = panel.querySelector('details')!;
expect(details.open).toBe(false);
expect(details.textContent).toContain('Updater manifest request failed (HTTP 503).');
const primary = panel.cloneNode(true) as HTMLElement;
primary.querySelectorAll('details').forEach((node) => node.remove());
expect(primary.textContent).not.toContain('HTTP 503');
expect(panel.textContent).toContain('Last attempted');
});
it('labels a retained revision honestly and clears the failure after retry succeeds', async () => {
let resolve!: (response: Response) => void;
const fetch = vi
.fn()
.mockResolvedValueOnce(Response.json(overview({ revision: 5354 })))
.mockImplementationOnce(() => new Promise<Response>((done) => (resolve = done)));
vi.stubGlobal('fetch', fetch);
const panel = await show();
expect(panel.textContent).toContain('Last known published revision');
expect(panel.textContent).toContain('r5354');
expect(panel.querySelector('[role="alert"]')!.textContent).toContain(
'Showing the last known release; it may be out of date.'
);
button(panel, 'Retry checks')!.click();
flushSync();
expect(fetch).toHaveBeenLastCalledWith('/api/maintenance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'scan' })
});
expect(button(panel, 'Retry checks')!.disabled).toBe(true);
resolve(
Response.json({ ...overview({ revision: 5355, error: null }), notice: 'Checks finished.' })
);
await vi.waitFor(() => expect(panel.querySelector('[role="alert"]')).toBeNull());
expect(panel.textContent).toContain('r5355');
expect(panel.textContent).toContain('Checked');
expect(panel.textContent).not.toMatch(/Last known|Last attempted|HTTP 503/);
});
it('keeps release failure details available without offering an administrator check to viewers', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(overview())));
const panel = await show('viewer');
expect(panel.querySelector('[role="alert"]')!.textContent).toContain(
'Could not refresh the published Plutonium release.'
);
expect(panel.querySelector('details')?.textContent).toContain('HTTP 503');
expect(button(panel, 'Retry checks')).toBeUndefined();
});
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import UsersPage from '../../src/routes/settings/users/+page.svelte';
const page = writable({
data: { user: { id: 'admin', username: 'administrator', role: 'admin' } }
});
vi.mock('$app/stores', () => ({
page: { subscribe: (run: Parameters<typeof page.subscribe>[0]) => page.subscribe(run) }
}));
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
it('describes requests without presenting routes as accounts and preserves their diagnostic evidence', async () => {
const events = [
{
action: 'request',
targetId: '/api/configs/inspect',
details: JSON.stringify({ method: 'POST', status: 200, path: '/api/configs/inspect' })
},
{
action: 'request',
targetId: '/api/servers/[name]',
details: JSON.stringify({ method: 'DELETE', status: 202, path: '/api/servers/demo' })
},
{ action: 'request', targetId: '/api/provisioning', details: '{old malformed details' },
{ action: 'request', targetId: '/api/future/action', details: 'null' },
{ action: 'login', targetId: 'admin', details: '{}' }
].map((event, index) => ({
...event,
id: index + 1,
actorId: 'admin',
actor: 'administrator',
createdAt: Date.now()
}));
vi.stubGlobal(
'fetch',
vi.fn(async (path: string) =>
Response.json(
path.endsWith('/audit')
? { events }
: {
users: [
{
id: 'admin',
username: 'administrator',
displayName: 'Administrator',
role: 'admin',
disabled: false,
mustChangePassword: false,
lastLoginAt: null
}
]
}
)
)
);
component = mount(UsersPage, { target: document.body });
flushSync();
await vi.waitFor(() => expect(document.querySelectorAll('tbody tr')).toHaveLength(5));
const rows = Array.from(document.querySelectorAll<HTMLTableRowElement>('tbody tr'));
expect(rows.map((row) => row.cells[1].textContent)).toEqual([
'Action requested',
'Action requested',
'Action requested',
'Action requested',
'Signed in'
]);
expect(rows.map((row) => row.cells[3].textContent)).toEqual([
'Inspect config',
'Remove server',
'Manage server operation',
'Other action',
'administrator'
]);
expect(document.querySelector('thead')?.textContent).toContain('Account or action');
expect(rows[1].querySelector('details')?.open).toBe(false);
expect(rows[1].querySelector('summary')?.textContent).toBe('Technical details');
expect(JSON.parse(rows[1].querySelector('pre')!.textContent!)).toEqual({
method: 'DELETE',
status: 202,
path: '/api/servers/demo',
routeId: '/api/servers/[name]'
});
expect(rows[2].querySelector('pre')?.textContent).toContain('{old malformed details');
expect(rows[3].querySelector('pre')?.textContent).toContain('/api/future/action');
});