Files
agent_coder 9a6dd0c408 feat(logs): match EE reference — combined datetime range picker, toggle icons
Close the visual gaps vs the maintainer's reference viewer:
- Replace the two separate From/To DateTimeField controls with a single combined
  datetime range picker (@wojtekmaj/react-datetimerange-picker, sibling of the
  react-datetime-picker / react-daterange-picker already used), from-to with time
  in one control — mirrors the existing DateRangePicker wrapper.
- Add icons to the Line numbers (List), Timestamp (Clock) and Wrap lines
  (WrapText) toggles (Auto refresh already had one).
- Line numbers gutter on by default.

Adds one dependency (@wojtekmaj/react-datetimerange-picker); all its transitive
deps were already present via the sibling pickers.

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

182 lines
6.2 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockClipboard } from '@/react/test-utils/clipboard';
import { StreamLogsFn } from './types';
import { LogViewer } from './LogViewer';
// Capture what would be downloaded so we can assert Download acts on the
// currently displayed (optionally filtered) lines.
const saveAsMock = vi.fn();
vi.mock('file-saver', () => ({
saveAs: (...args: unknown[]) => saveAsMock(...args),
}));
const encoder = new TextEncoder();
// jsdom's Blob has no async text(); read it via FileReader instead.
function readBlob(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsText(blob);
});
}
// A fake log source: synchronously emits the given raw text chunks, then keeps
// the stream "open" (resolves only when the caller aborts) so the viewer's live
// path never reconnects mid-test.
function makeStream(chunks: string[]): StreamLogsFn {
return (_params, onChunk, signal) =>
new Promise((resolve) => {
chunks.forEach((chunk) => onChunk(encoder.encode(chunk)));
signal.addEventListener('abort', () => resolve());
});
}
function renderViewer(chunks: string[]) {
return render(
<LogViewer
streamLogs={makeStream(chunks)}
resourceId="container-1"
// TTY (no multiplexed frame headers) so the fake chunks are plain text.
hasFrameHeaders={false}
resourceName="my-container"
/>
);
}
describe('LogViewer', () => {
beforeEach(() => {
saveAsMock.mockClear();
});
it('renders streamed lines with the formatter colour spans', async () => {
// ANSI red-coloured text -> the formatter emits a span with an fgColor.
renderViewer(['red line\nplain line\n']);
const redSpan = await screen.findByText('red line');
expect(redSpan).toBeVisible();
// colour comes straight from the existing formatter (rgb(...) inline style).
expect(redSpan).toHaveStyle({ color: 'rgb(128, 0, 0)' });
expect(await screen.findByText('plain line')).toBeVisible();
});
it('filters to matching lines only when "Filter search results" is checked', async () => {
const user = userEvent.setup();
renderViewer(['apple\nbanana\ncherry\n']);
await screen.findByText('banana');
await user.type(screen.getByTestId('log-viewer-search'), 'ban');
// Not filtering yet: every line is still shown (matches are highlighted).
expect(screen.getByText('apple')).toBeVisible();
const mark = screen.getByText('ban', { selector: 'mark' });
expect(mark).toBeVisible();
await user.click(screen.getByTestId('log-viewer-filter-results'));
// Now only the matching line remains.
await waitFor(() => {
expect(screen.queryByText('apple')).not.toBeInTheDocument();
});
expect(screen.getByText('ban', { selector: 'mark' })).toBeVisible();
expect(screen.getByText('ana')).toBeVisible();
});
it('shows the line-number gutter by default and hides it when toggled off', async () => {
const user = userEvent.setup();
const { container } = renderViewer(['one\ntwo\n']);
await screen.findByText('one');
// Line numbers default on: the gutter is present immediately.
const gutters = container.querySelectorAll('.log-viewer-gutter');
expect(gutters[0]).toHaveTextContent('1');
expect(gutters[1]).toHaveTextContent('2');
await user.click(screen.getByTestId('log-viewer-line-numbers'));
await waitFor(() => {
expect(container.querySelector('.log-viewer-gutter')).toBeNull();
});
});
it('renders the combined datetime range picker (single from – to control)', async () => {
renderViewer(['ready\n']);
await screen.findByText('ready');
// A single combined control replaces the former separate From/To fields.
const picker = screen.getByTestId('log-viewer-datetime-range');
expect(picker).toBeInTheDocument();
// It is the datetime range variant (includes time), not date-only.
expect(
picker.querySelector('.react-datetimerange-picker')
).not.toBeNull();
});
it('renders an icon on the line-number, timestamp and wrap toggles', async () => {
renderViewer(['x\n']);
await screen.findByText('x');
['log-viewer-line-numbers', 'log-viewer-timestamps', 'log-viewer-wrap'].forEach(
(dataCy) => {
expect(screen.getByTestId(dataCy).querySelector('svg')).not.toBeNull();
}
);
});
it('toggles line wrapping via the Wrap lines button', async () => {
const user = userEvent.setup();
renderViewer(['line\n']);
await screen.findByText('line');
const body = screen.getByTestId('log-viewer-body');
// default on
expect(body).toHaveClass('whitespace-pre-wrap');
await user.click(screen.getByTestId('log-viewer-wrap'));
await waitFor(() => {
expect(body).toHaveClass('whitespace-pre');
});
});
it('downloads the currently displayed (filtered) lines', async () => {
const user = userEvent.setup();
renderViewer(['apple\nbanana\n']);
await screen.findByText('banana');
// Filter down to the matching line, then download.
await user.type(screen.getByTestId('log-viewer-search'), 'banana');
await user.click(screen.getByTestId('log-viewer-filter-results'));
await waitFor(() => {
expect(screen.queryByText('apple')).not.toBeInTheDocument();
});
await user.click(screen.getByTestId('log-viewer-download'));
expect(saveAsMock).toHaveBeenCalledTimes(1);
const [blob, filename] = saveAsMock.mock.calls[0];
expect(filename).toBe('my-container_logs.txt');
const text = await readBlob(blob as Blob);
expect(text).toContain('banana');
expect(text).not.toContain('apple');
});
it('copies the displayed lines to the clipboard', async () => {
const { writeText } = mockClipboard();
renderViewer(['hello world\n']);
await screen.findByText('hello world');
fireEvent.click(screen.getByText('Copy'));
await waitFor(() => {
expect(writeText).toHaveBeenCalledTimes(1);
});
expect(writeText.mock.calls[0][0]).toContain('hello world');
});
});