feat/e2e-w-playwright
Alex Kern 5 months ago
parent fab1c4bb58
commit 31bf09cd5d
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

@ -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.

@ -61,7 +61,9 @@ export function ConnectionListItem({
</div>
<ProgressBar
value={
(conn.completedFiles + conn.currentFileProgress) / conn.totalFiles
conn.completedFiles === conn.totalFiles
? 1
: (conn.completedFiles + conn.currentFileProgress) / conn.totalFiles
}
max={1}
/>

@ -1,6 +0,0 @@
{
"status": "failed",
"failedTests": [
"79024892fc2de608051f-528b0ecfc96903d743c3"
]
}

@ -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 {

@ -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<void> {
export async function uploadFile(
page: Page,
testFile: TestFile,
): Promise<void> {
// 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<void>
)
// 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<string> {
@ -81,7 +84,11 @@ export async function startUpload(page: Page): Promise<string> {
return shareUrl
}
export async function downloadFile(page: Page, shareUrl: string, testFile: TestFile): Promise<string> {
export async function downloadFile(
page: Page,
shareUrl: string,
testFile: TestFile,
): Promise<string> {
// 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<void> {
export async function verifyFileIntegrity(
downloadPath: string,
testFile: TestFile,
): Promise<void> {
// 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<void> {
export async function verifyTransferCompletion(
downloaderPage: Page,
): Promise<void> {
// 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<PreciseChunkMonitor> {
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<ProgressMonitor> {
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,
}
}
Loading…
Cancel
Save