232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||
import { render } from 'svelte/server';
|
||
import type { ComponentProps } from 'svelte';
|
||
import { writable, type Writable } from 'svelte/store';
|
||
import { page } from '$app/stores';
|
||
import ActivityLog from '../../src/lib/ui/components/ActivityLog.svelte';
|
||
import ActivityPage from '../../src/routes/activity/+page.svelte';
|
||
import {
|
||
activityApiHref,
|
||
activityHistoryHref,
|
||
activityQuery,
|
||
activityTimeLabel
|
||
} from '../../src/lib/ui/activity-history.js';
|
||
import type { ActivityPage as ActivityReport, ActivityRow } from '../../src/lib/activity.js';
|
||
|
||
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/activity') }) }));
|
||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||
vi.mock('$ui/stores/fleet.js', () => ({
|
||
fleet: writable({ servers: [], operations: [], receivedAt: 0 })
|
||
}));
|
||
vi.mock('$ui/stores/servers.js', () => ({ uiNow: writable(Date.parse('2026-10-07T18:00:00Z')) }));
|
||
|
||
const row: ActivityRow = {
|
||
id: 'operation-1',
|
||
at: '2026-10-07T17:59:45Z',
|
||
type: 'restart',
|
||
source: 'maintenance',
|
||
sources: ['maintenance', 'campaign'],
|
||
state: 'succeeded',
|
||
rawState: 'done',
|
||
title: 'Restart server',
|
||
detail: 'Verified game readiness.',
|
||
server: 't6_zombie_town_1',
|
||
serverDeletedAt: null,
|
||
hostId: 'host-a',
|
||
hostName: 'Dallas 1',
|
||
href: '/provisioning/operation-1'
|
||
};
|
||
const report = (): ActivityReport => ({
|
||
rows: [{ ...row }],
|
||
total: 52,
|
||
limit: 25,
|
||
offset: 0,
|
||
query: { limit: 25, offset: 0 }
|
||
});
|
||
function scope(path = '/activity') {
|
||
(page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost${path}`) });
|
||
}
|
||
beforeEach(() => {
|
||
scope();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2026-10-07T18:00:00Z'));
|
||
});
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
it('sends only supported normalized filters and enforces scoped identities', () => {
|
||
const url = new URL(
|
||
'https://example.test/servers/town?tab=history&server=wrong&source=host&state=failed&offset=50&returnTo=%2F'
|
||
);
|
||
const href = activityApiHref(url, { server: 't6_zombie_town_1', source: 'maintenance' });
|
||
const params = new URL(href, url).searchParams;
|
||
expect(params.get('server')).toBe('t6_zombie_town_1');
|
||
expect(params.get('source')).toBe('maintenance');
|
||
expect(params.get('state')).toBe('failed');
|
||
expect(params.get('offset')).toBe('50');
|
||
expect(params.get('limit')).toBe('25');
|
||
expect(params.has('tab')).toBe(false);
|
||
expect(params.has('returnTo')).toBe(false);
|
||
expect(
|
||
activityQuery(new URL('https://example.test/activity?type=unknown&days=0')).type
|
||
).toBeUndefined();
|
||
});
|
||
|
||
it('resets pagination when filtering and preserves the containing page, hash, and return context', () => {
|
||
const current = new URL(
|
||
'https://example.test/servers/town?tab=history&offset=50&source=campaign&returnTo=%2F%3Ffilter%3Dattention#history'
|
||
);
|
||
const filtered = new URL(
|
||
activityHistoryHref(current, { state: 'failed', q: 'Dallas lobby' }),
|
||
current
|
||
);
|
||
expect(filtered.searchParams.get('offset')).toBeNull();
|
||
expect(filtered.searchParams.get('tab')).toBe('history');
|
||
expect(filtered.searchParams.get('source')).toBe('campaign');
|
||
expect(filtered.searchParams.get('returnTo')).toBe('/?filter=attention');
|
||
expect(filtered.hash).toBe('#history');
|
||
expect(filtered.searchParams.get('q')).toBe('Dallas lobby');
|
||
expect(
|
||
new URL(activityHistoryHref(filtered, { offset: '25' }), current).searchParams.get('state')
|
||
).toBe('failed');
|
||
});
|
||
|
||
it('keeps provisioning excluded from log polling after filtering or clearing filters', () => {
|
||
const url = new URL('http://localhost/activity?type=create&excludeProvisioning=false');
|
||
const scope = { excludeProvisioning: true };
|
||
expect(new URL(activityApiHref(url, scope), url).searchParams.get('excludeProvisioning')).toBe(
|
||
'true'
|
||
);
|
||
const cleared = new URL(activityHistoryHref(url, { type: '', source: '', offset: '' }), url);
|
||
expect(activityQuery(cleared, scope).excludeProvisioning).toBe(true);
|
||
expect(
|
||
activityQuery(new URL('http://localhost/servers/town?tab=history')).excludeProvisioning
|
||
).toBeUndefined();
|
||
});
|
||
|
||
it('keeps maintenance excluded from log polling while preserving the Maintenance history scope', () => {
|
||
const url = new URL('http://localhost/activity?source=maintenance&excludeMaintenance=false');
|
||
const logScope = { excludeProvisioning: true, excludeMaintenance: true };
|
||
expect(
|
||
new URL(activityApiHref(url, logScope), url).searchParams.get('excludeMaintenance')
|
||
).toBe('true');
|
||
const cleared = new URL(activityHistoryHref(url, { type: '', source: '', offset: '' }), url);
|
||
expect(activityQuery(cleared, logScope).excludeMaintenance).toBe(true);
|
||
const maintenance = new URL('http://localhost/maintenance?view=activity');
|
||
const query = new URL(
|
||
activityApiHref(maintenance, { source: 'maintenance', excludeMaintenance: false }),
|
||
maintenance
|
||
).searchParams;
|
||
expect(query.get('source')).toBe('maintenance');
|
||
expect(query.has('excludeMaintenance')).toBe(false);
|
||
});
|
||
|
||
it('shows relative recent timestamps and preserves accurate older dates and invalid evidence', () => {
|
||
const now = Date.parse('2026-10-07T18:00:00Z');
|
||
expect(activityTimeLabel('2026-10-07 17:59:45', 'America/New_York', now)).toBe('15 s ago');
|
||
expect(activityTimeLabel('2026-10-07T17:50:00Z', 'America/New_York', now)).toBe('10 min ago');
|
||
expect(activityTimeLabel('2026-10-07T16:00:00Z', 'America/New_York', now)).toBe('2 h ago');
|
||
expect(activityTimeLabel('2026-10-06T17:00:00Z', 'America/New_York', now)).toBe(
|
||
'Yesterday, 1:00 PM'
|
||
);
|
||
expect(activityTimeLabel('not a date', 'UTC', now)).toBe('Time unavailable');
|
||
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe(
|
||
'Oct 8, 2026, 2:00 PM EDT'
|
||
);
|
||
});
|
||
|
||
it('renders one page heading with provisioning management and filters removed', () => {
|
||
const body = render(ActivityPage, {
|
||
props: {
|
||
data: {
|
||
activity: report(),
|
||
user: { role: 'admin' },
|
||
setupRequired: false
|
||
} as ComponentProps<typeof ActivityPage>['data']
|
||
}
|
||
}).body;
|
||
expect(body.match(/<h1[ >]/g)).toHaveLength(1);
|
||
expect(body).not.toContain('Manage servers');
|
||
expect(body).not.toContain('href="/provisioning"');
|
||
expect(body).not.toContain('<option value="create"');
|
||
expect(body).not.toContain('<option value="provisioning"');
|
||
expect(body).not.toContain('<option value="restart"');
|
||
for (const value of ['update', 'health', 'maintenance', 'recovery'])
|
||
expect(body).not.toContain(`<option value="${value}"`);
|
||
expect(body).toContain('<option value="drift"');
|
||
const viewer = render(ActivityPage, {
|
||
props: {
|
||
data: {
|
||
activity: report(),
|
||
user: { role: 'viewer' },
|
||
setupRequired: false
|
||
} as ComponentProps<typeof ActivityPage>['data']
|
||
}
|
||
}).body;
|
||
expect(viewer).not.toContain('Manage servers');
|
||
});
|
||
|
||
it('renders named operation links, shared source memberships, friendly resources, and one freshness indicator', () => {
|
||
scope('/activity?state=succeeded&offset=25');
|
||
const data = report();
|
||
data.offset = 25;
|
||
const body = render(ActivityLog, { props: { initial: data } }).body;
|
||
expect(body).toContain('Town Survival');
|
||
expect(body).toContain('Dallas 1');
|
||
expect(body).toContain('Restart server · Town Survival · Lobby 1');
|
||
expect(body).toContain('Maintenance & recovery · Campaigns');
|
||
expect(body).toContain('Completed');
|
||
expect(body).toContain('shape-circle');
|
||
expect(body).toContain('15 s ago');
|
||
expect(body).toContain('datetime="2026-10-07T17:59:45Z"');
|
||
expect(body).toContain('returnTo=%2Factivity');
|
||
expect(body).toContain('state%3Dsucceeded');
|
||
expect(body).toContain('offset%3D25');
|
||
expect(body).toContain('26–26 of 52');
|
||
expect(body).toContain('offset=50');
|
||
expect(body.match(/Updates every 10 seconds/g)).toHaveLength(1);
|
||
expect(body).not.toContain('aria-live');
|
||
});
|
||
|
||
it('preserves deleted server evidence without linking to a replacement instance', () => {
|
||
const data = report();
|
||
data.rows = [{ ...row, serverDeletedAt: '2026-10-07T17:59:46Z' }];
|
||
const body = render(ActivityLog, { props: { initial: data } }).body;
|
||
expect(body).toContain('Town Survival');
|
||
expect(body).toContain('Deleted');
|
||
expect(body).not.toContain('href="/servers/');
|
||
expect(body).toContain('/provisioning/operation-1?returnTo=');
|
||
});
|
||
|
||
it('offers one mobile filter sheet with distinct IDs and hides fixed scope controls', () => {
|
||
const body = render(ActivityLog, { props: { initial: report() } }).body;
|
||
expect(body).toContain('Activity filters');
|
||
expect(body).toContain('id="activity-host-mobile"');
|
||
expect(body).toContain('id="activity-search-mobile"');
|
||
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
scope('/maintenance?view=activity&state=failed');
|
||
const fixed = render(ActivityLog, {
|
||
props: { initial: report(), server: row.server!, source: 'maintenance' }
|
||
}).body;
|
||
expect(fixed).not.toContain('id="activity-server');
|
||
expect(fixed).not.toContain('id="activity-source');
|
||
expect(fixed).toContain('<option value="update"');
|
||
expect(fixed).toContain('<option value="health"');
|
||
expect(fixed).toContain('view=activity');
|
||
expect(fixed).toContain('state=failed');
|
||
expect(fixed).toContain('Filters · 1');
|
||
});
|
||
|
||
it('offers useful empty and out-of-range page recovery', () => {
|
||
scope('/activity?q=missing&offset=100');
|
||
const data = report();
|
||
data.rows = [];
|
||
data.offset = 100;
|
||
const body = render(ActivityLog, { props: { initial: data } }).body;
|
||
expect(body).toContain('No matching activity');
|
||
expect(body).toContain('Clear filters');
|
||
expect(body).toContain('No activities on this page');
|
||
expect(body).toContain('offset=75');
|
||
expect(body).toContain('Previous');
|
||
});
|