259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createLogRefresh } from '../../src/lib/ui/log-refresh.js';
|
|
|
|
const target = { server: 't6_zombie_town_1', key: 'console', lines: 1000 };
|
|
const response = (line: string) => new Response(JSON.stringify({ ok: true, lines: [line] }));
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: Error) => void;
|
|
const promise = new Promise<T>((done, fail) => {
|
|
resolve = done;
|
|
reject = fail;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
let documentStub: EventTarget & { visibilityState: string };
|
|
let controller: ReturnType<typeof createLogRefresh>;
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
documentStub = Object.assign(new EventTarget(), { visibilityState: 'visible' });
|
|
vi.stubGlobal('document', documentStub);
|
|
});
|
|
afterEach(() => {
|
|
controller?.dispose();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function setup() {
|
|
const fetcher = vi.fn<typeof fetch>();
|
|
const onReset = vi.fn();
|
|
const onData = vi.fn();
|
|
const onError = vi.fn();
|
|
const onLoading = vi.fn();
|
|
controller = createLogRefresh({ fetcher, onReset, onData, onError, onLoading });
|
|
return { fetcher, onReset, onData, onError, onLoading };
|
|
}
|
|
const visibility = (state: string) => {
|
|
documentStub.visibilityState = state;
|
|
documentStub.dispatchEvent(new Event('visibilitychange'));
|
|
};
|
|
|
|
describe('log refresh controls', () => {
|
|
it('retains a manually loaded log through same-server fleet updates and resets only on server navigation', async () => {
|
|
const fetcher = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () => response('loaded log'));
|
|
let displayed: string[] = [];
|
|
const reset = vi.fn(() => {
|
|
displayed = [];
|
|
});
|
|
controller = createLogRefresh({
|
|
fetcher,
|
|
onReset: reset,
|
|
onData: (_key, body) => {
|
|
displayed = body.lines ?? [];
|
|
},
|
|
onError: vi.fn(),
|
|
onLoading: vi.fn()
|
|
});
|
|
controller.configure(target, true, 5000);
|
|
await controller.refresh();
|
|
controller.configure(target, false, 5000);
|
|
await controller.refresh();
|
|
expect(displayed).toEqual(['loaded log']);
|
|
reset.mockClear();
|
|
// The parent supplies fresh props whenever Fleet publishes a snapshot.
|
|
for (let i = 0; i < 3; i++) {
|
|
controller.configure({ ...target }, false, 5000);
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(displayed).toEqual(['loaded log']);
|
|
}
|
|
expect(reset).not.toHaveBeenCalled();
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
controller.configure({ ...target, key: 'games_zm' }, false, 5000);
|
|
await controller.refresh();
|
|
expect(reset).not.toHaveBeenCalled();
|
|
controller.configure({ ...target, server: 'different-server' }, false, 5000);
|
|
expect(reset).toHaveBeenCalledTimes(1);
|
|
expect(displayed).toEqual([]);
|
|
await controller.refresh();
|
|
expect(displayed).toEqual(['loaded log']);
|
|
});
|
|
|
|
it('pauses without fetching again and remains paused across intervals and visibility changes', async () => {
|
|
const { fetcher, onData, onLoading } = setup();
|
|
fetcher.mockResolvedValueOnce(response('saved log'));
|
|
controller.configure(target, true, 5000);
|
|
await controller.refresh();
|
|
controller.configure(target, false, 5000);
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
visibility('hidden');
|
|
visibility('visible');
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
expect(onData).toHaveBeenCalledTimes(1);
|
|
expect(onLoading).toHaveBeenLastCalledWith(false);
|
|
});
|
|
|
|
it('aborts a pending refresh immediately and ignores its late data and loading updates after resume', async () => {
|
|
const { fetcher, onData, onLoading, onError } = setup();
|
|
const old = deferred<Response>();
|
|
const fresh = deferred<Response>();
|
|
fetcher.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
|
|
controller.configure(target, true, 5000);
|
|
const pending = controller.refresh();
|
|
const signal = fetcher.mock.calls[0][1]!.signal!;
|
|
controller.configure(target, false, 5000);
|
|
expect(signal.aborted).toBe(true);
|
|
expect(onLoading).toHaveBeenLastCalledWith(false);
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
controller.configure(target, true, 5000);
|
|
const resumed = controller.refresh();
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
onData.mockClear();
|
|
onError.mockClear();
|
|
onLoading.mockClear();
|
|
// Simulate a transport that delivers a response despite cancellation.
|
|
old.resolve(response('obsolete'));
|
|
await pending;
|
|
expect(onData).not.toHaveBeenCalled();
|
|
expect(onError).not.toHaveBeenCalled();
|
|
expect(onLoading).not.toHaveBeenCalled();
|
|
fresh.resolve(response('current'));
|
|
await resumed;
|
|
expect(onData).toHaveBeenCalledWith(
|
|
'console',
|
|
expect.objectContaining({ lines: ['current'] })
|
|
);
|
|
expect(onLoading).toHaveBeenLastCalledWith(false);
|
|
});
|
|
|
|
it('preserves the loaded tail and suppresses a pending refresh error on pause', async () => {
|
|
const { fetcher, onData, onError } = setup();
|
|
const next = deferred<Response>();
|
|
fetcher.mockResolvedValueOnce(response('saved')).mockReturnValueOnce(next.promise);
|
|
controller.configure(target, true, 5000);
|
|
await controller.refresh();
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
const pending = controller.refresh();
|
|
controller.configure(target, false, 5000);
|
|
onError.mockClear();
|
|
next.reject(new Error('cancelled response'));
|
|
await pending;
|
|
expect(onData).toHaveBeenCalledTimes(1);
|
|
expect(onData).toHaveBeenLastCalledWith(
|
|
'console',
|
|
expect.objectContaining({ lines: ['saved'] })
|
|
);
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows a manual refresh while paused without restarting automatic polling', async () => {
|
|
const { fetcher, onData } = setup();
|
|
fetcher
|
|
.mockResolvedValueOnce(response('initial'))
|
|
.mockResolvedValueOnce(response('manual'));
|
|
controller.configure(target, true, 5000);
|
|
await controller.refresh();
|
|
controller.configure(target, false, 5000);
|
|
await controller.refresh();
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
expect(onData).toHaveBeenLastCalledWith(
|
|
'console',
|
|
expect.objectContaining({ lines: ['manual'] })
|
|
);
|
|
});
|
|
|
|
it('loads a selected log while paused and never accepts a reply from the prior server or tab', async () => {
|
|
const { fetcher, onData } = setup();
|
|
const old = deferred<Response>();
|
|
fetcher.mockReturnValueOnce(old.promise).mockResolvedValueOnce(response('selected log'));
|
|
controller.configure(target, false, 5000);
|
|
const pending = controller.refresh();
|
|
controller.configure({ ...target, server: 'another-server', key: 'games_zm' }, false, 5000);
|
|
await controller.refresh();
|
|
old.resolve(response('prior server'));
|
|
await pending;
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(fetcher.mock.calls[0][1]!.signal!.aborted).toBe(true);
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
expect(fetcher.mock.calls[1][0]).toContain('/another-server/logs/games_zm?lines=1000');
|
|
expect(onData).toHaveBeenCalledTimes(1);
|
|
expect(onData).toHaveBeenLastCalledWith(
|
|
'games_zm',
|
|
expect.objectContaining({ lines: ['selected log'] })
|
|
);
|
|
});
|
|
|
|
it('resumes one polling loop and coalesces manual and scheduled reads', async () => {
|
|
const { fetcher } = setup();
|
|
fetcher.mockImplementation(async () => response('log'));
|
|
controller.configure(target, false, 5000);
|
|
await controller.refresh();
|
|
controller.configure(target, true, 5000);
|
|
controller.configure({ ...target }, true, 5000);
|
|
await Promise.all([controller.refresh(), controller.refresh()]);
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(fetcher).toHaveBeenCalledTimes(3);
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(fetcher).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it('cancels outstanding work and polling on unmount, including a late response body', async () => {
|
|
const { fetcher, onData, onError, onLoading } = setup();
|
|
const body = deferred<unknown>();
|
|
fetcher.mockResolvedValue({ ok: true, json: () => body.promise } as Response);
|
|
controller.configure(target, true, 5000);
|
|
const pending = controller.refresh();
|
|
await Promise.resolve();
|
|
controller.dispose();
|
|
expect(fetcher.mock.calls[0][1]!.signal!.aborted).toBe(true);
|
|
onData.mockClear();
|
|
onError.mockClear();
|
|
onLoading.mockClear();
|
|
body.resolve({ ok: true, lines: ['late body'] });
|
|
await pending;
|
|
visibility('visible');
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
expect(onData).not.toHaveBeenCalled();
|
|
expect(onError).not.toHaveBeenCalled();
|
|
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.');
|
|
});
|