Skip to content

Commit

Permalink
Implement Github Workflow Log Viewer (anti-work#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
slavingia authored Oct 15, 2024
2 parents 705921b + 6381fb9 commit 36e51ce
Show file tree
Hide file tree
Showing 8 changed files with 480 additions and 20 deletions.
100 changes: 100 additions & 0 deletions app/(dashboard)/dashboard/log-view.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LogView {...defaultProps} />)
expect(screen.getByText('Loading logs...')).toBeInTheDocument()
})

it('renders error state', async () => {
vi.mocked(getWorkflowLogs).mockRejectedValue(new Error('Test error'))

render(
<SWRConfig value={{ provider: () => new Map() }}>
<LogView {...defaultProps} />
</SWRConfig>
)

await waitFor(() => {
expect(screen.getByText('Error loading logs: Test error')).toBeInTheDocument()
})
})

it('renders logs and groups correctly', async () => {
vi.mocked(getWorkflowLogs).mockResolvedValue(mockLogs)

render(
<SWRConfig value={{ provider: () => new Map() }}>
<LogView {...defaultProps} />
</SWRConfig>
)

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(
<SWRConfig value={{ provider: () => new Map() }}>
<LogView {...defaultProps} />
</SWRConfig>
)

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(<LogView owner="testOwner" repo="testRepo" runId={null} />)
expect(getWorkflowLogs).not.toHaveBeenCalled()
})
})
127 changes: 127 additions & 0 deletions app/(dashboard)/dashboard/log-view.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})

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 (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<span>Loading logs...</span>
</div>
)
}

if (error) {
return <div className="text-red-500">Error loading logs: {error.message}</div>
}

return (
<div className="bg-gray-900 text-gray-100 rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-2 bg-gray-800">
<h3 className="text-sm font-semibold">Logs</h3>
</div>
<div ref={logContainerRef} className="h-96 overflow-y-auto p-4 font-mono text-sm">
{parsedLogs.map((group) => (
<div key={group.id} className="mb-4">
<button
onClick={() => toggleGroup(group.id)}
className="flex items-center text-left w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 rounded"
>
{expandedGroups[group.id] ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
<span className="font-semibold">{group.name}</span>
</button>
{expandedGroups[group.id] && (
<pre className="whitespace-pre-wrap mt-2 pl-6 border-l-2 border-gray-700">
{group.logs.join('\n')}
</pre>
)}
</div>
))}
</div>
</div>
)
}
61 changes: 45 additions & 16 deletions app/(dashboard)/dashboard/pull-request.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
});

Expand All @@ -37,6 +37,10 @@ vi.mock('swr', () => ({
default: vi.fn(),
}));

vi.mock('./log-view', () => ({
LogView: () => <div data-testid="log-view">Mocked Log View</div>,
}));

describe('PullRequestItem', () => {
const mockPullRequest: PullRequest = {
id: 1,
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -178,4 +174,37 @@ describe('PullRequestItem', () => {
expect(mutate).toHaveBeenCalled();
});
});
});

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(<PullRequestItem pullRequest={mockPullRequest} />);

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();
});
});
});
Loading

0 comments on commit 36e51ce

Please sign in to comment.