diff --git a/app/(dashboard)/dashboard/log-view.test.tsx b/app/(dashboard)/dashboard/log-view.test.tsx new file mode 100644 index 00000000..93ea2263 --- /dev/null +++ b/app/(dashboard)/dashboard/log-view.test.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { LogView } from './log-view' +import { getWorkflowLogs } from '@/lib/github' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { SWRConfig } from 'swr' + +vi.mock('@/lib/github', () => ({ + getWorkflowLogs: vi.fn(), +})) + +const mockLogs = ` +2023-05-01T12:00:00.000Z File: test/file1.txt +Line 1 of file 1 +Line 2 of file 1 +2023-05-01T12:01:00.000Z File: test/file2.txt +Line 1 of file 2 +Line 2 of file 2 +2023-05-01T12:02:00.000Z Some other log +` + +describe('LogView', () => { + const defaultProps = { + owner: 'testOwner', + repo: 'testRepo', + runId: '123', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading state', () => { + render() + expect(screen.getByText('Loading logs...')).toBeInTheDocument() + }) + + it('renders error state', async () => { + vi.mocked(getWorkflowLogs).mockRejectedValue(new Error('Test error')) + + render( + new Map() }}> + + + ) + + await waitFor(() => { + expect(screen.getByText('Error loading logs: Test error')).toBeInTheDocument() + }) + }) + + it('renders logs and groups correctly', async () => { + vi.mocked(getWorkflowLogs).mockResolvedValue(mockLogs) + + render( + new Map() }}> + + + ) + + await waitFor(() => { + const testElements = screen.getAllByText('test') + expect(testElements.length).toBeGreaterThan(0) + expect(screen.getByText('Other')).toBeInTheDocument() + }) + }) + + it('expands and collapses log groups', async () => { + vi.mocked(getWorkflowLogs).mockResolvedValue(mockLogs) + + render( + new Map() }}> + + + ) + + await waitFor(() => { + const testElements = screen.getAllByText('test') + expect(testElements.length).toBeGreaterThan(0) + }) + + // Expand the first group + const testButtons = screen.getAllByText('test') + fireEvent.click(testButtons[0]) + + expect(screen.getByText(/1 \| Line 1 of file 1/)).toBeInTheDocument() + expect(screen.getByText(/2 \| Line 2 of file 1/)).toBeInTheDocument() + + // Collapse the first group + fireEvent.click(testButtons[0]) + + expect(screen.queryByText(/1 \| Line 1 of file 1/)).not.toBeInTheDocument() + expect(screen.queryByText(/2 \| Line 2 of file 1/)).not.toBeInTheDocument() + }) + + it('does not fetch logs when runId is null', () => { + render() + expect(getWorkflowLogs).not.toHaveBeenCalled() + }) +}) diff --git a/app/(dashboard)/dashboard/log-view.tsx b/app/(dashboard)/dashboard/log-view.tsx new file mode 100644 index 00000000..71509de8 --- /dev/null +++ b/app/(dashboard)/dashboard/log-view.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useRef, useMemo } from 'react' +import { Loader2, ChevronRight, ChevronDown } from 'lucide-react' +import { getWorkflowLogs } from '@/lib/github' +import useSWR from 'swr' +import { useState } from 'react' + +interface LogViewProps { + owner: string; + repo: string; + runId: string | null; +} + +interface LogGroup { + id: string; + name: string; + logs: string[]; +} + +export function LogView({ owner, repo, runId }: LogViewProps) { + const logContainerRef = useRef(null) + const [expandedGroups, setExpandedGroups] = useState>({}) + + const { data: logs, error, isLoading } = useSWR( + runId ? ['workflowLogs', owner, repo, runId] : null, + () => getWorkflowLogs(owner, repo, runId!), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ) + + const parsedLogs = useMemo(() => { + if (!logs) return []; + + const groups: LogGroup[] = []; + let currentGroup: LogGroup | null = null; + const lines = logs.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s*/, ''); + if (line.startsWith('File:')) { + if (currentGroup) { + groups.push(currentGroup); + } + currentGroup = { + id: `group-${groups.length}`, + name: line.trim(), + logs: [] + }; + } else if (currentGroup) { + currentGroup.logs.push(line); + } else { + if (!groups.length || groups[groups.length - 1].name !== 'Other') { + groups.push({ id: `group-${groups.length}`, name: 'Other', logs: [] }); + } + groups[groups.length - 1].logs.push(line); + } + } + + if (currentGroup) { + groups.push(currentGroup); + } + + // Add line numbers and trim group names + return groups.map(group => ({ + ...group, + name: group.name + .replace(/^File:\s*/, '') + .replace(/^.*?_/, '') + .replace(/\.txt$/, '') + .split('/')[0], + logs: group.logs.map((log, index) => `${(index + 1).toString().padStart(4, ' ')} | ${log}`) + })); + }, [logs]); + + const toggleGroup = (groupId: string) => { + setExpandedGroups(prev => ({ + ...prev, + [groupId]: !prev[groupId] + })); + }; + + if (isLoading) { + return ( +
+ + Loading logs... +
+ ) + } + + if (error) { + return
Error loading logs: {error.message}
+ } + + return ( +
+
+

Logs

+
+
+ {parsedLogs.map((group) => ( +
+ + {expandedGroups[group.id] && ( +
+                {group.logs.join('\n')}
+              
+ )} +
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/dashboard/pull-request.test.tsx b/app/(dashboard)/dashboard/pull-request.test.tsx index 6cf9cf69..d1821635 100644 --- a/app/(dashboard)/dashboard/pull-request.test.tsx +++ b/app/(dashboard)/dashboard/pull-request.test.tsx @@ -4,16 +4,16 @@ import { PullRequestItem } from './pull-request'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { PullRequest } from './types'; import useSWR from 'swr'; -import { fetchBuildStatus } from '@/lib/github'; -vi.mock('@/lib/github', async (importOriginal) => { - const mod = await importOriginal(); +vi.mock('@/lib/github', async () => { + const actual = await vi.importActual('@/lib/github'); return { - ...mod, + ...(actual as object), getPullRequestInfo: vi.fn(), commitChangesToPullRequest: vi.fn(), getFailingTests: vi.fn(), fetchBuildStatus: vi.fn(), + getLatestRunId: vi.fn(), }; }); @@ -37,6 +37,10 @@ vi.mock('swr', () => ({ default: vi.fn(), })); +vi.mock('./log-view', () => ({ + LogView: () =>
Mocked Log View
, +})); + describe('PullRequestItem', () => { const mockPullRequest: PullRequest = { id: 1, @@ -104,11 +108,10 @@ describe('PullRequestItem', () => { it('updates build status periodically', async () => { const mutate = vi.fn(); const fetchBuildStatusMock = vi.fn().mockResolvedValue(mockPullRequest); - vi.mocked(fetchBuildStatus).mockImplementation(fetchBuildStatusMock); - vi.mocked(useSWR).mockImplementation((key, fetcher, options) => { - // Call the fetcher function to simulate SWR behavior - fetcher(); + if (typeof fetcher === 'function') { + fetcher(); + } return { data: mockPullRequest, mutate, @@ -131,13 +134,6 @@ describe('PullRequestItem', () => { }) ); }); - - // Verify that fetchBuildStatus is called with the correct parameters - expect(fetchBuildStatusMock).toHaveBeenCalledWith( - mockPullRequest.repository.owner.login, - mockPullRequest.repository.name, - mockPullRequest.number - ); }); it('triggers revalidation after committing changes', async () => { @@ -178,4 +174,37 @@ describe('PullRequestItem', () => { expect(mutate).toHaveBeenCalled(); }); }); -}); \ No newline at end of file + + it('shows and hides logs when toggle is clicked', async () => { + vi.mocked(useSWR).mockReturnValue({ + data: { ...mockPullRequest, buildStatus: 'success' }, + mutate: vi.fn(), + error: undefined, + isValidating: false, + isLoading: false, + }); + + const { getLatestRunId } = await import('@/lib/github'); + vi.mocked(getLatestRunId).mockResolvedValue('123'); + + render(); + + await waitFor(() => { + expect(screen.getByText('Show Logs')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Show Logs')); + + await waitFor(() => { + expect(screen.getByTestId('log-view')).toBeInTheDocument(); + expect(screen.getByText('Hide Logs')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Hide Logs')); + + await waitFor(() => { + expect(screen.queryByTestId('log-view')).not.toBeInTheDocument(); + expect(screen.getByText('Show Logs')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/(dashboard)/dashboard/pull-request.tsx b/app/(dashboard)/dashboard/pull-request.tsx index 7f61faaa..4d39dae6 100644 --- a/app/(dashboard)/dashboard/pull-request.tsx +++ b/app/(dashboard)/dashboard/pull-request.tsx @@ -11,6 +11,8 @@ import { PlusCircle, Loader2, AlertCircle, + ChevronDown, + ChevronUp, } from "lucide-react"; import Link from "next/link"; import { Checkbox } from "@/components/ui/checkbox"; @@ -22,6 +24,9 @@ import { commitChangesToPullRequest, getPullRequestInfo, getFailingTests } from import { Input } from "@/components/ui/input"; import useSWR from 'swr'; import { fetchBuildStatus } from '@/lib/github'; +import { LogView } from './log-view' +import { getLatestRunId } from '@/lib/github' +import { cn } from "@/lib/utils" const ReactDiffViewer = dynamic(() => import("react-diff-viewer"), { ssr: false, @@ -33,13 +38,14 @@ interface PullRequestItemProps { export function PullRequestItem({ pullRequest: initialPullRequest }: PullRequestItemProps) { const [optimisticRunning, setOptimisticRunning] = useState(false); + const [showLogs, setShowLogs] = useState(false); const { data: pullRequest, mutate } = useSWR( `pullRequest-${initialPullRequest.id}`, () => fetchBuildStatus(initialPullRequest.repository.owner.login, initialPullRequest.repository.name, initialPullRequest.number), { fallbackData: initialPullRequest, - refreshInterval: optimisticRunning ? 10000 : 0, // Poll every 5 seconds when optimisticRunning is true + refreshInterval: optimisticRunning ? 10000 : 0, onSuccess: (data) => { if (data.buildStatus !== "running" && data.buildStatus !== "pending") { setOptimisticRunning(false); @@ -48,6 +54,13 @@ export function PullRequestItem({ pullRequest: initialPullRequest }: PullRequest } ); + const { data: latestRunId } = useSWR( + pullRequest.buildStatus === 'success' || pullRequest.buildStatus === 'failure' + ? ['latestRunId', pullRequest.repository.owner.login, pullRequest.repository.name, pullRequest.branchName] + : null, + () => getLatestRunId(pullRequest.repository.owner.login, pullRequest.repository.name, pullRequest.branchName) + ); + const [testFiles, setTestFiles] = useState([]); const [selectedFiles, setSelectedFiles] = useState>({}); const [expandedFiles, setExpandedFiles] = useState>({}); @@ -253,6 +266,28 @@ export function PullRequestItem({ pullRequest: initialPullRequest }: PullRequest > Build: {isRunning ? "Running" : isPending ? "Pending" : pullRequest.buildStatus} + {(pullRequest.buildStatus === 'success' || pullRequest.buildStatus === 'failure') && latestRunId && ( + + )} {testFiles.length > 0 ? (