/**
* Coverage for FileViewer.
*
* Locks the three content-render modes: Monaco editor for text files,
* binary panel for binary files, and oversized panel for files that
* exceed the preview limit.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import type { FileContentResult } from '@/lib/stackFilesApi';
// FileViewer now imports `Editor` from the lazy loader, not directly from
// @monaco-editor/react. Mock the loader so tests skip Monaco's setup path
// and the editor renders synchronously.
vi.mock('@/lib/monacoLoader', () => ({
Editor: () =>
,
DiffEditor: () =>
,
}));
vi.mock('@/lib/stackFilesApi', () => ({
readStackFile: vi.fn(),
writeStackFile: vi.fn(),
downloadStackFile: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(() => 'loading-id'),
dismiss: vi.fn(),
},
}));
vi.mock('@/components/ui/skeleton', () => ({
Skeleton: () =>
,
}));
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
disabled,
onClick,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
}) => (
{children}
),
}));
vi.mock('@/lib/utils', () => ({
formatBytes: (n: number) => `${n}B`,
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
vi.mock('@/lib/monacoLanguages', () => ({
extensionToLanguage: () => 'plaintext',
}));
const licenseState = { isPaid: true };
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => licenseState,
}));
import { readStackFile } from '@/lib/stackFilesApi';
import { FileViewer } from '../FileViewer';
const mockReadFile = readStackFile as unknown as ReturnType;
function textResult(content = 'hello world'): FileContentResult {
return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain' };
}
function binaryResult(): FileContentResult {
return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream' };
}
function oversizedResult(): FileContentResult {
return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain' };
}
const defaultProps = {
stackName: 'my-stack',
canEdit: true,
isDarkMode: false,
};
beforeEach(() => {
mockReadFile.mockReset();
licenseState.isPaid = true;
});
afterEach(() => vi.clearAllMocks());
describe('FileViewer', () => {
it('shows "Select a file" placeholder when selectedPath is null', () => {
render( );
expect(screen.getByText(/select a file/i)).toBeInTheDocument();
expect(mockReadFile).not.toHaveBeenCalled();
});
it('renders Monaco editor for a regular text file', async () => {
mockReadFile.mockResolvedValue(textResult());
render( );
await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument());
expect(screen.queryByText(/binary file/i)).not.toBeInTheDocument();
expect(screen.queryByText(/too large/i)).not.toBeInTheDocument();
});
it('calls readStackFile with the correct stack name and path', async () => {
mockReadFile.mockResolvedValue(textResult());
render( );
await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts'));
});
it('renders binary panel (not Monaco) for a binary file', async () => {
mockReadFile.mockResolvedValue(binaryResult());
render( );
expect(await screen.findByText(/binary file/i)).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('renders oversized panel (not Monaco) when file is too large to preview', async () => {
mockReadFile.mockResolvedValue(oversizedResult());
render( );
expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('renders error message when readStackFile rejects', async () => {
mockReadFile.mockRejectedValue(new Error('Not found'));
render( );
expect(await screen.findByText('Not found')).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('shows Download button when user has a paid tier', async () => {
mockReadFile.mockResolvedValue(binaryResult());
render( );
await screen.findByText(/binary file/i);
const downloadBtn = screen.getByRole('button', { name: /download/i });
expect(downloadBtn).not.toBeDisabled();
});
it('shows disabled Download button for community tier', async () => {
licenseState.isPaid = false;
mockReadFile.mockResolvedValue(binaryResult());
render( );
await screen.findByText(/binary file/i);
const downloadBtn = screen.getByRole('button', { name: /download/i });
expect(downloadBtn).toBeDisabled();
});
it('re-fetches when selectedPath changes', async () => {
mockReadFile.mockResolvedValue(textResult());
const { rerender } = render( );
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(1));
rerender( );
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(2));
expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt');
});
});