import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DragDropUpload } from './DragDropUpload'; function createMockFile(name: string, size: number, type: string): File { const content = new Array(size).fill('a').join(''); return new File([content], name, { type }); } function createMockXHR(options: { status?: number; response?: object; progressEvents?: { loaded: number; total: number }[]; shouldError?: boolean; shouldTimeout?: boolean; } = {}) { const { status = 200, response = { artifact_id: 'abc123', size: 100 }, progressEvents = [], shouldError = false, shouldTimeout = false, } = options; return class MockXHR { status = status; responseText = JSON.stringify(response); timeout = 0; upload = { addEventListener: vi.fn((event: string, handler: (e: ProgressEvent) => void) => { if (event === 'progress') { progressEvents.forEach((p, i) => { setTimeout(() => { handler({ lengthComputable: true, loaded: p.loaded, total: p.total } as ProgressEvent); }, i * 10); }); } }), }; addEventListener = vi.fn((event: string, handler: () => void) => { if (event === 'load' && !shouldError && !shouldTimeout) { setTimeout(handler, progressEvents.length * 10 + 10); } if (event === 'error' && shouldError) { setTimeout(handler, 10); } if (event === 'timeout' && shouldTimeout) { setTimeout(handler, 10); } }); open = vi.fn(); send = vi.fn(); }; } describe('DragDropUpload', () => { const defaultProps = { projectName: 'test-project', packageName: 'test-package', }; beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe('Rendering', () => { it('renders drop zone with instructional text', () => { render(); expect(screen.getByText(/drag files here/i)).toBeInTheDocument(); expect(screen.getByText(/click to browse/i)).toBeInTheDocument(); }); it('renders hidden file input', () => { render(); const input = document.querySelector('input[type="file"]'); expect(input).toBeInTheDocument(); expect(input).toHaveClass('drop-zone__input'); }); it('shows max file size hint when provided', () => { render(); expect(screen.getByText(/max file size: 1 mb/i)).toBeInTheDocument(); }); it('shows allowed types hint when provided', () => { render(); expect(screen.getByText(/\.zip, \.tar\.gz/i)).toBeInTheDocument(); }); }); describe('Click to Browse', () => { it('opens file picker when drop zone is clicked', async () => { render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const clickSpy = vi.spyOn(input, 'click'); const dropZone = screen.getByRole('button'); await userEvent.click(dropZone); expect(clickSpy).toHaveBeenCalled(); }); it('opens file picker on Enter key', () => { render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const clickSpy = vi.spyOn(input, 'click'); const dropZone = screen.getByRole('button'); fireEvent.keyDown(dropZone, { key: 'Enter' }); expect(clickSpy).toHaveBeenCalled(); }); }); describe('Drag and Drop Events', () => { it('shows visual feedback on drag over', () => { render(); const dropZone = screen.getByRole('button'); fireEvent.dragEnter(dropZone, { dataTransfer: { items: [{}] }, }); expect(dropZone).toHaveClass('drop-zone--active'); }); it('removes visual feedback on drag leave', () => { render(); const dropZone = screen.getByRole('button'); fireEvent.dragEnter(dropZone, { dataTransfer: { items: [{}] } }); expect(dropZone).toHaveClass('drop-zone--active'); fireEvent.dragLeave(dropZone); expect(dropZone).not.toHaveClass('drop-zone--active'); }); it('accepts dropped files', async () => { const MockXHR = createMockXHR(); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const dropZone = screen.getByRole('button'); const file = createMockFile('test.txt', 100, 'text/plain'); const dataTransfer = new DataTransfer(); Object.defineProperty(dataTransfer, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.drop(dropZone, { dataTransfer }); await waitFor(() => { expect(screen.getByText('test.txt')).toBeInTheDocument(); }); }); }); describe('File Validation', () => { it('rejects files exceeding max size', async () => { render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('large.txt', 200, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await waitFor(() => { expect(screen.getByText(/exceeds.*limit/i)).toBeInTheDocument(); }); }); it('rejects files with invalid type when allowAllTypes is false', async () => { render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await waitFor(() => { expect(screen.getByText(/not allowed/i)).toBeInTheDocument(); }); }); it('rejects empty files', async () => { render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('empty.txt', 0, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await waitFor(() => { expect(screen.getByText(/empty file/i)).toBeInTheDocument(); }); }); it('accepts valid files when allowAllTypes is true', async () => { const MockXHR = createMockXHR(); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await waitFor(() => { expect(screen.getByText('test.txt')).toBeInTheDocument(); expect(screen.queryByText(/not allowed/i)).not.toBeInTheDocument(); }); }); }); describe('Upload Queue', () => { it('shows file in queue after selection', async () => { const MockXHR = createMockXHR(); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('document.pdf', 1024, 'application/pdf'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await waitFor(() => { expect(screen.getByText('document.pdf')).toBeInTheDocument(); expect(screen.getByText('1 KB')).toBeInTheDocument(); }); }); it('handles multiple files', async () => { const MockXHR = createMockXHR(); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const files = [ createMockFile('file1.txt', 100, 'text/plain'), createMockFile('file2.txt', 200, 'text/plain'), createMockFile('file3.txt', 300, 'text/plain'), ]; Object.defineProperty(input, 'files', { value: Object.assign(files, { item: (i: number) => files[i] }), }); fireEvent.change(input); await waitFor(() => { expect(screen.getByText('file1.txt')).toBeInTheDocument(); expect(screen.getByText('file2.txt')).toBeInTheDocument(); expect(screen.getByText('file3.txt')).toBeInTheDocument(); }); }); it('shows overall progress for multiple files', async () => { const MockXHR = createMockXHR(); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const files = [ createMockFile('file1.txt', 100, 'text/plain'), createMockFile('file2.txt', 100, 'text/plain'), ]; Object.defineProperty(input, 'files', { value: Object.assign(files, { item: (i: number) => files[i] }), }); fireEvent.change(input); await waitFor(() => { expect(screen.getByText(/uploading.*of.*files/i)).toBeInTheDocument(); }); }); }); describe('Upload Progress', () => { it('shows progress bar during upload', async () => { const MockXHR = createMockXHR({ progressEvents: [ { loaded: 50, total: 100 }, ], }); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await waitFor(() => { const progressBar = document.querySelector('.progress-bar__fill'); expect(progressBar).toBeInTheDocument(); }); }); }); describe('Upload Completion', () => { it('shows success state when upload completes', async () => { const MockXHR = createMockXHR({ response: { artifact_id: 'abc123def456', size: 100 }, }); vi.stubGlobal('XMLHttpRequest', MockXHR); const onComplete = vi.fn(); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await vi.advanceTimersByTimeAsync(100); await waitFor(() => { expect(screen.getByText(/abc123def456/i)).toBeInTheDocument(); }); }); it('calls onUploadComplete callback with results', async () => { const MockXHR = createMockXHR({ response: { artifact_id: 'test-artifact-id', size: 100 }, }); vi.stubGlobal('XMLHttpRequest', MockXHR); const onComplete = vi.fn(); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await vi.advanceTimersByTimeAsync(100); await waitFor(() => { expect(onComplete).toHaveBeenCalledWith([ expect.objectContaining({ artifact_id: 'test-artifact-id' }), ]); }); }); }); describe('Upload Errors', () => { it('shows error state when upload fails after retries exhausted', async () => { const MockXHR = createMockXHR({ status: 500, response: { detail: 'Server error' }, shouldError: true, }); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await vi.advanceTimersByTimeAsync(100); await waitFor(() => { expect(screen.getByText(/network error/i)).toBeInTheDocument(); }); }); it('calls onUploadError callback when retries exhausted', async () => { const MockXHR = createMockXHR({ shouldError: true }); vi.stubGlobal('XMLHttpRequest', MockXHR); const onError = vi.fn(); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await vi.advanceTimersByTimeAsync(100); await waitFor(() => { expect(onError).toHaveBeenCalled(); }); }); }); describe('Queue Actions', () => { it('removes item from queue when remove button clicked', async () => { const MockXHR = createMockXHR(); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await vi.advanceTimersByTimeAsync(100); await waitFor(() => { expect(screen.getByText('test.txt')).toBeInTheDocument(); }); const removeButton = screen.getByTitle('Remove'); fireEvent.click(removeButton); await waitFor(() => { expect(screen.queryByText('test.txt')).not.toBeInTheDocument(); }); }); it('clears completed items when clear button clicked', async () => { const MockXHR = createMockXHR(); vi.stubGlobal('XMLHttpRequest', MockXHR); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const file = createMockFile('test.txt', 100, 'text/plain'); Object.defineProperty(input, 'files', { value: Object.assign([file], { item: (i: number) => [file][i] }), }); fireEvent.change(input); await vi.advanceTimersByTimeAsync(100); await waitFor(() => { const clearButton = screen.queryByText(/clear finished/i); if (clearButton) { fireEvent.click(clearButton); } }); }); }); });