156 lines
7.6 KiB
TypeScript
156 lines
7.6 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 onData = vi.fn(); const onError = vi.fn(); const onLoading = vi.fn();
|
|
controller = createLogRefresh({ fetcher, onData, onError, onLoading });
|
|
return { fetcher, onData, onError, onLoading };
|
|
}
|
|
const visibility = (state: string) => {
|
|
documentStub.visibilityState = state;
|
|
documentStub.dispatchEvent(new Event('visibilitychange'));
|
|
};
|
|
|
|
describe('log refresh controls', () => {
|
|
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();
|
|
});
|
|
});
|