Files
electric-cherry/tests/unit/ui-activity.test.ts
T

232 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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');
});