Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -1,19 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { currentRound, fleetIdentity, matchesFleet, type FleetServer, type FleetOperation } from '../../src/lib/fleet-overview.js';
|
||||
import {
|
||||
currentRound,
|
||||
fleetIdentity,
|
||||
matchesFleet,
|
||||
type FleetServer,
|
||||
type FleetOperation
|
||||
} from '../../src/lib/fleet-overview.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
const now = Date.parse('2026-09-18T12:00:00Z');
|
||||
const iso = (offset = 0) => new Date(now + offset).toISOString();
|
||||
const server: FleetServer = {
|
||||
name: 't6_zombie_town_1', game: 't6', map: 'zm_transit', last_map: 'zm_transit',
|
||||
last_players: 4, last_gametype: 'zstandard', last_seen_at: iso(-1000), last_error: null,
|
||||
roster: ['Alex', 'Blake', 'Casey', 'Josh'].map((name, i) => ({ name, num: i, score: 100 - i, ping: 30, guid: `${i}` })),
|
||||
health: 'online', held: [], active_since: iso(-120_000), inactive_since: null,
|
||||
game_started_at: iso(-60_000), game_ended_at: null
|
||||
name: 't6_zombie_town_1',
|
||||
game: 't6',
|
||||
map: 'zm_transit',
|
||||
last_map: 'zm_transit',
|
||||
last_players: 4,
|
||||
last_gametype: 'zstandard',
|
||||
last_seen_at: iso(-1000),
|
||||
last_error: null,
|
||||
roster: ['Alex', 'Blake', 'Casey', 'Josh'].map((name, i) => ({
|
||||
name,
|
||||
num: i,
|
||||
score: 100 - i,
|
||||
ping: 30,
|
||||
guid: `${i}`
|
||||
})),
|
||||
health: 'online',
|
||||
held: [],
|
||||
active_since: iso(-120_000),
|
||||
inactive_since: null,
|
||||
game_started_at: iso(-60_000),
|
||||
game_ended_at: null
|
||||
};
|
||||
const operation: FleetOperation = {
|
||||
key: 'campaign:one:server',
|
||||
group: 'campaign:one',
|
||||
server: server.name,
|
||||
kind: 'campaign',
|
||||
label: 'Queued',
|
||||
detail: '',
|
||||
href: '/campaigns/one',
|
||||
state: 'active',
|
||||
startedAt: iso()
|
||||
};
|
||||
const operation: FleetOperation = { key: 'campaign:one:server', group: 'campaign:one', server: server.name,
|
||||
kind: 'campaign', label: 'Queued', detail: '', href: '/campaigns/one', state: 'active', startedAt: iso() };
|
||||
|
||||
describe('Fleet discovery and match freshness', () => {
|
||||
it('finds a player outside the displayed top three, including with a map query', () => {
|
||||
@@ -26,48 +57,121 @@ describe('Fleet discovery and match freshness', () => {
|
||||
expect(town.map).toBe('Town Survival');
|
||||
expect(town.group).not.toBe(farm.group);
|
||||
expect(town.launch).toEqual({ game: 't6', slug: 'town' });
|
||||
expect(fleetIdentity({ ...server, name: 'custom', map_slug: 'town' }).launch).toEqual(town.launch);
|
||||
expect(fleetIdentity({ ...server, name: 'custom', map_slug: 'town' }).launch).toEqual(
|
||||
town.launch
|
||||
);
|
||||
expect(fleetIdentity({ ...server, last_map: 'zm_tomb' }).launch).toBeNull();
|
||||
expect(fleetIdentity({ ...server, game_mode: 'mp' }).launch).toBeNull();
|
||||
});
|
||||
it('does not classify stale, missing, or starting occupancy as an empty lobby', () => {
|
||||
for (const change of [{ last_seen_at: iso(-91_000) }, { last_players: null }, { health: 'starting' as const }, { health: 'offline' as const }]) {
|
||||
expect(matchesFleet({ ...server, last_players: 0, ...change }, '', 'empty', '', [], [], now)).toBe(false);
|
||||
for (const change of [
|
||||
{ last_seen_at: iso(-91_000) },
|
||||
{ last_players: null },
|
||||
{ health: 'starting' as const },
|
||||
{ health: 'offline' as const }
|
||||
]) {
|
||||
expect(
|
||||
matchesFleet(
|
||||
{ ...server, last_players: 0, ...change },
|
||||
'',
|
||||
'empty',
|
||||
'',
|
||||
[],
|
||||
[],
|
||||
now
|
||||
)
|
||||
).toBe(false);
|
||||
}
|
||||
expect(matchesFleet({ ...server, last_players: 0 }, '', 'empty', '', [], [], now)).toBe(true);
|
||||
expect(matchesFleet({ ...server, last_players: 0 }, '', 'empty', '', [], [], now)).toBe(
|
||||
true
|
||||
);
|
||||
expect(matchesFleet(server, '', 'occupied', 't5', [], [], now)).toBe(false);
|
||||
});
|
||||
it('includes stale health and failed operations in attention without flagging normal startup', () => {
|
||||
expect(matchesFleet({ ...server, last_seen_at: iso(-91_000) }, '', 'attention', '', [], [], now)).toBe(true);
|
||||
expect(matchesFleet(server, '', 'attention', '', [], [{ ...operation, state: 'attention' }], now)).toBe(true);
|
||||
expect(matchesFleet({ ...server, health: 'starting' }, '', 'attention', '', [], [], now)).toBe(false);
|
||||
expect(
|
||||
matchesFleet(
|
||||
{ ...server, last_seen_at: iso(-91_000) },
|
||||
'',
|
||||
'attention',
|
||||
'',
|
||||
[],
|
||||
[],
|
||||
now
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesFleet(
|
||||
server,
|
||||
'',
|
||||
'attention',
|
||||
'',
|
||||
[],
|
||||
[{ ...operation, state: 'attention' }],
|
||||
now
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesFleet({ ...server, health: 'starting' }, '', 'attention', '', [], [], now)
|
||||
).toBe(false);
|
||||
});
|
||||
it.each(['round_change', 'heartbeat'] as const)('only spotlights fresh %s telemetry from the current occupied match', (event) => {
|
||||
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
|
||||
expect(currentRound(server, report, now)).toBe(38);
|
||||
for (const change of [{ receivedAt: iso(-121_000) }, { receivedAt: iso(-61_000) }, { receivedAt: iso(60_000) }]) {
|
||||
expect(currentRound(server, { ...report, ...change }, now)).toBeNull();
|
||||
it.each(['round_change', 'heartbeat'] as const)(
|
||||
'only spotlights fresh %s telemetry from the current occupied match',
|
||||
(event) => {
|
||||
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
|
||||
expect(currentRound(server, report, now)).toBe(38);
|
||||
for (const change of [
|
||||
{ receivedAt: iso(-121_000) },
|
||||
{ receivedAt: iso(-61_000) },
|
||||
{ receivedAt: iso(60_000) }
|
||||
]) {
|
||||
expect(currentRound(server, { ...report, ...change }, now)).toBeNull();
|
||||
}
|
||||
for (const change of [
|
||||
{ game_ended_at: iso() },
|
||||
{ last_players: 0 },
|
||||
{ health: 'stale' as const },
|
||||
{ active_since: iso() }
|
||||
]) {
|
||||
expect(currentRound({ ...server, ...change }, report, now)).toBeNull();
|
||||
}
|
||||
}
|
||||
for (const change of [{ game_ended_at: iso() }, { last_players: 0 }, { health: 'stale' as const }, { active_since: iso() }]) {
|
||||
expect(currentRound({ ...server, ...change }, report, now)).toBeNull();
|
||||
}
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
describe('persisted Fleet operations', () => {
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture(); });
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it('shows failed update verification even after a healthy restart and clears attention on a new attempt', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
handle.prepare("INSERT INTO maintenance_jobs(server,kind,state,target_revision,requested_at,detail) VALUES ('server','update','failed',5354,?,'Revision not verified')").run(iso());
|
||||
const { fleetOperations, serverOperations } = await import('../../src/lib/server/fleet/overview.js');
|
||||
expect(fleetOperations(now)).toEqual([expect.objectContaining({ kind: 'maintenance', state: 'attention', detail: 'Revision not verified' })]);
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO maintenance_jobs(server,kind,state,target_revision,requested_at,detail) VALUES ('server','update','failed',5354,?,'Revision not verified')"
|
||||
)
|
||||
.run(iso());
|
||||
const { fleetOperations, serverOperations } =
|
||||
await import('../../src/lib/server/fleet/overview.js');
|
||||
expect(fleetOperations(now)).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'maintenance',
|
||||
state: 'attention',
|
||||
detail: 'Revision not verified'
|
||||
})
|
||||
]);
|
||||
expect(serverOperations('server', now)).toEqual(fleetOperations(now));
|
||||
expect(serverOperations('unrelated', now)).toEqual([]);
|
||||
handle.prepare("INSERT INTO maintenance_jobs(server,kind,state,target_revision,requested_at) VALUES ('server','update','queued',5354,?)").run(iso(1000));
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO maintenance_jobs(server,kind,state,target_revision,requested_at) VALUES ('server','update','queued',5354,?)"
|
||||
)
|
||||
.run(iso(1000));
|
||||
expect(fleetOperations(now)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -75,17 +179,36 @@ describe('persisted Fleet operations', () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
handle.prepare("INSERT INTO servers (name) VALUES ('server')").run();
|
||||
handle.prepare("INSERT INTO deployments (id, action, started_at) VALUES ('one', 'promote', ?)").run(iso(-2 * 86_400_000));
|
||||
handle.prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, players) VALUES ('one', 'server', 1, 'pending', 4)").run();
|
||||
const { fleetOperations, serverOperations } = await import('../../src/lib/server/fleet/overview.js');
|
||||
expect(fleetOperations(now)).toEqual([expect.objectContaining({ label: 'Waiting for an empty lobby', state: 'active', href: '/campaigns/one' })]);
|
||||
handle.prepare("UPDATE deployment_servers SET restarted_at = ?, players = NULL").run(iso());
|
||||
handle.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('server', ?)").run(iso());
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO deployments (id, action, started_at) VALUES ('one', 'promote', ?)"
|
||||
)
|
||||
.run(iso(-2 * 86_400_000));
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, players) VALUES ('one', 'server', 1, 'pending', 4)"
|
||||
)
|
||||
.run();
|
||||
const { fleetOperations, serverOperations } =
|
||||
await import('../../src/lib/server/fleet/overview.js');
|
||||
expect(fleetOperations(now)).toEqual([
|
||||
expect.objectContaining({
|
||||
label: 'Waiting for an empty lobby',
|
||||
state: 'active',
|
||||
href: '/campaigns/one'
|
||||
})
|
||||
]);
|
||||
handle.prepare('UPDATE deployment_servers SET restarted_at = ?, players = NULL').run(iso());
|
||||
handle
|
||||
.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('server', ?)")
|
||||
.run(iso());
|
||||
expect(fleetOperations(now)).toHaveLength(1);
|
||||
expect(fleetOperations(now)[0].label).toBe('Recovering');
|
||||
closeDb();
|
||||
expect(fleetOperations(now)[0].label).toBe('Recovering');
|
||||
db().prepare("UPDATE deployment_servers SET restart_state = 'done', checked_at = ?").run(iso());
|
||||
db()
|
||||
.prepare("UPDATE deployment_servers SET restart_state = 'done', checked_at = ?")
|
||||
.run(iso());
|
||||
db().prepare('UPDATE restart_rollout SET healthy_at = ?').run(iso());
|
||||
expect(fleetOperations(now)).toEqual([]);
|
||||
expect(serverOperations('server', now)[0].state).toBe('done');
|
||||
@@ -94,30 +217,78 @@ describe('persisted Fleet operations', () => {
|
||||
it('keeps lifecycle failures actionable until superseded and retains completions only for that server', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
const insert = handle.prepare("INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'test')");
|
||||
insert.run('failed', 'server', 'create', 'failed', 'Creating container', iso(-86_400_001), iso(-86_400_001));
|
||||
const { fleetOperations, serverOperations } = await import('../../src/lib/server/fleet/overview.js');
|
||||
const insert = handle.prepare(
|
||||
"INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'test')"
|
||||
);
|
||||
insert.run(
|
||||
'failed',
|
||||
'server',
|
||||
'create',
|
||||
'failed',
|
||||
'Creating container',
|
||||
iso(-86_400_001),
|
||||
iso(-86_400_001)
|
||||
);
|
||||
const { fleetOperations, serverOperations } =
|
||||
await import('../../src/lib/server/fleet/overview.js');
|
||||
expect(fleetOperations(now)[0].state).toBe('attention');
|
||||
insert.run('retry', 'server', 'create', 'done', 'Ready', iso(), iso());
|
||||
expect(fleetOperations(now)).toEqual([]);
|
||||
expect(serverOperations('server', now)).toEqual([expect.objectContaining({ key: 'deployment:retry', state: 'done' })]);
|
||||
expect(serverOperations('server', now)).toEqual([
|
||||
expect.objectContaining({ key: 'deployment:retry', state: 'done' })
|
||||
]);
|
||||
insert.run('queued', 'new-server', 'create', 'queued', 'Waiting to launch', iso(), null);
|
||||
expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ server: 'new-server', label: 'Waiting to launch', state: 'active' }));
|
||||
expect(fleetOperations(now)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
server: 'new-server',
|
||||
label: 'Waiting to launch',
|
||||
state: 'active'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces orphaned recovery errors and beta gates', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
handle.prepare("INSERT INTO servers (name) VALUES ('server')").run();
|
||||
handle.prepare("INSERT INTO restart_rollout (server, requested_at, error) VALUES ('server', ?, 'RCON not answering')").run(iso());
|
||||
const { fleetOperations, serverOperations } = await import('../../src/lib/server/fleet/overview.js');
|
||||
expect(fleetOperations(now)[0]).toMatchObject({ kind: 'recovery', state: 'attention', detail: 'RCON not answering' });
|
||||
handle.prepare("INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')").run();
|
||||
handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content_hash) VALUES (1, 1, 1, 'hash', 0, NULL)").run();
|
||||
handle.prepare("INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')").run();
|
||||
handle.prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'awaiting-match')").run();
|
||||
expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ kind: 'beta', label: 'Observing a match', href: '/betas/1' }));
|
||||
expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ kind: 'recovery', state: 'attention' }));
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO restart_rollout (server, requested_at, error) VALUES ('server', ?, 'RCON not answering')"
|
||||
)
|
||||
.run(iso());
|
||||
const { fleetOperations, serverOperations } =
|
||||
await import('../../src/lib/server/fleet/overview.js');
|
||||
expect(fleetOperations(now)[0]).toMatchObject({
|
||||
kind: 'recovery',
|
||||
state: 'attention',
|
||||
detail: 'RCON not answering'
|
||||
});
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')"
|
||||
)
|
||||
.run();
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO script_versions (id, script_id, version, sha256, size, content_hash) VALUES (1, 1, 1, 'hash', 0, NULL)"
|
||||
)
|
||||
.run();
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')"
|
||||
)
|
||||
.run();
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'awaiting-match')"
|
||||
)
|
||||
.run();
|
||||
expect(fleetOperations(now)).toContainEqual(
|
||||
expect.objectContaining({ kind: 'beta', label: 'Observing a match', href: '/betas/1' })
|
||||
);
|
||||
expect(fleetOperations(now)).toContainEqual(
|
||||
expect.objectContaining({ kind: 'recovery', state: 'attention' })
|
||||
);
|
||||
expect(serverOperations('server', now)).toEqual(fleetOperations(now));
|
||||
expect(serverOperations('unrelated', now)).toEqual([]);
|
||||
});
|
||||
@@ -131,25 +302,50 @@ describe('persisted Fleet operations', () => {
|
||||
(id, server, action, state, stage, started_at, finished_at, server_deleted_at, triggered_by)
|
||||
VALUES (?, ?, 'restart', ?, 'Stage', ?, ?, ?, 'test')`);
|
||||
let i = 0;
|
||||
for (const state of ['queued', 'running', 'waiting', 'awaiting-health', 'done', 'warning', 'cancelled', 'failed', 'interrupted']) {
|
||||
for (const state of [
|
||||
'queued',
|
||||
'running',
|
||||
'waiting',
|
||||
'awaiting-health',
|
||||
'done',
|
||||
'warning',
|
||||
'cancelled',
|
||||
'failed',
|
||||
'interrupted'
|
||||
]) {
|
||||
for (const finished of [null, iso(-86_400_001), since, iso()]) {
|
||||
for (const deleted of [null, iso()]) {
|
||||
for (const successor of ['none', 'live', 'deleted']) {
|
||||
const id = `operation-${i++}`;
|
||||
insert.run(id, id, state, iso(-i), finished, deleted);
|
||||
if (successor !== 'none') insert.run(`${id}-next`, id, 'done', iso(-i), null, successor === 'deleted' ? iso() : null);
|
||||
if (successor !== 'none')
|
||||
insert.run(
|
||||
`${id}-next`,
|
||||
id,
|
||||
'done',
|
||||
iso(-i),
|
||||
null,
|
||||
successor === 'deleted' ? iso() : null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
// Completed work, including warnings and cancellations, belongs to server history.
|
||||
const expected = handle.prepare(`SELECT d.id FROM server_deployments d
|
||||
const expected = handle
|
||||
.prepare(
|
||||
`SELECT d.id FROM server_deployments d
|
||||
WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'waiting', 'awaiting-health')
|
||||
OR (d.state IN ('failed', 'interrupted') AND NOT EXISTS
|
||||
(SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid)))`)
|
||||
(SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid)))`
|
||||
)
|
||||
.all() as { id: string }[];
|
||||
const { fleetOperations } = await import('../../src/lib/server/fleet/overview.js');
|
||||
expect(fleetOperations(now).map(row => row.key).sort()).toEqual(expected.map(row => `deployment:${row.id}`).sort());
|
||||
expect(
|
||||
fleetOperations(now)
|
||||
.map((row) => row.key)
|
||||
.sort()
|
||||
).toEqual(expected.map((row) => `deployment:${row.id}`).sort());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user