mirror of https://github.com/kern/filepizza
Add e2e file transfer test with playwright (#289)
* Add e2e test for file transfers * fix * fmtpull/253/merge
parent
ec70693b54
commit
106ebb972a
@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(npm test:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
@ -0,0 +1,93 @@
|
||||
/// <reference types="@playwright/test" />
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createTestFile,
|
||||
uploadFile,
|
||||
startUpload,
|
||||
downloadFile,
|
||||
verifyFileIntegrity,
|
||||
verifyTransferCompletion,
|
||||
createBrowserContexts,
|
||||
monitorChunkProgress,
|
||||
verifyPreciseProgress,
|
||||
} from './helpers'
|
||||
|
||||
interface TestCase {
|
||||
name: string
|
||||
fileSizeMultiplier: number
|
||||
expectedChunks: number
|
||||
fillChar: string
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 256 * 1024 // 256 KB
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
name: 'tiny file (basic transfer)',
|
||||
fileSizeMultiplier: 0.1, // ~26KB
|
||||
expectedChunks: 1,
|
||||
fillChar: 'T',
|
||||
},
|
||||
{
|
||||
name: 'small file (single chunk)',
|
||||
fileSizeMultiplier: 0.5, // 128KB
|
||||
expectedChunks: 1,
|
||||
fillChar: 'S',
|
||||
},
|
||||
{
|
||||
name: 'medium file (3 chunks)',
|
||||
fileSizeMultiplier: 2.5, // 640KB
|
||||
expectedChunks: 3,
|
||||
fillChar: 'M',
|
||||
},
|
||||
{
|
||||
name: 'large file (4 chunks)',
|
||||
fileSizeMultiplier: 4, // 1024KB
|
||||
expectedChunks: 4,
|
||||
fillChar: 'L',
|
||||
},
|
||||
{
|
||||
name: 'extra large file (7 chunks)',
|
||||
fileSizeMultiplier: 6.5, // ~1664KB
|
||||
expectedChunks: 7,
|
||||
fillChar: 'X',
|
||||
},
|
||||
]
|
||||
|
||||
for (const testCase of testCases) {
|
||||
test(`file transfer: ${testCase.name}`, async ({ browser }) => {
|
||||
const fileSize = Math.floor(CHUNK_SIZE * testCase.fileSizeMultiplier)
|
||||
const testFile = createTestFile(
|
||||
`test-${testCase.fillChar.toLowerCase()}-${testCase.expectedChunks}chunks.txt`,
|
||||
testCase.fillChar.repeat(fileSize)
|
||||
)
|
||||
|
||||
const { uploaderPage, downloaderPage, cleanup } = await createBrowserContexts(browser)
|
||||
|
||||
try {
|
||||
// Set up precise chunk and progress monitoring
|
||||
const monitor = await monitorChunkProgress(uploaderPage, downloaderPage, fileSize)
|
||||
|
||||
await uploadFile(uploaderPage, testFile)
|
||||
const shareUrl = await startUpload(uploaderPage)
|
||||
const downloadPath = await downloadFile(downloaderPage, shareUrl, testFile)
|
||||
|
||||
await verifyFileIntegrity(downloadPath, testFile)
|
||||
await verifyTransferCompletion(downloaderPage)
|
||||
|
||||
// Wait for all async progress captures to complete
|
||||
await downloaderPage.waitForTimeout(1000)
|
||||
|
||||
// Verify precise progress tracking for both upload and download
|
||||
verifyPreciseProgress(monitor.uploadChunks, testCase.expectedChunks, 'upload')
|
||||
verifyPreciseProgress(monitor.downloadChunks, testCase.expectedChunks, 'download')
|
||||
|
||||
// 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 {
|
||||
await cleanup()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,293 @@
|
||||
import { Page, Browser, expect } from '@playwright/test'
|
||||
import { createHash } from 'crypto'
|
||||
import { writeFileSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
export interface TestFile {
|
||||
name: string
|
||||
content: string
|
||||
path: string
|
||||
checksum: string
|
||||
}
|
||||
|
||||
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,
|
||||
path: testFilePath,
|
||||
checksum,
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
page: Page,
|
||||
testFile: TestFile,
|
||||
): Promise<void> {
|
||||
// Navigate to home page
|
||||
await page.goto('http://localhost:3000/')
|
||||
await expect(
|
||||
page.getByText('Peer-to-peer file transfers in your browser.'),
|
||||
).toBeVisible()
|
||||
|
||||
// Wait for drop zone button to be ready
|
||||
await expect(page.locator('#drop-zone-button')).toBeVisible()
|
||||
|
||||
// Upload file using the file input and trigger change event
|
||||
await page.evaluate(
|
||||
({ testContent, testFileName }) => {
|
||||
const input = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement
|
||||
if (input) {
|
||||
const file = new File([testContent], testFileName, {
|
||||
type: 'text/plain',
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
input.files = dataTransfer.files
|
||||
|
||||
// Manually trigger the change event
|
||||
const event = new Event('change', { bubbles: true })
|
||||
input.dispatchEvent(event)
|
||||
}
|
||||
},
|
||||
{ testContent: testFile.content, testFileName: testFile.name },
|
||||
)
|
||||
|
||||
// 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 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function startUpload(page: Page): Promise<string> {
|
||||
// Start sharing
|
||||
await page.locator('#start-button').click()
|
||||
|
||||
// Wait for uploading state and get the share URL
|
||||
await expect(page.getByText(/You are uploading/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Get the share URL from the copyable input (Long URL)
|
||||
const shareUrlInput = page.locator('#copyable-input-long-url')
|
||||
await expect(shareUrlInput).toBeVisible({ timeout: 5000 })
|
||||
const shareUrl = await shareUrlInput.inputValue()
|
||||
|
||||
expect(shareUrl).toMatch(/http:\/\/localhost:3000\//)
|
||||
return shareUrl
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
page: Page,
|
||||
shareUrl: string,
|
||||
testFile: TestFile,
|
||||
): Promise<string> {
|
||||
// Navigate to share URL
|
||||
await page.goto(shareUrl)
|
||||
|
||||
// Wait for download page to load
|
||||
await expect(page.getByText(testFile.name)).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Start download
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.locator('#download-button').click()
|
||||
const download = await downloadPromise
|
||||
|
||||
// Verify download
|
||||
expect(download.suggestedFilename()).toBe(testFile.name)
|
||||
|
||||
// Save downloaded file
|
||||
const downloadPath = join(tmpdir(), `downloaded-${testFile.name}`)
|
||||
await download.saveAs(downloadPath)
|
||||
|
||||
return downloadPath
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const downloadedChecksum = createHash('sha256')
|
||||
.update(downloadedContent)
|
||||
.digest('hex')
|
||||
expect(downloadedChecksum).toBe(testFile.checksum)
|
||||
}
|
||||
|
||||
export async function verifyTransferCompletion(
|
||||
downloaderPage: Page,
|
||||
): Promise<void> {
|
||||
// Verify download completion on downloader side
|
||||
await expect(downloaderPage.getByText(/You downloaded/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createBrowserContexts(browser: Browser): Promise<{
|
||||
uploaderPage: Page
|
||||
downloaderPage: Page
|
||||
cleanup: () => Promise<void>
|
||||
}> {
|
||||
const uploaderContext = await browser.newContext()
|
||||
const downloaderContext = await browser.newContext()
|
||||
|
||||
const uploaderPage = await uploaderContext.newPage()
|
||||
const downloaderPage = await downloaderContext.newPage()
|
||||
|
||||
const cleanup = async () => {
|
||||
await uploaderContext.close()
|
||||
await downloaderContext.close()
|
||||
}
|
||||
|
||||
return { uploaderPage, downloaderPage, cleanup }
|
||||
}
|
||||
|
||||
export interface ProgressMonitor {
|
||||
uploaderProgress: number
|
||||
downloaderProgress: number
|
||||
maxProgress: number
|
||||
}
|
||||
|
||||
export interface ChunkProgressLog {
|
||||
chunkNumber: number
|
||||
fileName: string
|
||||
offset: number
|
||||
end: number
|
||||
fileSize: number
|
||||
final: boolean
|
||||
progressPercentage: number
|
||||
side: 'upload' | 'download'
|
||||
}
|
||||
|
||||
export interface PreciseChunkMonitor {
|
||||
uploadChunks: ChunkProgressLog[]
|
||||
downloadChunks: ChunkProgressLog[]
|
||||
}
|
||||
|
||||
export async function monitorChunkProgress(
|
||||
uploaderPage: Page,
|
||||
downloaderPage: Page,
|
||||
expectedFileSize: number,
|
||||
): 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+)/,
|
||||
)
|
||||
if (ackMatch) {
|
||||
const [, fileName, offset, bytes] = ackMatch
|
||||
|
||||
// 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')
|
||||
) {
|
||||
// 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+)/,
|
||||
)
|
||||
if (chunkMatch) {
|
||||
const [, chunkNum, fileName, offset, end, final] = chunkMatch
|
||||
|
||||
// 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
uploadChunks,
|
||||
downloadChunks,
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyPreciseProgress(
|
||||
chunks: ChunkProgressLog[],
|
||||
expectedChunks: number,
|
||||
side: 'upload' | 'download',
|
||||
): void {
|
||||
expect(chunks.length).toBe(expectedChunks)
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i]
|
||||
|
||||
console.log(
|
||||
`${side} chunk ${chunk.chunkNumber}: ${chunk.offset}-${chunk.end}/${chunk.fileSize} ` +
|
||||
`progress=${chunk.progressPercentage}% final=${chunk.final}`,
|
||||
)
|
||||
|
||||
// Verify chunks are received in order
|
||||
expect(chunk.chunkNumber).toBe(i + 1)
|
||||
|
||||
// Verify progress is monotonically increasing
|
||||
if (i > 0) {
|
||||
expect(chunk.progressPercentage).toBeGreaterThanOrEqual(
|
||||
chunks[i - 1].progressPercentage,
|
||||
)
|
||||
}
|
||||
|
||||
// For the final chunk, ensure we reach exactly 100%
|
||||
if (chunk.final) {
|
||||
expect(chunk.progressPercentage).toBe(100)
|
||||
}
|
||||
|
||||
// Verify progress percentage is reasonable (0-100%)
|
||||
expect(chunk.progressPercentage).toBeGreaterThanOrEqual(0)
|
||||
expect(chunk.progressPercentage).toBeLessThanOrEqual(100)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue