Clarify server actions and group maintenance failures
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '../../src/lib/ui/maintenance-presentation.js';
|
||||
import type { MaintenanceJob, PlutoniumVersion } from '../../src/lib/plutonium.js';
|
||||
|
||||
const theater = 't5_zombie_theater_1';
|
||||
const moon = 't5_zombie_moon_1';
|
||||
const version = (server: string, changes: Partial<PlutoniumVersion> = {}): PlutoniumVersion => ({
|
||||
server, revision: 4878, checked_at: '2026-10-07T12:00:00Z', error: null, update_status: 'unknown', ...changes
|
||||
});
|
||||
const job = (server: string, changes: Partial<MaintenanceJob> = {}): MaintenanceJob => ({
|
||||
id: 1, server, state: 'queued', target_revision: 4878, requested_at: '2026-10-07T12:00:00Z',
|
||||
restarted_at: null, provisioning_id: 'restart-1', finished_at: null, detail: null,
|
||||
kind: 'update', origin: 'manual', ...changes
|
||||
});
|
||||
|
||||
describe('maintenance scan summaries', () => {
|
||||
it('groups shared failures into one cause while retaining each affected server and diagnostic message', () => {
|
||||
const errors = `${theater}: Host agent is offline; /srv/private-one unavailable\n${moon}: Host agent is offline; /srv/private-two unavailable`;
|
||||
const groups = groupMaintenanceScanErrors(errors, [theater, moon]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toMatchObject({ key: 'host', title: 'Host agent unavailable', servers: [theater, moon] });
|
||||
expect(groups[0].details).toHaveLength(2);
|
||||
expect(groups[0].description).not.toContain('/srv/');
|
||||
expect(groups[0].description).not.toContain(theater);
|
||||
expect(groups[0].details[0].message).toContain('/srv/private-one');
|
||||
});
|
||||
|
||||
it('separates file, permission, timeout and unknown causes without exposing raw messages as headings', () => {
|
||||
const groups = groupMaintenanceScanErrors([
|
||||
`${theater}: ENOENT: no such file /srv/missing`,
|
||||
`${moon}: EACCES: permission denied /srv/private`,
|
||||
`${theater}: timeout: no reply`,
|
||||
`${moon}: Unrecognized diagnostic code 123`
|
||||
].join('\n'), [theater, moon]);
|
||||
expect(groups.map(group => group.key)).toEqual(['files', 'permissions', 'timeout', 'other']);
|
||||
expect(groups.every(group => !/ENOENT|EACCES|123/.test(group.title))).toBe(true);
|
||||
});
|
||||
|
||||
it('supports custom names containing colons, avoids linking unknown identifiers and deduplicates identical failures', () => {
|
||||
const message = 'Weekend: west: timeout: No reply';
|
||||
const groups = groupMaintenanceScanErrors(`${message}\n${message}\nretired_server: Agent unavailable`, ['Weekend', 'Weekend: west']);
|
||||
expect(groups[0].servers).toEqual(['Weekend: west']);
|
||||
expect(groups[0].details).toEqual([{ server: 'Weekend: west', message: 'timeout: No reply' }]);
|
||||
expect(groups[1].servers).toEqual([]);
|
||||
expect(groups[1].details[0].server).toBeNull();
|
||||
});
|
||||
|
||||
it('handles setup messages and empty scans without inventing an affected server', () => {
|
||||
expect(groupMaintenanceScanErrors(null, [])).toEqual([]);
|
||||
expect(groupMaintenanceScanErrors(' \n ', [])).toEqual([]);
|
||||
expect(groupMaintenanceScanErrors('Register a native host instance before automatic recovery.', []))
|
||||
.toEqual([expect.objectContaining({ key: 'setup', servers: [] })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maintenance revision groups', () => {
|
||||
it('collapses identical revisions and verification states without claiming that unknown versions are current', () => {
|
||||
const versions = Array.from({ length: 12 }, (_, index) => version(`t5_zombie_theater_${index + 1}`));
|
||||
const groups = groupMaintenanceVersions(versions, []);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toMatchObject({ revision: 4878, label: 'Unverified', tone: 'warning', updatable: [] });
|
||||
expect(groups[0].versions).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('retains separate verification states and puts failures and available updates first', () => {
|
||||
const groups = groupMaintenanceVersions([
|
||||
version('current', { update_status: 'current' }), version('unknown'),
|
||||
version('outdated', { revision: 4800, update_status: 'outdated' }),
|
||||
version('ahead', { revision: 5000, update_status: 'ahead' })
|
||||
], []);
|
||||
expect(groups.map(group => group.status)).toEqual(['unknown', 'outdated', 'current', 'ahead']);
|
||||
expect(groups[0].revision).toBe(groups[2].revision);
|
||||
});
|
||||
|
||||
it('excludes pending restarts from group update actions while preserving the pending operation links', () => {
|
||||
const pending = job(theater, { state: 'restarting' });
|
||||
const [group] = groupMaintenanceVersions([
|
||||
version(theater, { update_status: 'outdated' }), version(moon, { update_status: 'outdated' })
|
||||
], [pending, job(moon, { id: 2, state: 'done' })]);
|
||||
expect(group.updatable).toEqual([moon]);
|
||||
expect(group.pending).toEqual([pending]);
|
||||
});
|
||||
|
||||
it('does not let deleted-server jobs suppress a current server update and preserves stopped legacy evidence', () => {
|
||||
const groups = groupMaintenanceVersions([
|
||||
version(theater, { update_status: 'outdated' }),
|
||||
version(moon, { update_status: undefined, stopped: true, revision: null })
|
||||
], [job(theater, { server_deleted_at: '2026-10-06' })]);
|
||||
expect(groups[0].updatable).toEqual([theater]);
|
||||
expect(groups[0].pending).toEqual([]);
|
||||
expect(groups[1]).toMatchObject({ status: 'stopped', label: 'Intentionally stopped', revision: null, updatable: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { get } from 'svelte/store';
|
||||
import type { FleetServer } from '../../src/lib/fleet-overview.js';
|
||||
import { applicableRuntimeActions, lifecycleImpact } from '../../src/lib/ui/lifecycle-actions.js';
|
||||
import { actionCompleted, actionLabels } from '../../src/lib/provisioning.js';
|
||||
import { uiNow } from '../../src/lib/ui/stores/servers.js';
|
||||
import LifecycleDialog from '../../src/lib/ui/components/LifecycleDialog.svelte';
|
||||
import DestroyServerModal from '../../src/lib/ui/components/DestroyServerModal.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
const now = get(uiNow);
|
||||
const iso = (offset = 0) => new Date(now + offset).toISOString();
|
||||
const server: FleetServer = {
|
||||
name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater',
|
||||
last_gametype: 'zclassic', last_players: 4, last_seen_at: iso(-10_000), last_error: null,
|
||||
container_state: 'running', container_checked_at: iso(-5_000), last_checked_at: iso(-5_000),
|
||||
roster: [], health: 'online', held: [], active_since: iso(-120_000), inactive_since: null,
|
||||
game_started_at: iso(-120_000), game_ended_at: null, host_id: 'host-1',
|
||||
host: { id: 'host-1', name: 'Dallas 1', connected: true, connectionState: 'online', lastHeartbeat: iso(-1_000) }
|
||||
};
|
||||
|
||||
describe('lifecycle action applicability', () => {
|
||||
it('offers Restart, Stop and Rebuild for a responding game, without Start', () => {
|
||||
expect(applicableRuntimeActions(server, now)).toEqual(['restart', 'stop', 'rebuild']);
|
||||
});
|
||||
|
||||
it('offers Start only for a confirmed stopped container with a reachable host', () => {
|
||||
const stopped = { ...server, health: 'offline' as const, container_state: 'stopped' };
|
||||
expect(applicableRuntimeActions(stopped, now)).toEqual(['start', 'rebuild']);
|
||||
for (const change of [
|
||||
{ container_checked_at: iso(-91_000) }, { container_checked_at: null },
|
||||
{ telemetry_after: iso(-2_000) }, { last_seen_at: iso(-2_000) },
|
||||
{ host: null }, { host: { ...server.host!, connected: false } }
|
||||
]) expect(applicableRuntimeActions({ ...stopped, ...change }, now)).not.toContain('start');
|
||||
});
|
||||
|
||||
it('permits recovery of an unresponsive game only with a current running-container observation', () => {
|
||||
const unresponsive = { ...server, health: 'stale' as const, last_seen_at: iso(-300_000), last_error: 'timeout:no reply' };
|
||||
expect(applicableRuntimeActions(unresponsive, now)).toEqual(['restart', 'stop', 'rebuild']);
|
||||
expect(applicableRuntimeActions({ ...unresponsive, container_checked_at: iso(-91_000) }, now)).toEqual([]);
|
||||
expect(applicableRuntimeActions({ ...unresponsive, telemetry_after: iso(-2_000) }, now)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not offer runtime mutations during startup or without evidence', () => {
|
||||
for (const change of [
|
||||
{ health: 'starting' as const }, { health: 'provisioning' as const },
|
||||
{ host: { ...server.host!, connected: false } },
|
||||
{ health: 'offline' as const, container_state: 'unknown', last_seen_at: null }
|
||||
]) expect(applicableRuntimeActions({ ...server, ...change }, now)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle impact copy', () => {
|
||||
it('names current players and round with correct singular grammar', () => {
|
||||
expect(lifecycleImpact('restart', 4, 30)).toBe('4 players are in round 30. Restart waits until the lobby is empty.');
|
||||
expect(lifecycleImpact('stop', 1, null)).toBe('1 player is currently online. Stop waits until the lobby is empty.');
|
||||
});
|
||||
|
||||
it('does not confuse unknown or invalid player counts with an empty lobby', () => {
|
||||
for (const players of [null, -1, NaN, 1.5]) expect(lifecycleImpact('destroy', players, 30))
|
||||
.toBe('Player count is unknown. Removal waits for a live check confirming the lobby is empty.');
|
||||
expect(lifecycleImpact('rebuild', 0, 30)).toBe('The lobby is currently empty. A live check will confirm this before rebuild begins.');
|
||||
});
|
||||
|
||||
it('does not claim a round or occupancy wait when starting a stopped container', () => {
|
||||
expect(lifecycleImpact('start', null, null)).toBe('Start the existing container, then verify that the game is responding.');
|
||||
expect(lifecycleImpact('restart', 4, NaN)).toContain('4 players are currently online.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextual lifecycle confirmation', () => {
|
||||
it('names the server and impact without re-asking the instance or action', () => {
|
||||
const body = render(LifecycleDialog, { props: { open: true, initialServer: server.name,
|
||||
initialAction: 'restart', serverContext: server, players: 4, round: 30 } }).body;
|
||||
expect(body).toContain('Restart Kino Der Toten · Lobby 1?');
|
||||
expect(body).toContain('4 players are in round 30.');
|
||||
expect(body).toContain('Restart when empty');
|
||||
expect(body).not.toContain('<select');
|
||||
expect(body).not.toContain('>Manage server<');
|
||||
});
|
||||
|
||||
it('explains an unavailable action when the evidence no longer supports it', () => {
|
||||
const body = render(LifecycleDialog, { props: { open: true, initialServer: server.name,
|
||||
initialAction: 'start', serverContext: server } }).body;
|
||||
expect(body).toContain('Start is unavailable. The game is responding.');
|
||||
const startButton = [...body.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].find(match => match[0].includes('Start server'))?.[0];
|
||||
expect(startButton).toContain('disabled');
|
||||
expect(body).not.toContain('Start the existing container');
|
||||
});
|
||||
|
||||
it('uses Remove server consistently and preserves recorded history in the explanation', () => {
|
||||
const body = render(DestroyServerModal, { props: { open: true, name: server.name, players: 4, round: 30 } }).body;
|
||||
expect(body).toContain('Remove Kino Der Toten · Lobby 1?');
|
||||
expect(body).toContain('4 players are in round 30.');
|
||||
expect(body).toContain('Remove server when empty');
|
||||
expect(body).toContain('recorded history');
|
||||
expect(body).not.toContain('campaign details');
|
||||
expect(actionLabels.destroy).toBe('Remove');
|
||||
expect(actionCompleted.destroy).toBe('Server removed');
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,14 @@ describe('historical match chat', () => {
|
||||
expect(body).not.toContain('Loading chat');
|
||||
});
|
||||
|
||||
it('explains an offline host without claiming live updates or offering a manual refresh', () => {
|
||||
const body = render(ChatPanel, { props: { server: 't5_zombie_theater_1', hostOffline: true, hostName: 'Dallas 1' } }).body.replaceAll(/<!--.*?-->/g, '');
|
||||
expect(body).toContain('Chat resumes when Dallas 1 reconnects.');
|
||||
expect(body).not.toContain('Live ·');
|
||||
expect(body).not.toContain('Loading chat');
|
||||
expect(body).not.toContain('Refresh chat');
|
||||
});
|
||||
|
||||
it('explains when a historical match has no saved messages', () => {
|
||||
const body = render(ChatPanel, { props: { history: [] } }).body;
|
||||
expect(body).toContain('No chat messages were saved for this match.');
|
||||
|
||||
Reference in New Issue
Block a user