diff --git a/.gitignore b/.gitignore index e386e9c..b0e260a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +slogx_logs/ node_modules dist diff --git a/ReplayApp.tsx b/ReplayApp.tsx new file mode 100644 index 0000000..7ac55c6 --- /dev/null +++ b/ReplayApp.tsx @@ -0,0 +1,213 @@ +import { useState, useRef, useEffect } from 'preact/hooks'; +import type { FunctionComponent } from 'preact'; +import { LogEntry, LogLevel, FilterState } from './types'; +import LogDetailsPanel from './components/LogDetailsPanel'; +import SetupModal from './components/SetupModal'; +import FilterBar from './components/FilterBar'; +import LogList from './components/LogList'; +import FullScreenDropZone from './components/FullScreenDropZone'; +import { useLogFilter } from './hooks/useLogFilter'; +import { useSplitPane } from './hooks/useSplitPane'; +import { useToggleSelection } from './hooks/useToggleSelection'; +import { parseNDJSON } from './services/fileParser'; +import { Settings, FileText, RefreshCw, X } from 'lucide-preact'; + +const ReplayApp: FunctionComponent = () => { + const [logs, setLogs] = useState([]); + const { selected: selectedLog, setSelected: setSelectedLog, toggle: toggleLogSelection } = useToggleSelection(); + const [fileInfo, setFileInfo] = useState<{ name: string; count: number } | null>(null); + const [isLoading, setIsLoading] = useState(false); + + const [filter, setFilter] = useState({ + search: '', + levels: new Set([LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]), + paused: false + }); + + const { splitContainerRef, panelHeight, startResize } = useSplitPane(); + const [showSetup, setShowSetup] = useState(false); + const fileInputRef = useRef(null); + + const filteredLogs = useLogFilter(logs, filter, new Set()); + + const handleFileLoad = async (file: File) => { + setIsLoading(true); + try { + setLogs([]); + const entries = await parseNDJSON(file); + entries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + setLogs(entries); + setFileInfo({ name: file.name, count: entries.length }); + setSelectedLog(null); + } catch (e) { + alert('Failed to parse log file'); + console.error(e); + } finally { + setIsLoading(false); + } + }; + + const handleUrlLoad = async (url: string) => { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + const filename = url.split('/').pop() || 'remote.ndjson'; + const file = new File([blob], filename, { type: 'application/x-ndjson' }); + await handleFileLoad(file); + }; + + // Check for ?url= query param on mount + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const url = params.get('url'); + if (url) { + setIsLoading(true); + handleUrlLoad(url).catch(err => { + console.error('Failed to load remote log:', err); + alert(`Failed to load remote log: ${err.message}`); + }).finally(() => setIsLoading(false)); + } + }, []); + + const handleClear = () => { + setLogs([]); + setFileInfo(null); + setSelectedLog(null); + }; + + // Show full-screen drop zone when no file is loaded + if (!fileInfo && !isLoading) { + return ( +
+
+
+ slogx + REPLAY MODE +
+
+ +
+
+ + + + setShowSetup(false)} /> +
+ ); + } + + // Show logs view when file is loaded + return ( +
+
+
+ slogx + REPLAY MODE +
+ + {fileInfo && ( +
+ + {fileInfo.name} + ({fileInfo.count} events) + + + { + const files = (e.target as HTMLInputElement).files; + if (files?.[0]) handleFileLoad(files[0]); + (e.target as HTMLInputElement).value = ''; + }} + /> +
+ )} + +
+ +
+
+ + setLogs([])} + showPauseResume={false} + /> + +
+
+ +

No matching logs

+

Try adjusting your filters

