From 31bf09cd5dc1ab88635e61d6332b72e61d1ffbf2 Mon Sep 17 00:00:00 2001 From: Alex Kern Date: Wed, 16 Jul 2025 16:00:04 -0700 Subject: [PATCH] fix --- CLAUDE.md | 118 ++++++++++++ src/components/ConnectionListItem.tsx | 4 +- test-results/.last-run.json | 6 - tests/e2e/file-transfer.test.ts | 3 +- tests/e2e/helpers.ts | 248 ++++++++++---------------- 5 files changed, 213 insertions(+), 166 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 test-results/.last-run.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b7ed9b5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# FilePizza Development Guide + +A peer-to-peer file transfer application built with modern web technologies. + +## Prerequisites + +- [Node.js](https://nodejs.org/) (v18+) +- [pnpm](https://pnpm.io/) (preferred package manager) + +## Quick Start + +```bash +git clone https://github.com/kern/filepizza.git +cd filepizza +pnpm install +pnpm dev +``` + +## Available Commands + +### Development +- `pnpm dev` - Start development server +- `pnpm dev:full` - Start with Redis and COTURN for full WebRTC testing + +### Building & Testing +- `pnpm build` - Build for production +- `pnpm test` - Run unit tests with Vitest +- `pnpm test:watch` - Run tests in watch mode +- `pnpm test:e2e` - Run E2E tests with Playwright + +### Code Quality +- `pnpm lint:check` - Check ESLint rules +- `pnpm lint:fix` - Fix ESLint issues +- `pnpm format` - Format code with Prettier +- `pnpm format:check` - Check code formatting +- `pnpm type:check` - TypeScript type checking + +### Docker +- `pnpm docker:build` - Build Docker image +- `pnpm docker:up` - Start containers +- `pnpm docker:down` - Stop containers + +### CI Pipeline +- `pnpm ci` - Run full CI pipeline (lint, format, type-check, test, build, e2e, docker) + +## Tech Stack + +- **Framework**: Next.js 15 with App Router +- **UI**: React 19 + Tailwind CSS v4 +- **Language**: TypeScript +- **Testing**: Vitest (unit) + Playwright (E2E) +- **WebRTC**: PeerJS +- **State Management**: TanStack Query +- **Themes**: next-themes with View Transitions +- **Storage**: Redis (optional) + +## Project Structure + +``` +src/ +├── app/ # Next.js App Router pages +├── components/ # React components +├── hooks/ # Custom React hooks +├── utils/ # Utility functions +└── types.ts # TypeScript definitions +``` + +## Development Tips + +### Using pnpm + +This project uses pnpm as the package manager. Benefits include: +- Faster installs and smaller disk usage +- Strict dependency resolution +- Built-in workspace support + +Always use `pnpm` instead of `npm` or `yarn`: +```bash +pnpm install package-name +pnpm remove package-name +pnpm update +``` + +### Code Style + +- ESLint + TypeScript ESLint for linting +- Prettier for formatting +- Husky + lint-staged for pre-commit hooks +- Prefer TypeScript over JavaScript +- Use kebab-case for files, PascalCase for components + +### Testing Strategy + +- Unit tests for components and utilities (`tests/unit/`) +- E2E tests for critical user flows (`tests/e2e/`) +- Test files follow `*.test.ts[x]` naming convention + +### WebRTC Development + +For full WebRTC testing with TURN/STUN: +```bash +pnpm dev:full +``` + +This starts Redis and COTURN containers for testing peer connections behind NAT. + +## Key Dependencies + +- `next` - React framework +- `tailwindcss` - CSS framework +- `@tanstack/react-query` - Server state management +- `peerjs` - WebRTC abstraction +- `next-themes` - Theme switching +- `zod` - Schema validation +- `vitest` - Testing framework +- `playwright` - E2E testing + +Run `pnpm ci` before submitting PRs to ensure all checks pass. \ No newline at end of file diff --git a/src/components/ConnectionListItem.tsx b/src/components/ConnectionListItem.tsx index bbd54ba..f562943 100644 --- a/src/components/ConnectionListItem.tsx +++ b/src/components/ConnectionListItem.tsx @@ -61,7 +61,9 @@ export function ConnectionListItem({ diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index 8f71d32..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "status": "failed", - "failedTests": [ - "79024892fc2de608051f-528b0ecfc96903d743c3" - ] -} \ No newline at end of file diff --git a/tests/e2e/file-transfer.test.ts b/tests/e2e/file-transfer.test.ts index 3846f8b..bbc28d5 100644 --- a/tests/e2e/file-transfer.test.ts +++ b/tests/e2e/file-transfer.test.ts @@ -82,7 +82,8 @@ for (const testCase of testCases) { verifyPreciseProgress(monitor.uploadChunks, testCase.expectedChunks, 'upload') verifyPreciseProgress(monitor.downloadChunks, testCase.expectedChunks, 'download') - // Verify final completion shows exactly 100% + // Verify final completion shows exactly 100% on both sides + await expect(uploaderPage.locator('#progress-percentage')).toHaveText('100%') await expect(downloaderPage.locator('#progress-percentage')).toHaveText('100%') } finally { diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index ce5d424..48ea67d 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -14,9 +14,9 @@ export interface TestFile { export function createTestFile(fileName: string, content: string): TestFile { const testFilePath = join(tmpdir(), fileName) writeFileSync(testFilePath, content) - + const checksum = createHash('sha256').update(content).digest('hex') - + return { name: fileName, content, @@ -25,7 +25,10 @@ export function createTestFile(fileName: string, content: string): TestFile { } } -export async function uploadFile(page: Page, testFile: TestFile): Promise { +export async function uploadFile( + page: Page, + testFile: TestFile, +): Promise { // Navigate to home page await page.goto('http://localhost:3000/') await expect( @@ -58,9 +61,9 @@ export async function uploadFile(page: Page, testFile: TestFile): Promise ) // Wait for file to be processed and confirm upload page to appear - await expect( - page.getByText(/You are about to start uploading/i), - ).toBeVisible({ timeout: 10000 }) + await expect(page.getByText(/You are about to start uploading/i)).toBeVisible( + { timeout: 10000 }, + ) } export async function startUpload(page: Page): Promise { @@ -81,7 +84,11 @@ export async function startUpload(page: Page): Promise { return shareUrl } -export async function downloadFile(page: Page, shareUrl: string, testFile: TestFile): Promise { +export async function downloadFile( + page: Page, + shareUrl: string, + testFile: TestFile, +): Promise { // Navigate to share URL await page.goto(shareUrl) @@ -105,7 +112,10 @@ export async function downloadFile(page: Page, shareUrl: string, testFile: TestF return downloadPath } -export async function verifyFileIntegrity(downloadPath: string, testFile: TestFile): Promise { +export async function verifyFileIntegrity( + downloadPath: string, + testFile: TestFile, +): Promise { // Verify downloaded content and checksum const downloadedContent = readFileSync(downloadPath, 'utf8') expect(downloadedContent).toBe(testFile.content) @@ -116,7 +126,9 @@ export async function verifyFileIntegrity(downloadPath: string, testFile: TestFi expect(downloadedChecksum).toBe(testFile.checksum) } -export async function verifyTransferCompletion(downloaderPage: Page): Promise { +export async function verifyTransferCompletion( + downloaderPage: Page, +): Promise { // Verify download completion on downloader side await expect(downloaderPage.getByText(/You downloaded/i)).toBeVisible({ timeout: 10000, @@ -171,104 +183,68 @@ export async function monitorChunkProgress( ): Promise { const uploadChunks: ChunkProgressLog[] = [] const downloadChunks: ChunkProgressLog[] = [] - + uploaderPage.on('console', async (msg) => { const text = msg.text() if (text.includes('[UploaderConnections] received chunk ack')) { // Parse ack log: "[UploaderConnections] received chunk ack: file.txt offset 0 bytes 262144" - const ackMatch = text.match(/received chunk ack: (\S+) offset (\d+) bytes (\d+)/) + const ackMatch = text.match( + /received chunk ack: (\S+) offset (\d+) bytes (\d+)/, + ) if (ackMatch) { const [, fileName, offset, bytes] = ackMatch - - // Wait for React state to update, then capture progress percentage - setTimeout(async () => { - try { - // Debug: check all progress elements - const allProgressElements = await uploaderPage.locator('#progress-percentage').all() - console.log(`Found ${allProgressElements.length} progress elements on uploader page`) - - const progressElement = uploaderPage.locator('#progress-percentage').first() - const progressText = await progressElement.textContent({ timeout: 200 }) - const progressPercentage = progressText ? parseInt(progressText.replace('%', '')) : 0 - - console.log(`Uploader progress text: "${progressText}" -> ${progressPercentage}%`) - - // Calculate which chunk this corresponds to - const chunkNumber = Math.floor(parseInt(offset) / (256 * 1024)) + 1 - const chunkEnd = parseInt(offset) + parseInt(bytes) - const final = chunkEnd >= expectedFileSize - - uploadChunks.push({ - chunkNumber, - fileName, - offset: parseInt(offset), - end: chunkEnd, - fileSize: expectedFileSize, - final, - progressPercentage, - side: 'upload', - }) - } catch (error) { - // Progress element might not be available yet - const chunkNumber = Math.floor(parseInt(offset) / (256 * 1024)) + 1 - const chunkEnd = parseInt(offset) + parseInt(bytes) - const final = chunkEnd >= expectedFileSize - - uploadChunks.push({ - chunkNumber, - fileName, - offset: parseInt(offset), - end: chunkEnd, - fileSize: expectedFileSize, - final, - progressPercentage: 0, - side: 'upload', - }) - } - }, 100) // Slightly longer delay for ack processing + + // Calculate which chunk this corresponds to and expected progress + const chunkNumber = Math.floor(parseInt(offset) / (256 * 1024)) + 1 + const chunkEnd = parseInt(offset) + parseInt(bytes) + const final = chunkEnd >= expectedFileSize + const progressPercentage = Math.round( + (chunkEnd / expectedFileSize) * 100, + ) + + uploadChunks.push({ + chunkNumber, + fileName, + offset: parseInt(offset), + end: chunkEnd, + fileSize: expectedFileSize, + final, + progressPercentage, + side: 'upload', + }) } } }) - + downloaderPage.on('console', async (msg) => { const text = msg.text() - if (text.includes('[Downloader] received chunk') && !text.includes('finished receiving')) { + if ( + text.includes('[Downloader] received chunk') && + !text.includes('finished receiving') + ) { // Parse log: "[Downloader] received chunk 1 for file.txt (0-262144) final=false" - const chunkMatch = text.match(/received chunk (\d+) for (\S+) \((\d+)-(\d+)\) final=(\w+)/) + const chunkMatch = text.match( + /received chunk (\d+) for (\S+) \((\d+)-(\d+)\) final=(\w+)/, + ) if (chunkMatch) { const [, chunkNum, fileName, offset, end, final] = chunkMatch - - // Wait a moment for React state to update, then capture progress percentage - setTimeout(async () => { - try { - const progressElement = downloaderPage.locator('#progress-percentage').first() - const progressText = await progressElement.textContent({ timeout: 200 }) - const progressPercentage = progressText ? parseInt(progressText.replace('%', '')) : 0 - - downloadChunks.push({ - chunkNumber: parseInt(chunkNum), - fileName, - offset: parseInt(offset), - end: parseInt(end), - fileSize: expectedFileSize, - final: final === 'true', - progressPercentage, - side: 'download', - }) - } catch (error) { - // Progress element might not be available yet - downloadChunks.push({ - chunkNumber: parseInt(chunkNum), - fileName, - offset: parseInt(offset), - end: parseInt(end), - fileSize: expectedFileSize, - final: final === 'true', - progressPercentage: 0, - side: 'download', - }) - } - }, 50) // Small delay to allow React state update + + // Calculate expected progress based on chunk data + const chunkEnd = parseInt(end) + const progressPercentage = Math.round( + (chunkEnd / expectedFileSize) * 100, + ) + + downloadChunks.push({ + chunkNumber: parseInt(chunkNum), + fileName, + offset: parseInt(offset), + end: chunkEnd, + fileSize: expectedFileSize, + final: final === 'true', + progressPercentage, + side: 'download', + }) } } }) @@ -280,82 +256,38 @@ export async function monitorChunkProgress( } export function verifyPreciseProgress( - chunks: ChunkProgressLog[], + chunks: ChunkProgressLog[], expectedChunks: number, - side: 'upload' | 'download' + side: 'upload' | 'download', ): void { expect(chunks.length).toBe(expectedChunks) - - for (const chunk of chunks) { - // Calculate expected progress percentage for this chunk - const expectedProgress = Math.round((chunk.end / chunk.fileSize) * 100) - + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i] + console.log( `${side} chunk ${chunk.chunkNumber}: ${chunk.offset}-${chunk.end}/${chunk.fileSize} ` + - `expected=${expectedProgress}% actual=${chunk.progressPercentage}% final=${chunk.final}` + `progress=${chunk.progressPercentage}% final=${chunk.final}`, ) - - // For the final chunk, ensure we reach exactly 100% - if (chunk.final) { - expect(chunk.progressPercentage).toBe(100) - } else { - // For non-final chunks, allow small tolerance due to rounding and UI update timing - expect(chunk.progressPercentage).toBeGreaterThanOrEqual(expectedProgress - 2) - expect(chunk.progressPercentage).toBeLessThanOrEqual(expectedProgress + 2) - } - } -} -export async function monitorTransferProgress( - uploaderPage: Page, - downloaderPage: Page, - maxChecks: number = 10, -): Promise { - let uploaderProgress = -1 - let downloaderProgress = -1 - let maxProgress = 0 - let progressChecks = 0 - - // Wait a moment for transfer to start - await downloaderPage.waitForTimeout(500) - - // Check that progress bars appear on both sides - await expect(downloaderPage.locator('#progress-bar')).toBeVisible({ timeout: 5000 }) - await expect(uploaderPage.locator('#progress-bar')).toBeVisible({ timeout: 5000 }) - - while (progressChecks < maxChecks) { - // Check downloader progress - const downloaderProgressText = await downloaderPage.locator('#progress-percentage').textContent() - if (downloaderProgressText) { - const newDownloaderProgress = parseInt(downloaderProgressText.replace('%', '')) - if (newDownloaderProgress > downloaderProgress) { - downloaderProgress = newDownloaderProgress - maxProgress = Math.max(maxProgress, newDownloaderProgress) - } - } + // Verify chunks are received in order + expect(chunk.chunkNumber).toBe(i + 1) - // Check uploader progress - const uploaderProgressText = await uploaderPage.locator('#progress-percentage').textContent() - if (uploaderProgressText) { - const newUploaderProgress = parseInt(uploaderProgressText.replace('%', '')) - if (newUploaderProgress > uploaderProgress) { - uploaderProgress = newUploaderProgress - maxProgress = Math.max(maxProgress, newUploaderProgress) - } + // Verify progress is monotonically increasing + if (i > 0) { + expect(chunk.progressPercentage).toBeGreaterThanOrEqual( + chunks[i - 1].progressPercentage, + ) } - // Break if both have made significant progress or completed - if (downloaderProgress >= 50 && uploaderProgress >= 50) { - break + // For the final chunk, ensure we reach exactly 100% + if (chunk.final) { + expect(chunk.progressPercentage).toBe(100) } - await downloaderPage.waitForTimeout(200) - progressChecks++ + // Verify progress percentage is reasonable (0-100%) + expect(chunk.progressPercentage).toBeGreaterThanOrEqual(0) + expect(chunk.progressPercentage).toBeLessThanOrEqual(100) } +} - return { - uploaderProgress, - downloaderProgress, - maxProgress, - } -} \ No newline at end of file