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