+
+ } + /> +
+ + {selectedLog && ( +
+ +
+ )} + + {selectedLog && ( +
+ setSelectedLog(null)} + /> +
+ )} +
+ + setShowSetup(false)} /> + + ); +}; + +export default ReplayApp; diff --git a/components/FullScreenDropZone.test.tsx b/components/FullScreenDropZone.test.tsx new file mode 100644 index 0000000..85e4ec0 --- /dev/null +++ b/components/FullScreenDropZone.test.tsx @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; +import FullScreenDropZone from './FullScreenDropZone'; + +describe('FullScreenDropZone', () => { + const mockOnFileLoad = vi.fn(); + const mockOnUrlLoad = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('renders default state with upload instructions', () => { + render(); + + expect(screen.getByText('Load Log File')).toBeDefined(); + expect(screen.getByText('Browse Files')).toBeDefined(); + expect(screen.getByText('Load from URL')).toBeDefined(); + }); + + it('handles file drop', async () => { + render(); + + const dropzone = document.querySelector('.fullscreen-dropzone'); + const file = new File(['test content'], 'test.ndjson', { type: 'application/x-ndjson' }); + + const dropEvent = new Event('drop', { bubbles: true }) as any; + dropEvent.preventDefault = vi.fn(); + dropEvent.dataTransfer = { files: [file] }; + + fireEvent(dropzone!, dropEvent); + + expect(mockOnFileLoad).toHaveBeenCalledWith(file); + }); + + it('shows dragging state on drag over', () => { + render(); + + const dropzone = document.querySelector('.fullscreen-dropzone'); + + fireEvent.dragOver(dropzone!); + + expect(dropzone?.classList.contains('dragging')).toBe(true); + }); + + it('removes dragging state on drag leave', () => { + render(); + + const dropzone = document.querySelector('.fullscreen-dropzone'); + + fireEvent.dragOver(dropzone!); + expect(dropzone?.classList.contains('dragging')).toBe(true); + + fireEvent.dragLeave(dropzone!); + expect(dropzone?.classList.contains('dragging')).toBe(false); + }); + + it('switches to URL input mode when clicking Load from URL', () => { + render(); + + fireEvent.click(screen.getByText('Load from URL')); + + expect(screen.getByPlaceholderText('https://example.com/logs.ndjson')).toBeDefined(); + expect(screen.getByText('Load')).toBeDefined(); + expect(screen.getByText('Cancel')).toBeDefined(); + }); + + it('cancels URL input mode', () => { + render(); + + fireEvent.click(screen.getByText('Load from URL')); + expect(screen.getByPlaceholderText('https://example.com/logs.ndjson')).toBeDefined(); + + fireEvent.click(screen.getByText('Cancel')); + + // Should be back to default state + expect(screen.getByText('Browse Files')).toBeDefined(); + }); + + it('calls onUrlLoad with URL when submitted', async () => { + mockOnUrlLoad.mockResolvedValue(undefined); + + render(); + + fireEvent.click(screen.getByText('Load from URL')); + + const input = screen.getByPlaceholderText('https://example.com/logs.ndjson'); + fireEvent.input(input, { target: { value: 'https://test.com/logs.ndjson' } }); + + fireEvent.click(screen.getByText('Load')); + + await waitFor(() => { + expect(mockOnUrlLoad).toHaveBeenCalledWith('https://test.com/logs.ndjson'); + }); + }); + + it('shows error message when URL load fails', async () => { + mockOnUrlLoad.mockRejectedValue(new Error('Network error')); + + render(); + + fireEvent.click(screen.getByText('Load from URL')); + + const input = screen.getByPlaceholderText('https://example.com/logs.ndjson'); + fireEvent.input(input, { target: { value: 'https://test.com/logs.ndjson' } }); + + fireEvent.click(screen.getByText('Load')); + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeDefined(); + }); + }); + + it('disables Load button when URL is empty', () => { + render(); + + fireEvent.click(screen.getByText('Load from URL')); + + const loadButton = screen.getByText('Load'); + expect(loadButton.hasAttribute('disabled')).toBe(true); + }); + + it('opens file picker when Browse Files is clicked', () => { + render(); + + // Mock the file input click + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, 'click'); + + fireEvent.click(screen.getByText('Browse Files')); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('handles file selection via input', () => { + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['test'], 'test.ndjson'); + + Object.defineProperty(fileInput, 'files', { value: [file] }); + fireEvent.change(fileInput); + + expect(mockOnFileLoad).toHaveBeenCalledWith(file); + }); + + it('shows loading state while URL is being fetched', async () => { + // Create a promise that we can control + let resolveLoad: () => void; + const loadPromise = new Promise((resolve) => { + resolveLoad = resolve; + }); + mockOnUrlLoad.mockReturnValue(loadPromise); + + render(); + + fireEvent.click(screen.getByText('Load from URL')); + + const input = screen.getByPlaceholderText('https://example.com/logs.ndjson'); + fireEvent.input(input, { target: { value: 'https://test.com/logs.ndjson' } }); + + fireEvent.click(screen.getByText('Load')); + + // Should show loading state + await waitFor(() => { + expect(screen.getByText('Loading logs...')).toBeDefined(); + }); + + // Resolve the promise + resolveLoad!(); + }); + + it('shows fallback error message for non-Error exceptions', async () => { + mockOnUrlLoad.mockRejectedValue('string error'); + + render(); + + fireEvent.click(screen.getByText('Load from URL')); + + const input = screen.getByPlaceholderText('https://example.com/logs.ndjson'); + fireEvent.input(input, { target: { value: 'https://test.com/logs.ndjson' } }); + + fireEvent.click(screen.getByText('Load')); + + await waitFor(() => { + expect(screen.getByText('Failed to load from URL')).toBeDefined(); + }); + }); + + it('does not submit when URL is only whitespace', () => { + render(); + + fireEvent.click(screen.getByText('Load from URL')); + + const input = screen.getByPlaceholderText('https://example.com/logs.ndjson'); + fireEvent.input(input, { target: { value: ' ' } }); + + const form = document.querySelector('.url-input-form') as HTMLFormElement; + fireEvent.submit(form); + + expect(mockOnUrlLoad).not.toHaveBeenCalled(); + }); + + it('ignores drop without files', () => { + render(); + + const dropzone = document.querySelector('.fullscreen-dropzone'); + + const dropEvent = new Event('drop', { bubbles: true }) as any; + dropEvent.preventDefault = vi.fn(); + dropEvent.dataTransfer = { files: [] }; + + fireEvent(dropzone!, dropEvent); + + expect(mockOnFileLoad).not.toHaveBeenCalled(); + }); + + it('clears error when cancel is clicked', async () => { + mockOnUrlLoad.mockRejectedValue(new Error('Some error')); + + render(); + + fireEvent.click(screen.getByText('Load from URL')); + + const input = screen.getByPlaceholderText('https://example.com/logs.ndjson'); + fireEvent.input(input, { target: { value: 'https://test.com/logs.ndjson' } }); + + fireEvent.click(screen.getByText('Load')); + + await waitFor(() => { + expect(screen.getByText('Some error')).toBeDefined(); + }); + + fireEvent.click(screen.getByText('Cancel')); + + // Back to default state, error should be cleared + expect(screen.queryByText('Some error')).toBeNull(); + expect(screen.getByText('Browse Files')).toBeDefined(); + }); + + it('ignores file input change when no files selected', () => { + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + // Simulate change event with no files (e.g., user cancels file picker) + Object.defineProperty(fileInput, 'files', { value: null, configurable: true }); + fireEvent.change(fileInput); + + expect(mockOnFileLoad).not.toHaveBeenCalled(); + }); + + it('ignores drop with null dataTransfer', () => { + render(); + + const dropzone = document.querySelector('.fullscreen-dropzone'); + + const dropEvent = new Event('drop', { bubbles: true }) as any; + dropEvent.preventDefault = vi.fn(); + dropEvent.dataTransfer = null; + + fireEvent(dropzone!, dropEvent); + + expect(mockOnFileLoad).not.toHaveBeenCalled(); + }); +}); diff --git a/components/FullScreenDropZone.tsx b/components/FullScreenDropZone.tsx new file mode 100644 index 0000000..7316dc3 --- /dev/null +++ b/components/FullScreenDropZone.tsx @@ -0,0 +1,154 @@ +import { FunctionComponent } from 'preact'; +import { useRef, useState } from 'preact/hooks'; +import { UploadCloud, Link, RefreshCw, X } from 'lucide-preact'; + +interface FullScreenDropZoneProps { + onFileLoad: (file: File) => void; + onUrlLoad: (url: string) => Promise; +} + +const FullScreenDropZone: FunctionComponent = ({ onFileLoad, onUrlLoad }) => { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [showUrlInput, setShowUrlInput] = useState(false); + const [url, setUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleFile = (file: File) => { + if (!file) return; + setError(null); + onFileLoad(file); + }; + + const handleUrlSubmit = async (e: Event) => { + e.preventDefault(); + if (!url.trim()) return; + + setLoading(true); + setError(null); + try { + await onUrlLoad(url); + setShowUrlInput(false); + setUrl(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load from URL'); + } finally { + setLoading(false); + } + }; + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const onDragLeave = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer?.files?.[0]) { + handleFile(e.dataTransfer.files[0]); + } + }; + + return ( +
+ { + const files = (e.target as HTMLInputElement).files; + if (files?.[0]) handleFile(files[0]); + }} + /> + +
+ {loading ? ( +
+ +

Loading logs...

+
+ ) : showUrlInput ? ( +
+

Load from URL

+

Paste a link to an NDJSON log file (e.g., CI artifact URL)

+
+ setUrl((e.target as HTMLInputElement).value)} + autoFocus + /> +
+ + +
+
+ {error &&
{error}
} +
+ ) : ( +
+
+ +
+

Load Log File

+

+ Drop a .ndjson file here, or use one of the options below +

+ +
+ + +
+ + {isDragging && ( +
+ + Drop file to load +
+ )} +
+ )} +
+
+ ); +}; + +export default FullScreenDropZone; diff --git a/components/ReplayApp.test.tsx b/components/ReplayApp.test.tsx new file mode 100644 index 0000000..08763d6 --- /dev/null +++ b/components/ReplayApp.test.tsx @@ -0,0 +1,411 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; +import ReplayApp from '../ReplayApp'; +import * as fileParser from '../services/fileParser'; +import { LogLevel } from '../types'; + +// Mock child components to simplify testing +vi.mock('../components/LogList', () => ({ + default: ({ logs, emptyState, onSelectLog }: any) => ( +
+ {logs.length > 0 ? `Count: ${logs.length}` : emptyState} + {logs.length > 0 && {logs[0].id}} + {logs.length > 0 && ( + + )} +
+ ) +})); + +vi.mock('../components/FilterBar', () => ({ + default: ({ filter, onFilterChange, onClear }: any) => ( +
+ + +
+ ) +})); + +vi.mock('../components/FullScreenDropZone', () => ({ + default: ({ onFileLoad, onUrlLoad }: any) => ( +
+ + +
+ ) +})); + +// Mock LogDetailsPanel +vi.mock('../components/LogDetailsPanel', () => ({ + default: ({ onClose }: any) => ( +
+ +
+ ) +})); + +// Mock SetupModal +vi.mock('../components/SetupModal', () => ({ + default: ({ isOpen, onClose }: any) => ( + isOpen ? ( +
+ +
+ ) : null + ) +})); + +describe('ReplayApp', () => { + const originalLocation = window.location; + + beforeEach(() => { + vi.resetAllMocks(); + // Reset location to default + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '' }, + writable: true + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true + }); + }); + + it('renders initial empty state with dropzone', () => { + render(); + + // Header should be present + expect(screen.getByText('REPLAY MODE')).toBeDefined(); + + // FullScreenDropZone should be visible (no file loaded) + expect(screen.getByTestId('dropzone')).toBeDefined(); + expect(screen.getByText('Load File')).toBeDefined(); + }); + + it('loads and processes file successfully', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['one'], metadata: {} }, + { id: '2', timestamp: '2024-01-01T10:00:01Z', level: LogLevel.INFO, args: ['two'], metadata: {} } + ]; + + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + // Click load button (mocked FullScreenDropZone) + fireEvent.click(screen.getByText('Load File')); + + // Wait for async processing + await waitFor(() => { + expect(fileParser.parseNDJSON).toHaveBeenCalled(); + }); + + // File info should be in header + expect(screen.getByText('test.ndjson')).toBeDefined(); + + // LogList should show count + expect(screen.getByText('Count: 2')).toBeDefined(); + }); + + it('clears logs when cleared from header', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['log'], metadata: {} } + ]; + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + // Load file first + fireEvent.click(screen.getByText('Load File')); + await waitFor(() => screen.getByText('Count: 1')); + + // Clear file using the X button in header (title="Close file") + const closeButton = screen.getByTitle('Close file'); + fireEvent.click(closeButton); + + // Should revert to dropzone + expect(screen.getByTestId('dropzone')).toBeDefined(); + }); + + it('handles parse errors gracefully', async () => { + vi.spyOn(fileParser, 'parseNDJSON').mockRejectedValue(new Error('Parse fail')); + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => { }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => { }); + + render(); + + fireEvent.click(screen.getByText('Load File')); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith('Failed to parse log file'); + }); + + expect(consoleError).toHaveBeenCalled(); + }); + + it('sorts logs by timestamp after loading', async () => { + // Logs in reverse order + const mockLogs = [ + { id: 'later', timestamp: '2024-01-01T10:00:02Z', level: LogLevel.INFO, args: ['two'], metadata: {} }, + { id: 'earlier', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['one'], metadata: {} } + ]; + + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + fireEvent.click(screen.getByText('Load File')); + + await waitFor(() => { + // First log should be the earlier one after sorting + expect(screen.getByTestId('first-log-id').textContent).toBe('earlier'); + }); + }); + + it('displays event count in header', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['one'], metadata: {} }, + { id: '2', timestamp: '2024-01-01T10:00:01Z', level: LogLevel.INFO, args: ['two'], metadata: {} }, + { id: '3', timestamp: '2024-01-01T10:00:02Z', level: LogLevel.INFO, args: ['three'], metadata: {} } + ]; + + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + fireEvent.click(screen.getByText('Load File')); + + await waitFor(() => { + expect(screen.getByText('(3 events)')).toBeDefined(); + }); + }); + + it('loads from URL via query parameter on mount', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '?url=https://example.com/remote.ndjson' }, + writable: true + }); + + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['remote'], metadata: {} } + ]; + + const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['{}'])) + } as Response); + + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('https://example.com/remote.ndjson'); + }); + + await waitFor(() => { + expect(screen.getByText('Count: 1')).toBeDefined(); + }); + }); + + it('shows error alert when URL query param load fails', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '?url=https://example.com/bad.ndjson' }, + writable: true + }); + + const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 404 + } as Response); + + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => { }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => { }); + + render(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith(expect.stringContaining('Failed to load remote log')); + }); + }); + + it('clears logs when clear button in FilterBar is clicked', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['log'], metadata: {} } + ]; + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + fireEvent.click(screen.getByText('Load File')); + await waitFor(() => screen.getByText('Count: 1')); + + // Click clear button in FilterBar mock + fireEvent.click(screen.getByText('Clear')); + + // Logs should be cleared (shows empty state) but file info header should still show + await waitFor(() => { + expect(screen.getByText('No matching logs')).toBeDefined(); + }); + // File info should still be visible + expect(screen.getByText('test.ndjson')).toBeDefined(); + }); + + it('shows log details panel when a log is selected', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['log'], metadata: {} } + ]; + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + fireEvent.click(screen.getByText('Load File')); + await waitFor(() => screen.getByText('Count: 1')); + + // Select a log + fireEvent.click(screen.getByTestId('select-log')); + + // Details panel should be visible + await waitFor(() => { + expect(screen.getByTestId('log-details')).toBeDefined(); + }); + }); + + it('allows loading a different file via hidden input', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['first'], metadata: {} } + ]; + const mockLogs2 = [ + { id: '2', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['second'], metadata: {} }, + { id: '3', timestamp: '2024-01-01T10:00:01Z', level: LogLevel.INFO, args: ['third'], metadata: {} } + ]; + vi.spyOn(fileParser, 'parseNDJSON') + .mockResolvedValueOnce(mockLogs) + .mockResolvedValueOnce(mockLogs2); + + render(); + + // Load first file + fireEvent.click(screen.getByText('Load File')); + await waitFor(() => screen.getByText('Count: 1')); + + // Find the hidden file input and simulate file change + const fileInput = document.querySelector('input[type="file"][accept=".ndjson,.json,.log"]') as HTMLInputElement; + const newFile = new File(['{}'], 'new-file.ndjson'); + + Object.defineProperty(fileInput, 'files', { value: [newFile], configurable: true }); + fireEvent.change(fileInput); + + // Should load the new file + await waitFor(() => { + expect(screen.getByText('Count: 2')).toBeDefined(); + }); + }); + + it('clicks file input when load different file button is clicked', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['log'], metadata: {} } + ]; + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + fireEvent.click(screen.getByText('Load File')); + await waitFor(() => screen.getByText('Count: 1')); + + // Find and spy on the file input click + const fileInput = document.querySelector('input[type="file"][accept=".ndjson,.json,.log"]') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, 'click'); + + // Click the "Load different file" button + const loadDifferentButton = screen.getByTitle('Load different file'); + fireEvent.click(loadDifferentButton); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('opens and closes setup modal in dropzone view', () => { + render(); + + // Initially setup modal should not be visible + expect(screen.queryByTestId('setup-modal')).toBeNull(); + + // Click the settings button + const settingsButton = screen.getByTitle('Helper Setup'); + fireEvent.click(settingsButton); + + // Setup modal should now be visible + expect(screen.getByTestId('setup-modal')).toBeDefined(); + + // Close the modal + fireEvent.click(screen.getByTestId('close-setup')); + + // Modal should be hidden + expect(screen.queryByTestId('setup-modal')).toBeNull(); + }); + + it('opens and closes setup modal in log view', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['log'], metadata: {} } + ]; + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + fireEvent.click(screen.getByText('Load File')); + await waitFor(() => screen.getByText('Count: 1')); + + // Initially setup modal should not be visible + expect(screen.queryByTestId('setup-modal')).toBeNull(); + + // Click the settings button + const settingsButton = screen.getByTitle('Helper Setup'); + fireEvent.click(settingsButton); + + // Setup modal should now be visible + expect(screen.getByTestId('setup-modal')).toBeDefined(); + + // Close the modal + fireEvent.click(screen.getByTestId('close-setup')); + + // Modal should be hidden + expect(screen.queryByTestId('setup-modal')).toBeNull(); + }); + + it('closes log details panel when close button is clicked', async () => { + const mockLogs = [ + { id: '1', timestamp: '2024-01-01T10:00:00Z', level: LogLevel.INFO, args: ['log'], metadata: {} } + ]; + vi.spyOn(fileParser, 'parseNDJSON').mockResolvedValue(mockLogs); + + render(); + + fireEvent.click(screen.getByText('Load File')); + await waitFor(() => screen.getByText('Count: 1')); + + // Select a log + fireEvent.click(screen.getByTestId('select-log')); + + // Details panel should be visible + await waitFor(() => { + expect(screen.getByTestId('log-details')).toBeDefined(); + }); + + // Close the details panel + fireEvent.click(screen.getByTestId('close-details')); + + // Details panel should be hidden + await waitFor(() => { + expect(screen.queryByTestId('log-details')).toBeNull(); + }); + }); +}); diff --git a/e2e/ci-replay.spec.ts b/e2e/ci-replay.spec.ts new file mode 100644 index 0000000..2e80995 --- /dev/null +++ b/e2e/ci-replay.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +const LOG_DIR = './slogx_logs'; +const TEST_LOG_FILE = 'e2e-test-service.ndjson'; +const FULL_LOG_PATH = path.join(LOG_DIR, TEST_LOG_FILE); + +test.describe('CI Replay Flow', () => { + // Test Data + const timestamp = new Date().toISOString(); + const testLog = { + id: 'test-uuid-123', + timestamp, + level: 'INFO', + args: ['E2E Test Message'], + metadata: { + file: 'e2e.ts', + line: 1, + func: 'test', + lang: 'node', + service: 'e2e-test-service' + } + }; + + test.beforeAll(() => { + // 1. Simulate CI Log Generation + // We manually write a file to mimic the SDK behavior in CI mode + if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR); + } + + // Clear existing file + if (fs.existsSync(FULL_LOG_PATH)) { + fs.unlinkSync(FULL_LOG_PATH); + } + + // Write a few lines of NDJSON + const entries = [ + { ...testLog, id: '1', level: 'INFO', args: ['First log'] }, + { ...testLog, id: '2', level: 'WARN', args: ['Warning log'] }, + { ...testLog, id: '3', level: 'ERROR', args: ['Error log', { detail: 'something broke' }] } + ]; + + const content = entries.map(e => JSON.stringify(e)).join('\n'); + fs.writeFileSync(FULL_LOG_PATH, content); + console.log(`Generated test log file at ${FULL_LOG_PATH}`); + }); + + test('should load and display logs in replay mode', async ({ page }) => { + // 2. Open Replay UI + await page.goto('/replay.html'); + await expect(page).toHaveTitle(/slogx | Replay/); + + // 3. Upload the generated file using the new FullScreenDropZone component + // Use drag-and-drop simulation on the dropzone + + // Create a DataTransfer to simulate drag and drop if no input is exposed + const buffer = fs.readFileSync(FULL_LOG_PATH); + + // We'll use a more robust drag-and-drop simulation + const dataTransfer = await page.evaluateHandle(({ bufferHex, fileName }) => { + const dt = new DataTransfer(); + const buffer = new Uint8Array(bufferHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))); + const file = new File([buffer], fileName, { type: 'application/x-ndjson' }); + dt.items.add(file); + return dt; + }, { + bufferHex: buffer.toString('hex'), + fileName: TEST_LOG_FILE + }); + + // Dispatch drop event on the drop zone + await page.dispatchEvent('.fullscreen-dropzone', 'drop', { dataTransfer }); + + // Check for file info in header + await expect(page.locator('.file-info-header')).toContainText(TEST_LOG_FILE); + await expect(page.locator('.file-info-header')).toContainText('3 events'); + + // Check for log content + await expect(page.getByText('First log')).toBeVisible(); + await expect(page.getByText('Warning log')).toBeVisible(); + await expect(page.getByText('Error log')).toBeVisible(); + + // Check styling classes + await expect(page.locator('.log-item.warn')).toBeVisible(); + await expect(page.locator('.log-item.error')).toBeVisible(); + }); + + test('should filter logs', async ({ page }) => { + // Load file again (state resets on reload unless we persist) + // For speed, let's just re-upload or chain steps. Re-uploading is safer for test isolation. + await page.goto('/replay.html'); + const buffer = fs.readFileSync(FULL_LOG_PATH); + const dataTransfer = await page.evaluateHandle(({ bufferHex, fileName }) => { + const dt = new DataTransfer(); + const buffer = new Uint8Array(bufferHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))); + const file = new File([buffer], fileName, { type: 'application/x-ndjson' }); + dt.items.add(file); + return dt; + }, { + bufferHex: buffer.toString('hex'), + fileName: TEST_LOG_FILE + }); + await page.dispatchEvent('.fullscreen-dropzone', 'drop', { dataTransfer }); + + // Filter by text + await page.getByPlaceholder('Filter logs (msg, service, file)...').fill('Warning'); + await expect(page.getByText('First log')).not.toBeVisible(); + await expect(page.getByText('Warning log')).toBeVisible(); + + // Clear filter + await page.getByPlaceholder('Filter logs (msg, service, file)...').fill(''); + await expect(page.getByText('First log')).toBeVisible(); + }); + + test('should show URL input when Load from URL is clicked', async ({ page }) => { + await page.goto('/replay.html'); + + // Click Load from URL button + await page.click('button:has-text("Load from URL")'); + + // URL input should appear + await expect(page.getByPlaceholder('https://example.com/logs.ndjson')).toBeVisible(); + await expect(page.getByText('Load', { exact: true })).toBeVisible(); + + // Cancel should work + await page.click('button:has-text("Cancel")'); + + // Should be back to default state + await expect(page.getByText('Browse Files')).toBeVisible(); + await expect(page.getByText('Load from URL')).toBeVisible(); + }); +}); diff --git a/replay.html b/replay.html new file mode 100644 index 0000000..aeccf33 --- /dev/null +++ b/replay.html @@ -0,0 +1,19 @@ + + + + + + + + + slogx | Replay Mode + + + +
+ + + + \ No newline at end of file diff --git a/replay.tsx b/replay.tsx new file mode 100644 index 0000000..2770b40 --- /dev/null +++ b/replay.tsx @@ -0,0 +1,8 @@ +import { render } from 'preact'; +import ReplayApp from './ReplayApp'; +import './styles/main.css'; +import './styles/layout.css'; +import './styles/components.css'; +import './styles/log.css'; + +render(, document.getElementById('app')!); diff --git a/services/fileParser.test.ts b/services/fileParser.test.ts new file mode 100644 index 0000000..21f782a --- /dev/null +++ b/services/fileParser.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parseNDJSON } from './fileParser'; + +describe('parseNDJSON', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => { }); + }); + + const createFile = (content: string, name = 'test.ndjson') => { + return new File([content], name, { type: 'application/x-ndjson' }); + }; + + it('parses valid NDJSON with multiple entries', async () => { + const content = [ + '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["msg1"],"metadata":{}}', + '{"timestamp":"2024-01-01T10:00:01Z","level":"WARN","args":["msg2"],"metadata":{}}' + ].join('\n'); + + const file = createFile(content); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(2); + expect(result[0].level).toBe('INFO'); + expect(result[1].level).toBe('WARN'); + }); + + it('returns empty array for empty file', async () => { + const file = createFile(''); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(0); + }); + + it('skips empty lines', async () => { + const content = [ + '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["msg1"],"metadata":{}}', + '', + ' ', + '{"timestamp":"2024-01-01T10:00:01Z","level":"ERROR","args":["msg2"],"metadata":{}}' + ].join('\n'); + + const file = createFile(content); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(2); + }); + + it('skips malformed JSON lines and continues parsing', async () => { + const content = [ + '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["valid"],"metadata":{}}', + 'this is not json', + '{"timestamp":"2024-01-01T10:00:01Z","level":"ERROR","args":["also valid"],"metadata":{}}' + ].join('\n'); + + const file = createFile(content); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(2); + expect(console.warn).toHaveBeenCalled(); + }); + + it('skips entries missing required fields (timestamp, level)', async () => { + const content = [ + '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["valid"],"metadata":{}}', + '{"level":"INFO","args":["missing timestamp"]}', // no timestamp + '{"timestamp":"2024-01-01T10:00:01Z","args":["missing level"]}', // no level + '{"timestamp":"2024-01-01T10:00:02Z","level":"WARN","args":["also valid"],"metadata":{}}' + ].join('\n'); + + const file = createFile(content); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(2); + expect(result[0].args).toEqual(['valid']); + expect(result[1].args).toEqual(['also valid']); + }); + + it('adds source field with filename to each entry', async () => { + const content = '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["msg"],"metadata":{}}'; + + const file = createFile(content, 'my-logs.ndjson'); + const result = await parseNDJSON(file); + + expect(result[0].source).toBe('my-logs.ndjson'); + }); + + it('handles file with only whitespace', async () => { + const file = createFile(' \n\n \n'); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(0); + }); + + it('handles file with trailing newline', async () => { + const content = '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["msg"],"metadata":{}}\n'; + + const file = createFile(content); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(1); + }); + + it('handles entries with extra fields gracefully', async () => { + const content = '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["msg"],"metadata":{},"extra":"field","another":123}'; + + const file = createFile(content); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(1); + expect((result[0] as any).extra).toBe('field'); + }); + + it('skips objects that are not valid log entries (non-object JSON)', async () => { + const content = [ + '"just a string"', + '123', + 'null', + '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["valid"],"metadata":{}}' + ].join('\n'); + + const file = createFile(content); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(1); + expect(result[0].args).toEqual(['valid']); + }); + + it('handles arrays as valid JSON but not as log entries', async () => { + const content = [ + '[1, 2, 3]', + '{"timestamp":"2024-01-01T10:00:00Z","level":"INFO","args":["valid"],"metadata":{}}' + ].join('\n'); + + const file = createFile(content); + const result = await parseNDJSON(file); + + expect(result).toHaveLength(1); + expect(result[0].args).toEqual(['valid']); + }); + +}); diff --git a/services/fileParser.ts b/services/fileParser.ts new file mode 100644 index 0000000..855da3a --- /dev/null +++ b/services/fileParser.ts @@ -0,0 +1,45 @@ +import { LogEntry } from '../types'; + +export const parseNDJSON = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const text = e.target?.result as string; + if (!text) { + resolve([]); + return; + } + + const entries: LogEntry[] = []; + const lines = text.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + try { + const entry = JSON.parse(line) as LogEntry; + // Basic validation + if (entry && typeof entry === 'object' && 'timestamp' in entry && 'level' in entry) { + // Ensure source is tagged as "file" if not present, or maybe just leave it + // Adding a source helps with filtering if we ever merge multiple files + entry.source = file.name; + entries.push(entry); + } + } catch (err) { + console.warn(`[slogx] Failed to parse line ${i + 1}:`, line.substring(0, 50) + '...'); + } + } + + resolve(entries); + } catch (err) { + reject(err); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }); +}; diff --git a/styles/components.css b/styles/components.css index d7c0d5b..becbd39 100644 --- a/styles/components.css +++ b/styles/components.css @@ -27,6 +27,16 @@ background: var(--slate-800); } +.btn-icon:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-icon:disabled:hover { + color: var(--slate-400); + background: transparent; +} + .btn-icon.danger:hover { color: var(--red-400); } diff --git a/vite.config.ts b/vite.config.ts index b035061..bda5404 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,6 +38,6 @@ export default defineConfig({ '**/*.spec.ts', ] }, - include: ['components/*.test.{ts,tsx}', 'hooks/*.test.{ts,tsx}'], + include: ['components/*.test.{ts,tsx}', 'hooks/*.test.{ts,tsx}', 'services/*.test.{ts,tsx}'], } });