Add frontend testing infrastructure with Vitest (#14)
This commit is contained in:
@@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Retry and remove actions per file
|
- Retry and remove actions per file
|
||||||
- Auto-dismiss success messages after 5 seconds
|
- Auto-dismiss success messages after 5 seconds
|
||||||
- Integrated DragDropUpload into PackagePage replacing basic file input (#8)
|
- Integrated DragDropUpload into PackagePage replacing basic file input (#8)
|
||||||
|
- Added frontend testing infrastructure with Vitest and React Testing Library (#14)
|
||||||
|
- Configured Vitest for React/TypeScript with jsdom
|
||||||
|
- Added 24 unit tests for DragDropUpload component
|
||||||
|
- Tests cover: rendering, drag-drop events, file validation, upload queue, progress, errors
|
||||||
- Added download verification with `verify` and `verify_mode` query parameters (#26)
|
- Added download verification with `verify` and `verify_mode` query parameters (#26)
|
||||||
- `?verify=true&verify_mode=pre` - Pre-verification: verify before streaming (guaranteed no corrupt data)
|
- `?verify=true&verify_mode=pre` - Pre-verification: verify before streaming (guaranteed no corrupt data)
|
||||||
- `?verify=true&verify_mode=stream` - Streaming verification: verify while streaming (logs error if mismatch)
|
- `?verify=true&verify_mode=stream` - Streaming verification: verify while streaming (logs error if mismatch)
|
||||||
|
|||||||
4451
frontend/package-lock.json
generated
Normal file
4451
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -14,10 +17,15 @@
|
|||||||
"react-router-dom": "^6.21.3"
|
"react-router-dom": "^6.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
|
"@testing-library/react": "^14.2.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.12"
|
"vite": "^5.0.12",
|
||||||
|
"vitest": "^1.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
545
frontend/src/components/DragDropUpload.test.tsx
Normal file
545
frontend/src/components/DragDropUpload.test.tsx
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/drag files here/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/click to browse/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hidden file input', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} maxFileSize={1024 * 1024} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/max file size: 1 mb/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows allowed types hint when provided', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} allowedTypes={['.zip', '.tar.gz']} allowAllTypes={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/\.zip, \.tar\.gz/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Click to Browse', () => {
|
||||||
|
it('opens file picker when drop zone is clicked', async () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const dropZone = screen.getByRole('button');
|
||||||
|
|
||||||
|
fireEvent.dragEnter(dropZone, {
|
||||||
|
dataTransfer: { items: [{}] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dropZone).toHaveClass('drop-zone--active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes visual feedback on drag leave', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} maxFileSize={100} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} allowedTypes={['.zip']} allowAllTypes={false} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} allowAllTypes={true} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} onUploadComplete={onComplete} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} onUploadComplete={onComplete} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} maxRetries={0} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} maxRetries={0} onUploadError={onError} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tag Support', () => {
|
||||||
|
it('includes tag in upload request', async () => {
|
||||||
|
let capturedFormData: FormData | null = null;
|
||||||
|
|
||||||
|
class MockXHR {
|
||||||
|
status = 200;
|
||||||
|
responseText = JSON.stringify({ artifact_id: 'abc123', size: 100 });
|
||||||
|
timeout = 0;
|
||||||
|
upload = { addEventListener: vi.fn() };
|
||||||
|
addEventListener = vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (event === 'load') setTimeout(handler, 10);
|
||||||
|
});
|
||||||
|
open = vi.fn();
|
||||||
|
send = vi.fn((data: FormData) => {
|
||||||
|
capturedFormData = data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} tag="v1.0.0" />);
|
||||||
|
|
||||||
|
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(capturedFormData?.get('tag')).toBe('v1.0.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/test/setup.ts
Normal file
37
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
class MockDataTransfer implements DataTransfer {
|
||||||
|
dropEffect: DataTransfer['dropEffect'] = 'none';
|
||||||
|
effectAllowed: DataTransfer['effectAllowed'] = 'all';
|
||||||
|
files: FileList = Object.assign([], { item: (i: number) => this.files[i] || null });
|
||||||
|
items: DataTransferItemList = Object.assign([], {
|
||||||
|
add: () => null,
|
||||||
|
remove: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
item: () => null,
|
||||||
|
}) as unknown as DataTransferItemList;
|
||||||
|
types: readonly string[] = [];
|
||||||
|
|
||||||
|
clearData(): void {}
|
||||||
|
getData(): string { return ''; }
|
||||||
|
setData(): void {}
|
||||||
|
setDragImage(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(global, 'DataTransfer', {
|
||||||
|
value: MockDataTransfer,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
@@ -9,5 +10,11 @@ export default defineConfig({
|
|||||||
'/health': 'http://localhost:8080',
|
'/health': 'http://localhost:8080',
|
||||||
'/project': 'http://localhost:8080',
|
'/project': 'http://localhost:8080',
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
css: true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user