Files
electric-cherry/tests/unit/log-refresh.test.ts
T

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.');
});