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() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; } let documentStub: EventTarget & { visibilityState: string }; let controller: ReturnType; 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(); 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().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(); const fresh = deferred(); 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(); 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(); 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(); 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(); }); });