Simplify instance details and clarify diagnostics
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
let fixture: Fixture;
|
||||
const name = 't5_zombie_theater_1';
|
||||
beforeEach(async () => {
|
||||
vi.resetModules(); fixture = await newFixture([name]);
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
});
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
|
||||
it('links only inventory whose exact content matches its library version', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const artifactId = Number(db().prepare("INSERT INTO scripts(name, target_rel_path, kind) VALUES ('utils', 'utils.dll', 'plugin')").run().lastInsertRowid);
|
||||
const versionId = Number(db().prepare("INSERT INTO script_versions(script_id, version, release_version, sha256, size) VALUES (?, 2, 'v1.1.0', 'known', 10)").run(artifactId).lastInsertRowid);
|
||||
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
|
||||
recordScript(name, 'utils.dll', versionId, 'known', 10, 'native-create');
|
||||
recordScript(name, 'changed.dll', versionId, 'different', 12, 'native-create');
|
||||
recordScript(name, 'unknown.gsc', null, 'unknown', 2, 'native-create');
|
||||
const { GET } = await import('../../src/routes/api/servers/[name]/scripts/+server.js');
|
||||
const response = await GET({ params: { name } });
|
||||
expect(response.status).toBe(200);
|
||||
const { scripts } = await response.json();
|
||||
expect(scripts.find((item: { relPath: string }) => item.relPath === 'utils.dll')).toMatchObject({ artifactId, version: 2, releaseVersion: 'v1.1.0', kind: 'plugin' });
|
||||
expect(scripts.find((item: { relPath: string }) => item.relPath === 'changed.dll')).toMatchObject({ artifactId: null, version: null, releaseVersion: null });
|
||||
expect(scripts.find((item: { relPath: string }) => item.relPath === 'unknown.gsc')).toMatchObject({ artifactId: null, version: null, kind: 'script' });
|
||||
});
|
||||
@@ -184,3 +184,28 @@ describe('log refresh controls', () => {
|
||||
expect(onLoading).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the last tail on a non-JSON gateway failure and can recover with a retry', async () => {
|
||||
const { fetcher, onData, onError } = setup();
|
||||
fetcher.mockResolvedValueOnce(response('saved'))
|
||||
.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502 }))
|
||||
.mockResolvedValueOnce(response('recovered'));
|
||||
controller.configure(target, false, 5000);
|
||||
await controller.refresh();
|
||||
await controller.refresh();
|
||||
expect(onError).toHaveBeenLastCalledWith('HTTP 502: Expected a JSON log response.');
|
||||
expect(onData).toHaveBeenCalledTimes(1);
|
||||
expect(onData).toHaveBeenLastCalledWith('console', expect.objectContaining({ lines: ['saved'] }));
|
||||
await controller.refresh();
|
||||
expect(onError).toHaveBeenLastCalledWith('');
|
||||
expect(onData).toHaveBeenLastCalledWith('console', expect.objectContaining({ lines: ['recovered'] }));
|
||||
});
|
||||
|
||||
it('rejects malformed successful log payloads before they reach the renderer', async () => {
|
||||
const { fetcher, onData, onError } = setup();
|
||||
fetcher.mockResolvedValue(new Response(JSON.stringify({ ok: true, lines: [42] })));
|
||||
controller.configure(target, false, 5000);
|
||||
await controller.refresh();
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenLastCalledWith('Invalid log response: expected an array of lines.');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { writable } from 'svelte/store';
|
||||
import { get } from 'svelte/store';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import type { GameTelemetry } from '../../src/lib/telemetry.js';
|
||||
import type { FleetOperation } from '../../src/lib/fleet-overview.js';
|
||||
import ServerPage from '../../src/routes/servers/[name]/+page.svelte';
|
||||
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
|
||||
import ConfigInspector from '../../src/lib/ui/components/ConfigInspector.svelte';
|
||||
import { uiNow } from '../../src/lib/ui/stores/servers.js';
|
||||
import { instanceActivity } from '../../src/lib/ui/server-activity.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/servers/t5_zombie_theater_1') } }));
|
||||
vi.mock('$app/stores', () => ({ page: writable({ params: { name: 't5_zombie_theater_1' }, url: new URL('http://localhost/servers/t5_zombie_theater_1') }) }));
|
||||
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), afterNavigate: vi.fn(), goto: vi.fn() }));
|
||||
|
||||
const report = (current = true): GameTelemetry => {
|
||||
const receivedAt = new Date(get(uiNow) - (current ? 10_000 : 300_000)).toISOString();
|
||||
const joined = new Date(Date.parse(receivedAt) - 60_000).toISOString();
|
||||
return { ...gameEvent('heartbeat', 4, 1), receivedAt, timing: { matchId: 1, players: {
|
||||
'12345678901234560': { serverJoinedAt: joined, matchJoinedAt: joined, roundStarted: 1 }
|
||||
} } };
|
||||
};
|
||||
|
||||
it('keeps scoreboard columns focused and collapses player identifiers and timings', () => {
|
||||
const body = render(PlayerScoreboard, { props: { report: report(), matchElapsed: '4m 19s' } }).body;
|
||||
expect(body).toContain('Live scoreboard');
|
||||
expect(body).toContain('Round 4');
|
||||
expect(body).toContain('Match 4m 19s');
|
||||
expect(body).toContain('aria-expanded="false"');
|
||||
expect(body).toMatch(/<tr[^>]*hidden/);
|
||||
expect(body).toContain('Time on instance: 1m 10s');
|
||||
expect(body).toContain('aria-label="Copy GUID for Player 1"');
|
||||
expect(body).not.toContain('>Time on server</th>');
|
||||
expect(body).not.toContain('>Round started</th>');
|
||||
expect(body).toContain('aria-sort="descending"');
|
||||
expect(body).toContain('About these stats');
|
||||
});
|
||||
|
||||
it('freezes historical timing and keeps unreported scores distinct from zero', () => {
|
||||
const body = render(PlayerScoreboard, { props: { report: report(false) } }).body;
|
||||
expect(body).toContain('Last reported scoreboard');
|
||||
expect(body).toContain('in last report');
|
||||
expect(body).toContain('Time on instance: 1m 00s');
|
||||
expect(body).not.toContain('Time on instance: 6m');
|
||||
const start = { ...gameEvent('game_start', 0, 1), receivedAt: report().receivedAt };
|
||||
expect(render(PlayerScoreboard, { props: { report: start } }).body).toContain('aria-label="Not yet reported"');
|
||||
});
|
||||
|
||||
it('omits the fixed instance selector while keeping promoted/live distinction', () => {
|
||||
const body = render(ConfigInspector, { props: { fixedServer: 't5_zombie_theater_1' } }).body;
|
||||
expect(body).not.toContain('<select');
|
||||
expect(body).toContain('Promoted configuration');
|
||||
expect(body).toContain('Live files and active betas may differ');
|
||||
});
|
||||
|
||||
const op = (changes: Partial<FleetOperation> = {}): FleetOperation => ({ key: 'deployment:1', group: 'deployment:1', server: 'one', kind: 'deployment', label: 'Complete', activityLabel: 'Restart instance', resultState: 'cancelled', detail: 'Checking game status', href: '/provisioning/1', state: 'done', startedAt: '2026-10-06T12:00:00Z', ...changes });
|
||||
it('merges duplicate campaign records, retains provisioning and preserves the real outcome', () => {
|
||||
const rows = instanceActivity('one', [op(), op({ key: 'campaign:x:one', kind: 'campaign', href: '/campaigns/x' }), op({ key: 'other', server: 'two' })], [{
|
||||
id: 'x', action: 'promote', started_at: '2026-10-06T13:00:00Z', servers: [{ server: 'one', restart_state: 'failed', error: 'Restart failed', files_changed: 1 }]
|
||||
}]);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ label: 'Promote files · 1 file', state: 'failed', detail: 'Restart failed' });
|
||||
expect(rows[1]).toMatchObject({ label: 'Restart instance', state: 'cancelled', href: '/provisioning/1' });
|
||||
});
|
||||
|
||||
function pageBody(tab: string) {
|
||||
(page as unknown as ReturnType<typeof writable>).set({ params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) });
|
||||
const telemetry = report();
|
||||
const server = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', health: 'online', last_players: 1,
|
||||
last_seen_at: telemetry.receivedAt, last_error: null, held: [], telemetry, status_source: 'telemetry', host_id: 'host-1', native_instance_id: 'instance-1',
|
||||
host: { name: 'ec-1', connected: true, lastHeartbeat: telemetry.receivedAt },
|
||||
rcon_status: { last_seen_at: null, last_error: 'timeout: no reply' }, game_started_at: null, game_ended_at: null, active_since: null, inactive_since: null };
|
||||
const data = { serverSnapshot: { servers: [server], operations: [], serverNow: get(uiNow), integrations: {}, reservedNames: [] } } as unknown as ComponentProps<typeof ServerPage>['data'];
|
||||
return render(ServerPage, { props: { data } }).body;
|
||||
}
|
||||
it('keeps match content out of the shared header and preserves the fleet return filter', () => {
|
||||
const body = pageBody('configuration');
|
||||
expect(body).toContain('Instance actions');
|
||||
expect(body).toContain('href="/?filter=occupied"');
|
||||
expect(body).toContain('ec-1');
|
||||
expect(body).not.toContain('Active for');
|
||||
expect(body).not.toContain('Round 4');
|
||||
expect(body).not.toContain('aria-label="Configuration sections"');
|
||||
});
|
||||
it('reports RCON failures independently of fresh telemetry and defers the request inspector', () => {
|
||||
const body = pageBody('diagnostics');
|
||||
expect(body).toContain('Game connection · RCON');
|
||||
expect(body).toContain('Issue');
|
||||
expect(body).toContain('Reporting');
|
||||
expect(body).toContain('Player count source: game telemetry');
|
||||
expect(body).toContain('Connection details');
|
||||
expect(body).not.toContain('Telemetry API requests');
|
||||
expect(body).not.toContain('>Healthy<');
|
||||
});
|
||||
Reference in New Issue
Block a user