Files
portainer/app/react/components/LogViewer/useLogViewer.test.ts
T
agent_coder 7f02d20e54 feat(logs): React log viewer for container logs
Replace the legacy AngularJS <log-viewer> on the container logs page with a
modern React log viewer, reusing the existing streaming (#6) and formatting/
coloring pipeline. Features: line-number gutter, zerolog level + key=value
coloring (from the existing formatter spans), from/to datetime range, Lines
limit, Line numbers / Timestamp / Wrap lines toggles, Auto refresh (live tail
on/off), Search + 'Filter search results', Copy, Download logs, and fullscreen.

The viewer is source-agnostic (StreamLogsFn), so service/task logs can adopt it
later; this PR wires container logs only. containerLogsController.js no longer
opens its own live stream (React owns fetching now), preventing a double stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:05:56 +03:00

109 lines
3.4 KiB
TypeScript

import { waitFor } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react-hooks';
import { StreamLogsParams } from '@/react/docker/containers/containers.service';
import { StreamLogsFn } from './types';
import { useLogViewer } from './useLogViewer';
const encoder = new TextEncoder();
describe('useLogViewer', () => {
it('fetches a single bounded snapshot (no follow, no reconnect) when auto refresh is off', async () => {
let callCount = 0;
let capturedParams: StreamLogsParams | undefined;
// A snapshot source: emits two complete lines plus a trailing partial line
// (no newline) then resolves — mirroring a non-following body that ends.
// eslint-disable-next-line func-style
const streamLogs: StreamLogsFn = (params, onChunk) => {
callCount += 1;
capturedParams = params;
onChunk(encoder.encode('first\nsecond\ntrailing-partial'));
return Promise.resolve();
};
const { result } = renderHook(() =>
useLogViewer({
streamLogs,
resourceId: 'container-1',
stripHeaders: false,
autoRefresh: false,
withTimestamps: false,
lineCount: 100,
since: new Date('2020-01-01T00:00:00Z'),
until: new Date('2020-01-02T00:00:00Z'),
reloadNonce: 0,
})
);
// The trailing partial line is flushed when the body ends, so all three
// lines are shown.
await waitFor(() => {
expect(result.current.logs).toHaveLength(3);
});
expect(result.current.logs.map((l) => l.line)).toEqual([
'first',
'second',
'trailing-partial',
]);
// follow off, until bound applied, and no reconnect (called exactly once).
expect(capturedParams?.follow).toBe(false);
expect(capturedParams?.until).toBe(
Math.floor(new Date('2020-01-02T00:00:00Z').getTime() / 1000)
);
expect(callCount).toBe(1);
});
it('clears the error banner when a following stream recovers on reconnect', async () => {
vi.useFakeTimers();
try {
let call = 0;
// First connect rejects (surfaces the banner); the reconnect succeeds and
// delivers a line (should clear the banner).
// eslint-disable-next-line func-style
const streamLogs: StreamLogsFn = (_params, onChunk) => {
call += 1;
if (call === 1) {
return Promise.reject(new Error('boom'));
}
onChunk(encoder.encode('recovered\n'));
// stay open so no further reconnect fires during the test
return new Promise(() => {});
};
const { result } = renderHook(() =>
useLogViewer({
streamLogs,
resourceId: 'container-1',
stripHeaders: false,
autoRefresh: true,
withTimestamps: false,
lineCount: 100,
since: null,
until: null,
reloadNonce: 0,
})
);
// Let the initial rejection settle -> banner shows.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.error).toBe('boom');
// Advance past the 3s reconnect delay + the 250ms flush window.
await act(async () => {
await vi.advanceTimersByTimeAsync(3300);
});
expect(result.current.error).toBeUndefined();
expect(result.current.logs.map((l) => l.line)).toContain('recovered');
expect(call).toBe(2);
} finally {
vi.useRealTimers();
}
});
});