Merge branch 'main' of github.com:kern/filepizza

# Conflicts:
#	package.json
#	pnpm-lock.yaml
#	src/app/page.tsx
pull/313/head
abawi 3 months ago
commit 91f4ee5149

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(pnpm build:*)",
"Bash(pnpm test:*)",
"Bash(npm test:*)"
],
"deny": []
}
}

@ -13,7 +13,10 @@ jobs:
node-version: 20 node-version: 20
cache: 'pnpm' cache: 'pnpm'
- run: pnpm install - run: pnpm install
- run: pnpm exec playwright install --with-deps
- run: pnpm lint:check - run: pnpm lint:check
- run: pnpm format:check - run: pnpm format:check
- run: pnpm type:check - run: pnpm type:check
- run: pnpm test
- run: pnpm build - run: pnpm build
- run: pnpm test:e2e

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

@ -135,6 +135,16 @@ If you need to set up the tunnel manually:
* View Transitions * View Transitions
* Redis (optional) * Redis (optional)
## Configuration
The server can be customized with the following environment variables:
- `REDIS_URL` Connection string for a Redis instance used to store channel metadata. If not set, FilePizza falls back to in-memory storage.
- `COTURN_ENABLED` When set to `true`, enables TURN support for connecting peers behind NAT.
- `TURN_HOST` Hostname or IP address of the TURN server. Defaults to `127.0.0.1`.
- `TURN_REALM` Realm used when generating TURN credentials. Defaults to `file.pizza`.
- `STUN_SERVER` STUN server URL to use when `COTURN_ENABLED` is disabled. Defaults to `stun:stun.l.google.com:19302`.
## FAQ ## FAQ
**How are my files sent?** Your files are sent directly from your browser to the downloader's browser. They never pass through our servers. FilePizza uses WebRTC to send files. This requires that the uploader leave their browser window open until the transfer is complete. **How are my files sent?** Your files are sent directly from your browser to the downloader's browser. They never pass through our servers. FilePizza uses WebRTC to send files. This requires that the uploader leave their browser window open until the transfer is complete.

@ -0,0 +1,212 @@
# FilePizza File Transfer Protocol
This document explains the message-based protocol that FilePizza uses to
transfer files directly between browsers over a WebRTC data channel. It
covers the complete conversation required to build either an uploader or a
downloader and includes examples for common scenarios.
## Architecture Overview
```mermaid
flowchart LR
Uploader -- WebRTC / PeerJS --> Downloader
Uploader -- REST --> Server[(FilePizza Server)]
Downloader -- REST --> Server
Server -- signalling / slug --> Uploader
Server -- signalling / slug --> Downloader
```
1. The uploader creates a channel with the server and receives a slug that
encodes its PeerJS identifier.
2. The downloader resolves the slug via the server to obtain the uploader's
PeerJS identifier.
3. All subsequent messages travel directly between peers over a reliable
WebRTC data channel.
## Message Types
Every message is a JSON object with a `type` field that matches one of the
values in the table below. Fields marked with `?` are optional.
```mermaid
classDiagram
class RequestInfo {
+"RequestInfo" type
+string browserName
+string browserVersion
+string osName
+string osVersion
+string mobileVendor
+string mobileModel
}
class Info {
+"Info" type
+FileInfo[] files
}
class FileInfo {
+string fileName
+number size
+string type
}
class Start {
+"Start" type
+string fileName
+number offset
}
class Chunk {
+"Chunk" type
+string fileName
+number offset
+ArrayBuffer bytes
+boolean final
}
class ChunkAck {
+"ChunkAck" type
+string fileName
+number offset
+number bytesReceived
}
class Pause {
+"Pause" type
}
class Done {
+"Done" type
}
class Error {
+"Error" type
+string error
}
class PasswordRequired {
+"PasswordRequired" type
+string errorMessage?
}
class UsePassword {
+"UsePassword" type
+string password
}
class Report {
+"Report" type
}
```
Chunks are sent in pieces of at most 256 KiB (`MAX_CHUNK_SIZE`). The `final` flag in a `Chunk` message marks the last piece of a file.
## Normal Transfer Sequence
The following diagram shows the exchange for downloading multiple files
without a password.
```mermaid
sequenceDiagram
participant D as Downloader
participant U as Uploader
D->>U: RequestInfo
U-->>D: Info(files)
loop For each file
D->>U: Start(fileName, offset=0)
loop For each chunk
U-->>D: Chunk(offset, bytes, final=false)
D->>U: ChunkAck(offset, bytesReceived)
end
U-->>D: Chunk(offset, bytes, final=true)
D->>U: ChunkAck(offset, bytesReceived)
end
D->>U: Done
U-->>D: close connection
```
## PasswordProtected Transfers
If the uploader specified a password when creating the channel, the
conversation includes an authentication step.
```mermaid
sequenceDiagram
participant D as Downloader
participant U as Uploader
D->>U: RequestInfo
U-->>D: PasswordRequired(errorMessage?)
D->>U: UsePassword(password)
U-->>D: Info(files) or PasswordRequired("Invalid password")
Note over D,U: Continue with normal transfer sequence on success
```
## Pause and Resume
A downloader may pause an inprogress transfer. To resume, it reconnects and
requests the remainder of the file starting at the last acknowledged offset.
```mermaid
sequenceDiagram
participant D as Downloader
participant U as Uploader
D->>U: Start(fileName, offset=0)
U-->>D: Chunk(...)
D->>U: ChunkAck(...)
D->>U: Pause
Note over D,U: Connection closed or kept idle
D->>U: Start(fileName, offset=previouslyAcked)
Note over D,U: Transfer resumes from offset
```
## Reporting
A special PeerJS connection with metadata `{ type: "report" }` causes the
uploader to broadcast a `Report` message to all connected downloaders and to
redirect its own UI to a reported page. Downloaders receiving this message
should abort the transfer.
```mermaid
sequenceDiagram
participant Reporter
participant U as Uploader
participant D as Downloader
Reporter->>U: Peer connection(type="report")
U-->>D: Report
U-->>Reporter: redirect to /reported
```
## Example Conversations
### Single file without password
```
RequestInfo
Info [{ fileName: "photo.jpg", size: 1048576, type: "image/jpeg" }]
Start { fileName: "photo.jpg", offset: 0 }
Chunk { offset: 0, bytes: <256 KB>, final: false }
ChunkAck { offset: 0, bytesReceived: 262144 }
...
Chunk { offset: 1048576, bytes: <0>, final: true }
ChunkAck { offset: 1048576, bytesReceived: 0 }
Done
```
### Passwordprotected download
```
RequestInfo
PasswordRequired
UsePassword { password: "secret" }
Info [...]
...
```
### Resuming after interruption
```
RequestInfo
Info [...]
Start { fileName: "video.mp4", offset: 0 }
Chunk/ChunkAck exchanges...
<connection drops after 1 MB>
Start { fileName: "video.mp4", offset: 1048576 }
Chunk/ChunkAck exchanges...
Done
```
---
With these message definitions and sequences you can implement a compatible
uploader or downloader for FilePizza or adapt the protocol for other
applications.

@ -11,7 +11,7 @@
"scripts": { "scripts": {
"dev": "next", "dev": "next",
"dev:full": "docker compose up redis coturn -d && COTURN_ENABLED=true REDIS_URL=redis://localhost:6379 next", "dev:full": "docker compose up redis coturn -d && COTURN_ENABLED=true REDIS_URL=redis://localhost:6379 next",
"build": "next build", "build": "next build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",
"start": "next start", "start": "next start",
"start:peerjs": "./bin/peerjs.js", "start:peerjs": "./bin/peerjs.js",
"lint:check": "eslint 'src/**/*.ts[x]'", "lint:check": "eslint 'src/**/*.ts[x]'",
@ -29,11 +29,14 @@
"format": "prettier --write \"src/**/*.{ts,tsx}\"", "format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"type:check": "tsc --noEmit", "type:check": "tsc --noEmit",
"ci": "pnpm lint:check && pnpm format:check && pnpm type:check && pnpm build && pnpm docker:build",
"tunnel:setup": "chmod +x ./scripts/run_filepizza_cloudflare_tunnel.sh", "tunnel:setup": "chmod +x ./scripts/run_filepizza_cloudflare_tunnel.sh",
"tunnel:start": "bash -c 'source ./.env && ./scripts/run_filepizza_cloudflare_tunnel.sh \"${CLOUDFLARE_API_KEY}\" \"${HOST_DOMAIN}\"'", "tunnel:start": "bash -c 'source ./.env && ./scripts/run_filepizza_cloudflare_tunnel.sh \"${CLOUDFLARE_API_KEY}\" \"${HOST_DOMAIN}\"'",
"tunnel:start:manual": "bash -c './scripts/run_filepizza_cloudflare_tunnel.sh \"$npm_config_apikey\" \"$npm_config_domain\"'", "tunnel:start:manual": "bash -c './scripts/run_filepizza_cloudflare_tunnel.sh \"$npm_config_apikey\" \"$npm_config_domain\"'",
"deploy:full": "pnpm docker:up && pnpm tunnel:setup && pnpm tunnel:start" "deploy:full": "pnpm docker:up && pnpm tunnel:setup && pnpm tunnel:start",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"ci": "pnpm lint:check && pnpm format:check && pnpm type:check && pnpm test && pnpm build && pnpm test:e2e && pnpm docker:build"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -43,6 +46,7 @@
"url": "https://github.com/TeXlyre/filepizza/issues" "url": "https://github.com/TeXlyre/filepizza/issues"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.55.2", "@tanstack/react-query": "^5.55.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"debug": "^4.3.6", "debug": "^4.3.6",
@ -60,26 +64,36 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-qr-code": "^2.0.15", "react-qr-code": "^2.0.15",
"streamsaver": "^2.0.6", "streamsaver": "^2.0.6",
"tailwindcss": "^3.4.10", "tailwindcss": "^4.1.11",
"web-streams-polyfill": "^4.0.0", "web-streams-polyfill": "^4.0.0",
"webrtcsupport": "^2.2.0", "webrtcsupport": "^2.2.0",
"zod": "^3.23.8" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.0",
"@playwright/test": "^1.53.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2", "@typescript-eslint/parser": "^8.18.2",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "^15.1.3", "eslint-config-next": "^15.1.3",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.3", "eslint-plugin-react": "^7.37.3",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^15.0.0", "jsdom": "^26.1.0",
"lint-staged": "^16.0.0",
"playwright": "^1.53.2",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.18.2" "typescript-eslint": "^8.18.2",
"vitest": "^3.2.4"
}, },
"husky": { "husky": {
"hooks": { "hooks": {

@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
workers: 1, // Run tests serially to avoid WebRTC port conflicts
webServer: {
command: 'node .next/standalone/server.js',
url: 'http://localhost:3000',
timeout: 120 * 1000,
reuseExistingServer: true,
},
use: {
baseURL: 'http://localhost:3000',
},
})

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {}, autoprefixer: {},
}, },
} }

@ -3,11 +3,12 @@ import crypto from 'crypto'
import { setTurnCredentials } from '../../../coturn' import { setTurnCredentials } from '../../../coturn'
const turnHost = process.env.TURN_HOST || '127.0.0.1' const turnHost = process.env.TURN_HOST || '127.0.0.1'
const stunServer = process.env.STUN_SERVER || 'stun:stun.l.google.com:19302'
export async function POST(): Promise<NextResponse> { export async function POST(): Promise<NextResponse> {
if (!process.env.COTURN_ENABLED) { if (!process.env.COTURN_ENABLED) {
return NextResponse.json({ return NextResponse.json({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], iceServers: [{ urls: stunServer }],
}) })
} }
@ -21,7 +22,7 @@ export async function POST(): Promise<NextResponse> {
return NextResponse.json({ return NextResponse.json({
iceServers: [ iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: stunServer },
{ {
urls: [`turn:${turnHost}:3478`, `turns:${turnHost}:5349`], urls: [`turn:${turnHost}:3478`, `turns:${turnHost}:5349`],
username, username,

@ -15,7 +15,9 @@ import Spinner from '../components/Spinner'
import Wordmark from '../components/Wordmark' import Wordmark from '../components/Wordmark'
import CancelButton from '../components/CancelButton' import CancelButton from '../components/CancelButton'
import TitleText from '../components/TitleText' import TitleText from '../components/TitleText'
import SubtitleText from '../components/SubtitleText'
import TermsAcceptance from '../components/TermsAcceptance' import TermsAcceptance from '../components/TermsAcceptance'
import AddFilesButton from '../components/AddFilesButton'
function PageWrapper({ children }: { children: React.ReactNode }): JSX.Element { function PageWrapper({ children }: { children: React.ReactNode }): JSX.Element {
return ( return (
@ -73,6 +75,7 @@ function ConfirmUploadState({
onCancel, onCancel,
onStart, onStart,
onRemoveFile, onRemoveFile,
onAddFiles,
}: { }: {
uploadedFiles: UploadedFile[] uploadedFiles: UploadedFile[]
password: string password: string
@ -82,13 +85,15 @@ function ConfirmUploadState({
onCancel: () => void onCancel: () => void
onStart: () => void onStart: () => void
onRemoveFile: (index: number) => void onRemoveFile: (index: number) => void
onAddFiles: (files: UploadedFile[]) => void
}): JSX.Element { }): JSX.Element {
const fileListData = useUploaderFileListData(uploadedFiles) const fileListData = useUploaderFileListData(uploadedFiles)
return ( return (
<PageWrapper> <PageWrapper>
<TitleText> <TitleText>
You are about to start uploading{' '} You are about to start uploading{' '}
{pluralize(uploadedFiles.length, 'file', 'files')}. {pluralize(uploadedFiles.length, 'file', 'files')}.{' '}
<AddFilesButton onAdd={onAddFiles} />
</TitleText> </TitleText>
<UploadFileList files={fileListData} onRemove={onRemoveFile} /> <UploadFileList files={fileListData} onRemove={onRemoveFile} />
<PasswordField value={password} onChange={onChangePassword} /> <PasswordField value={password} onChange={onChangePassword} />
@ -120,6 +125,9 @@ function UploadingState({
<TitleText> <TitleText>
You are uploading {pluralize(uploadedFiles.length, 'file', 'files')}. You are uploading {pluralize(uploadedFiles.length, 'file', 'files')}.
</TitleText> </TitleText>
<SubtitleText>
Leave this tab open. FilePizza does not store files.
</SubtitleText>
<UploadFileList files={fileListData} /> <UploadFileList files={fileListData} />
<WebRTCPeerProvider> <WebRTCPeerProvider>
<Uploader files={uploadedFiles} password={password} sharedSlug={sharedSlug} onStop={onStop} /> <Uploader files={uploadedFiles} password={password} sharedSlug={sharedSlug} onStop={onStop} />
@ -163,6 +171,10 @@ export default function UploadPage(): JSX.Element {
setUploadedFiles((fs) => fs.filter((_, i) => i !== index)) setUploadedFiles((fs) => fs.filter((_, i) => i !== index))
}, []) }, [])
const handleAddFiles = useCallback((files: UploadedFile[]) => {
setUploadedFiles((fs) => [...fs, ...files])
}, [])
if (!uploadedFiles.length) { if (!uploadedFiles.length) {
return <InitialState onDrop={handleDrop} /> return <InitialState onDrop={handleDrop} />
} }
@ -178,6 +190,7 @@ export default function UploadPage(): JSX.Element {
onCancel={handleCancel} onCancel={handleCancel}
onStart={handleStart} onStart={handleStart}
onRemoveFile={handleRemoveFile} onRemoveFile={handleRemoveFile}
onAddFiles={handleAddFiles}
/> />
) )
} }

@ -0,0 +1,45 @@
import React, { useRef, useCallback, JSX } from 'react'
import { UploadedFile } from '../types'
export default function AddFilesButton({
onAdd,
}: {
onAdd: (files: UploadedFile[]) => void
}): JSX.Element {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleClick = useCallback(() => {
fileInputRef.current?.click()
}, [])
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
onAdd(Array.from(e.target.files) as UploadedFile[])
e.target.value = ''
}
},
[onAdd],
)
return (
<>
<input
id="add-files-input"
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleChange}
/>
<button
id="add-files-button"
type="button"
onClick={handleClick}
className="underline text-stone-700 dark:text-stone-300 hover:text-stone-900 dark:hover:text-stone-100 transition-colors duration-200"
>
Add more files
</button>
</>
)
}

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

@ -16,7 +16,8 @@ export function CopyableInput({
<InputLabel>{label}</InputLabel> <InputLabel>{label}</InputLabel>
<div className="flex w-full"> <div className="flex w-full">
<input <input
className="flex-grow px-3 py-2 text-xs border border-r-0 rounded-l text-stone-900 dark:text-stone-100 bg-white dark:bg-stone-800 border-stone-300 dark:border-stone-600" id={`copyable-input-${label.toLowerCase().replace(/\s+/g, '-')}`}
className="grow px-3 py-2 text-xs border border-r-0 rounded-l text-stone-900 dark:text-stone-100 bg-white dark:bg-stone-800 border-stone-300 dark:border-stone-600"
value={value} value={value}
readOnly readOnly
/> />

@ -7,8 +7,9 @@ export default function DownloadButton({
}): JSX.Element { }): JSX.Element {
return ( return (
<button <button
id="download-button"
onClick={onClick} onClick={onClick}
className="h-12 px-4 bg-gradient-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow" className="h-12 px-4 bg-linear-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow"
> >
Download Download
</button> </button>

@ -97,6 +97,7 @@ export default function DropZone({
multiple multiple
/> />
<button <button
id="drop-zone-button"
className="block cursor-pointer relative py-3 px-6 text-base font-bold text-stone-700 dark:text-stone-200 bg-white dark:bg-stone-800 border-2 border-stone-700 dark:border-stone-700 rounded-lg transition-all duration-300 ease-in-out outline-none hover:shadow-md active:shadow-inner focus:shadow-outline" className="block cursor-pointer relative py-3 px-6 text-base font-bold text-stone-700 dark:text-stone-200 bg-white dark:bg-stone-800 border-2 border-stone-700 dark:border-stone-700 rounded-lg transition-all duration-300 ease-in-out outline-none hover:shadow-md active:shadow-inner focus:shadow-outline"
onClick={handleClick} onClick={handleClick}
> >

@ -11,20 +11,27 @@ export default function ProgressBar({
const isComplete = value === max const isComplete = value === max
return ( return (
<div className="w-full h-12 bg-stone-200 dark:bg-stone-700 rounded-md overflow-hidden relative shadow-sm"> <div
id="progress-bar"
className="w-full h-12 bg-stone-200 dark:bg-stone-700 rounded-md overflow-hidden relative shadow-sm"
>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<span className="text-black font-bold">{Math.round(percentage)}%</span> <span className="text-black font-bold">{Math.round(percentage)}%</span>
</div> </div>
<div <div
id="progress-bar-fill"
className={`h-full ${ className={`h-full ${
isComplete isComplete
? 'bg-gradient-to-b from-green-500 to-green-600' ? 'bg-linear-to-b from-green-500 to-green-600'
: 'bg-gradient-to-b from-blue-500 to-blue-600' : 'bg-linear-to-b from-blue-500 to-blue-600'
} transition-all duration-300 ease-in-out`} } transition-all duration-300 ease-in-out`}
style={{ width: `${percentage}%` }} style={{ width: `${percentage}%` }}
/> />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<span className="text-white font-bold text-shadow"> <span
id="progress-percentage"
className="text-white font-bold text-shadow"
>
{Math.round(percentage)}% {Math.round(percentage)}%
</span> </span>
</div> </div>

@ -128,7 +128,7 @@ export default function ReportTermsViolationButton({
<button <button
disabled={isReporting} disabled={isReporting}
onClick={handleReport} onClick={handleReport}
className={`px-4 py-2 bg-gradient-to-b from-red-500 to-red-600 text-white rounded-md border border-red-600 shadow-sm text-shadow disabled:opacity-50 disabled:cursor-not-allowed enabled:hover:from-red-500 enabled:hover:to-red-700 enabled:hover:shadow-md transition-all duration-200`} className={`px-4 py-2 bg-linear-to-b from-red-500 to-red-600 text-white rounded-md border border-red-600 shadow-sm text-shadow disabled:opacity-50 disabled:cursor-not-allowed enabled:hover:from-red-500 enabled:hover:to-red-700 enabled:hover:shadow-md transition-all duration-200`}
aria-label="Confirm report" aria-label="Confirm report"
> >
{isReporting ? 'Reporting...' : 'Report'} {isReporting ? 'Reporting...' : 'Report'}

@ -7,8 +7,9 @@ export default function StartButton({
}): React.ReactElement { }): React.ReactElement {
return ( return (
<button <button
id="start-button"
onClick={onClick} onClick={onClick}
className="px-4 py-2 bg-gradient-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow" className="px-4 py-2 bg-linear-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow"
> >
Start Start
</button> </button>

@ -0,0 +1,13 @@
import React, { JSX } from 'react'
export default function SubtitleText({
children,
}: {
children: React.ReactNode
}): JSX.Element {
return (
<p className="text-sm text-center text-stone-600 dark:text-stone-400 max-w-md">
{children}
</p>
)
}

@ -8,7 +8,7 @@ export default function UnlockButton({
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="px-4 py-2 bg-gradient-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow" className="px-4 py-2 bg-linear-to-b from-green-500 to-green-600 text-white rounded-md hover:from-green-500 hover:to-green-700 transition-all duration-200 border border-green-600 shadow-sm hover:shadow-md text-shadow"
> >
Unlock Unlock
</button> </button>

@ -10,11 +10,41 @@ export default function useClipboard(
const [hasCopied, setHasCopied] = useState(false) const [hasCopied, setHasCopied] = useState(false)
const onCopy = useCallback(() => { const onCopy = useCallback(() => {
navigator.clipboard.writeText(text).then(() => { if (navigator.clipboard && navigator.clipboard.writeText) {
setHasCopied(true) navigator.clipboard
}) .writeText(text)
.then(() => {
setHasCopied(true)
})
.catch((error) => {
console.error('Clipboard API error:', error)
fallbackCopyText(text)
})
} else {
fallbackCopyText(text)
}
}, [text]) }, [text])
const fallbackCopyText = (textToCopy: string) => {
const textArea = document.createElement('textarea')
textArea.value = textToCopy
textArea.style.position = 'absolute'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
setHasCopied(true)
} catch (error) {
console.error('execCommand:', error)
} finally {
textArea.remove()
}
}
useEffect(() => { useEffect(() => {
let timeoutId: NodeJS.Timeout let timeoutId: NodeJS.Timeout
if (hasCopied) { if (hasCopied) {

@ -201,16 +201,43 @@ export function useDownloader(uploaderPeerID: string): {
nextFileIndex++ nextFileIndex++
} }
let chunkCountByFile: Record<string, number> = {}
processChunk.current = (message: z.infer<typeof ChunkMessage>) => { processChunk.current = (message: z.infer<typeof ChunkMessage>) => {
const fileStream = fileStreamByPath[message.fileName] const fileStream = fileStreamByPath[message.fileName]
if (!fileStream) { if (!fileStream) {
console.error('[Downloader] no stream found for', message.fileName) console.error('[Downloader] no stream found for', message.fileName)
return return
} }
setBytesDownloaded((bd) => bd + (message.bytes as ArrayBuffer).byteLength)
// Track chunks for e2e testing
if (!chunkCountByFile[message.fileName]) {
chunkCountByFile[message.fileName] = 0
}
chunkCountByFile[message.fileName]++
console.log(
`[Downloader] received chunk ${chunkCountByFile[message.fileName]} for ${message.fileName} (${message.offset}-${message.offset + (message.bytes as ArrayBuffer).byteLength}) final=${message.final}`,
)
const chunkSize = (message.bytes as ArrayBuffer).byteLength
setBytesDownloaded((bd) => bd + chunkSize)
fileStream.enqueue(new Uint8Array(message.bytes as ArrayBuffer)) fileStream.enqueue(new Uint8Array(message.bytes as ArrayBuffer))
// Send acknowledgment to uploader
const ackMessage: Message = {
type: MessageType.ChunkAck,
fileName: message.fileName,
offset: message.offset,
bytesReceived: chunkSize,
}
dataConnection.send(ackMessage)
console.log(
`[Downloader] sent ack for chunk ${chunkCountByFile[message.fileName]} (${message.offset}, ${chunkSize} bytes)`,
)
if (message.final) { if (message.final) {
console.log('[Downloader] finished receiving', message.fileName) console.log(
`[Downloader] finished receiving ${message.fileName} after ${chunkCountByFile[message.fileName]} chunks`,
)
fileStream.close() fileStream.close()
startNextFileOrFinish() startNextFileOrFinish()
} }

@ -5,12 +5,22 @@ import {
UploaderConnection, UploaderConnection,
UploaderConnectionStatus, UploaderConnectionStatus,
} from '../types' } from '../types'
import { decodeMessage, Message, MessageType } from '../messages' import {
decodeMessage,
Message,
MessageType,
ChunkAckMessage,
} from '../messages'
import { z } from 'zod'
import { getFileName } from '../fs' import { getFileName } from '../fs'
import { setRotating } from './useRotatingSpinner' import { setRotating } from './useRotatingSpinner'
// TODO(@kern): Test for better values // TODO(@kern): Test for better values
const MAX_CHUNK_SIZE = 256 * 1024 // 256 KB export const MAX_CHUNK_SIZE = 256 * 1024 // 256 KB
export function isFinalChunk(offset: number, fileSize: number): boolean {
return offset + MAX_CHUNK_SIZE >= fileSize
}
function validateOffset( function validateOffset(
files: UploadedFile[], files: UploadedFile[],
@ -68,6 +78,7 @@ export function useUploaderConnections(
completedFiles: 0, completedFiles: 0,
totalFiles: files.length, totalFiles: files.length,
currentFileProgress: 0, currentFileProgress: 0,
acknowledgedBytes: 0,
} }
setConnections((conns) => { setConnections((conns) => {
@ -224,11 +235,16 @@ export function useUploaderConnections(
) )
const file = validateOffset(files, fileName, offset) const file = validateOffset(files, fileName, offset)
let chunkCount = 0
const sendNextChunkAsync = () => { const sendNextChunkAsync = () => {
sendChunkTimeout = setTimeout(() => { sendChunkTimeout = setTimeout(() => {
const end = Math.min(file.size, offset + MAX_CHUNK_SIZE) const end = Math.min(file.size, offset + MAX_CHUNK_SIZE)
const chunkSize = end - offset const final = isFinalChunk(offset, file.size)
const final = chunkSize < MAX_CHUNK_SIZE chunkCount++
// Log for e2e testing
console.log(
`[UploaderConnections] sending chunk ${chunkCount} for ${fileName} (${offset}-${end}/${file.size}) final=${final}`,
)
const request: Message = { const request: Message = {
type: MessageType.Chunk, type: MessageType.Chunk,
fileName, fileName,
@ -282,7 +298,8 @@ export function useUploaderConnections(
status: UploaderConnectionStatus.Uploading, status: UploaderConnectionStatus.Uploading,
uploadingFileName: fileName, uploadingFileName: fileName,
uploadingOffset: offset, uploadingOffset: offset,
currentFileProgress: offset / file.size, acknowledgedBytes: 0, // Reset acknowledged bytes for new file
currentFileProgress: 0, // Progress based on acks, not sends
} }
}) })
@ -309,6 +326,42 @@ export function useUploaderConnections(
break break
} }
case MessageType.ChunkAck: {
const ackMessage = message as z.infer<typeof ChunkAckMessage>
console.log(
'[UploaderConnections] received chunk ack:',
ackMessage.fileName,
'offset',
ackMessage.offset,
'bytes',
ackMessage.bytesReceived,
)
updateConnection((draft) => {
const currentAcked = draft.acknowledgedBytes || 0
const newAcked = currentAcked + ackMessage.bytesReceived
// Find the file to calculate progress
const file = files.find(
(f) => getFileName(f) === ackMessage.fileName,
)
if (file) {
const acknowledgedProgress = newAcked / file.size
return {
...draft,
acknowledgedBytes: newAcked,
currentFileProgress: acknowledgedProgress,
}
}
return {
...draft,
acknowledgedBytes: newAcked,
}
})
break
}
case MessageType.Done: { case MessageType.Done: {
console.log( console.log(
'[UploaderConnections] transfer completed successfully', '[UploaderConnections] transfer completed successfully',

@ -5,6 +5,7 @@ export enum MessageType {
Info = 'Info', Info = 'Info',
Start = 'Start', Start = 'Start',
Chunk = 'Chunk', Chunk = 'Chunk',
ChunkAck = 'ChunkAck',
Pause = 'Pause', Pause = 'Pause',
Done = 'Done', Done = 'Done',
Error = 'Error', Error = 'Error',
@ -48,6 +49,13 @@ export const ChunkMessage = z.object({
final: z.boolean(), final: z.boolean(),
}) })
export const ChunkAckMessage = z.object({
type: z.literal(MessageType.ChunkAck),
fileName: z.string(),
offset: z.number(),
bytesReceived: z.number(),
})
export const DoneMessage = z.object({ export const DoneMessage = z.object({
type: z.literal(MessageType.Done), type: z.literal(MessageType.Done),
}) })
@ -80,6 +88,7 @@ export const Message = z.discriminatedUnion('type', [
InfoMessage, InfoMessage,
StartMessage, StartMessage,
ChunkMessage, ChunkMessage,
ChunkAckMessage,
DoneMessage, DoneMessage,
ErrorMessage, ErrorMessage,
PasswordRequiredMessage, PasswordRequiredMessage,

@ -1,6 +1,10 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities; @custom-variant dark (&:where(.dark, .dark *));
@theme {
--animate-spin-slow: spin 16s linear infinite;
}
html { html {
height: 100dvh; height: 100dvh;

@ -24,6 +24,7 @@ export type UploaderConnection = {
mobileModel?: string mobileModel?: string
uploadingFileName?: string uploadingFileName?: string
uploadingOffset?: number uploadingOffset?: number
acknowledgedBytes?: number
completedFiles: number completedFiles: number
totalFiles: number totalFiles: number
currentFileProgress: number currentFileProgress: number

@ -1,13 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
animation: {
'spin-slow': 'spin 16s linear infinite',
},
},
},
plugins: [],
darkMode: 'class',
}

@ -0,0 +1,17 @@
/// <reference types="@playwright/test" />
import { test, expect } from '@playwright/test'
import { createTestFile, uploadFile, addFile } from './helpers'
test('user can add more files before starting upload', async ({ page }) => {
const file1 = createTestFile('first.txt', 'A')
const file2 = createTestFile('second.txt', 'B')
await uploadFile(page, file1)
// Add another file using the add files button
await addFile(page, file2)
// Both files should be listed
await expect(page.getByText(file1.name)).toBeVisible()
await expect(page.getByText(file2.name)).toBeVisible()
})

@ -0,0 +1,9 @@
/// <reference types="@playwright/test" />
import { test, expect } from '@playwright/test'
test('home page loads', async ({ page }) => {
await page.goto('http://localhost:3000/')
await expect(
page.getByText('Peer-to-peer file transfers in your browser.'),
).toBeVisible()
})

@ -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,320 @@
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 addFile(
page: Page,
testFile: TestFile,
): Promise<void> {
await page.evaluate(
({ testContent, testFileName }) => {
const input = document.querySelector(
'#add-files-input',
) 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
const event = new Event('change', { bubbles: true })
input.dispatchEvent(event)
}
},
{ testContent: testFile.content, testFileName: testFile.name },
)
await expect(page.getByText(testFile.name)).toBeVisible({ timeout: 5000 })
}
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)
}
}

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import CancelButton from '../../src/components/CancelButton'
describe('CancelButton', () => {
it('calls onClick when clicked', () => {
const onClick = vi.fn()
const { getByText } = render(<CancelButton onClick={onClick} />)
fireEvent.click(getByText('Cancel'))
expect(onClick).toHaveBeenCalled()
})
})

@ -0,0 +1,25 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ConnectionListItem } from '../../src/components/ConnectionListItem'
import { UploaderConnectionStatus } from '../../src/types'
const baseConn = {
status: UploaderConnectionStatus.Uploading,
dataConnection: {} as any,
completedFiles: 1,
totalFiles: 2,
currentFileProgress: 0.5,
browserName: 'Chrome',
browserVersion: '120',
}
describe('ConnectionListItem', () => {
it('shows status and progress', () => {
const { getByText } = render(<ConnectionListItem conn={baseConn} />)
expect(getByText((c, e) => e?.textContent === 'Chrome v120')).toBeInTheDocument()
expect(getByText('UPLOADING')).toBeInTheDocument()
expect(getByText('Completed: 1 / 2 files')).toBeInTheDocument()
})
})

@ -0,0 +1,22 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { act } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { CopyableInput } from '../../src/components/CopyableInput'
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
})
describe('CopyableInput', () => {
it('copies text when button clicked', async () => {
const { getByText } = render(<CopyableInput label="URL" value="hello" />)
await act(async () => {
fireEvent.click(getByText('Copy'))
})
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello')
})
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import DownloadButton from '../../src/components/DownloadButton'
describe('DownloadButton', () => {
it('calls onClick', () => {
const fn = vi.fn()
const { getByText } = render(<DownloadButton onClick={fn} />)
fireEvent.click(getByText('Download'))
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,60 @@
/// <reference types="@testing-library/jest-dom" />
import { vi } from 'vitest'
vi.mock('next-view-transitions', () => ({ Link: (p: any) => <a {...p}>{p.children}</a> }))
import React from 'react'
import { render, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import {
ConnectingToUploader,
DownloadComplete,
DownloadInProgress,
ReadyToDownload,
PasswordEntry,
} from '../../src/components/Downloader'
const files = [{ fileName: 'a.txt', size: 1, type: 'text/plain' }]
describe('Downloader subcomponents', () => {
it('ConnectingToUploader shows troubleshooting', async () => {
const { getByText } = render(
<ConnectingToUploader showTroubleshootingAfter={0} />,
)
await waitFor(() => {
expect(getByText('Having trouble connecting?')).toBeInTheDocument()
})
})
it('DownloadComplete lists files', () => {
const { getByText } = render(
<DownloadComplete filesInfo={files} bytesDownloaded={1} totalSize={1} />,
)
expect(getByText('You downloaded 1 file.')).toBeInTheDocument()
})
it('DownloadInProgress shows stop button', () => {
const { getByText } = render(
<DownloadInProgress filesInfo={files} bytesDownloaded={0} totalSize={1} onStop={() => {}} />,
)
expect(getByText('Stop Download')).toBeInTheDocument()
})
it('ReadyToDownload shows start button', () => {
const { getByText } = render(
<ReadyToDownload filesInfo={files} onStart={() => {}} />,
)
expect(getByText('Download')).toBeInTheDocument()
})
it('PasswordEntry submits value', () => {
let submitted = ''
const { getByPlaceholderText, getByText } = render(
<PasswordEntry errorMessage={null} onSubmit={(v) => (submitted = v)} />,
)
fireEvent.change(
getByPlaceholderText('Enter a secret password for this slice of FilePizza...'),
{ target: { value: 'secret' } },
)
fireEvent.submit(getByText('Unlock'))
expect(submitted).toBe('secret')
})
})

@ -0,0 +1,19 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import DropZone from '../../src/components/DropZone'
function createFile(name: string) {
return new File(['hello'], name, { type: 'text/plain' })
}
describe('DropZone', () => {
it('calls onDrop when file selected', () => {
const fn = vi.fn()
const { container } = render(<DropZone onDrop={fn} />)
const input = container.querySelector('input') as HTMLInputElement
fireEvent.change(input, { target: { files: [createFile('a.txt')] } })
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ErrorMessage } from '../../src/components/ErrorMessage'
describe('ErrorMessage', () => {
it('renders message', () => {
const { getByText } = render(<ErrorMessage message="oops" />)
expect(getByText('oops')).toBeInTheDocument()
})
})

@ -0,0 +1,18 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Footer from '../../src/components/Footer'
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
})
describe('Footer', () => {
it('redirects to donate link', () => {
const { getByText } = render(<Footer />)
fireEvent.click(getByText('Donate'))
expect(window.location.href).toContain('coinbase')
})
})

@ -0,0 +1,16 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import InputLabel from '../../src/components/InputLabel'
describe('InputLabel', () => {
it('shows tooltip on hover', () => {
const { getByRole, getByText } = render(
<InputLabel tooltip="tip">Label</InputLabel>,
)
const button = getByRole('button')
fireEvent.mouseOver(button)
expect(getByText('tip')).toBeInTheDocument()
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Loading from '../../src/components/Loading'
describe('Loading', () => {
it('renders text', () => {
const { getByText } = render(<Loading text="wait" />)
expect(getByText('wait')).toBeInTheDocument()
})
})

@ -0,0 +1,16 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { ModeToggle } from '../../src/components/ModeToggle'
const setTheme = vi.fn()
vi.mock('next-themes', () => ({ useTheme: () => ({ setTheme, resolvedTheme: 'light' }) }))
describe('ModeToggle', () => {
it('toggles theme', () => {
const { getByRole } = render(<ModeToggle />)
fireEvent.click(getByRole('button'))
expect(setTheme).toHaveBeenCalledWith('dark')
})
})

@ -0,0 +1,18 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import PasswordField from '../../src/components/PasswordField'
describe('PasswordField', () => {
it('calls onChange', () => {
let val = ''
const { getByPlaceholderText } = render(
<PasswordField value="" onChange={(v) => (val = v)} />,
)
fireEvent.change(getByPlaceholderText('Enter a secret password for this slice of FilePizza...'), {
target: { value: 'a' },
})
expect(val).toBe('a')
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import ProgressBar from '../../src/components/ProgressBar'
describe('ProgressBar', () => {
it('shows percentage', () => {
const { getAllByText } = render(<ProgressBar value={50} max={100} />)
expect(getAllByText('50%').length).toBeGreaterThan(0)
})
})

@ -0,0 +1,16 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import FilePizzaQueryClientProvider from '../../src/components/QueryClientProvider'
describe('QueryClientProvider', () => {
it('renders children', () => {
const { getByText } = render(
<FilePizzaQueryClientProvider>
<span>child</span>
</FilePizzaQueryClientProvider>,
)
expect(getByText('child')).toBeInTheDocument()
})
})

@ -0,0 +1,23 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import FilePizzaQueryClientProvider from '../../src/components/QueryClientProvider'
vi.mock('../../src/components/WebRTCProvider', () => ({
useWebRTCPeer: () => ({ peer: { connect: vi.fn(() => ({ on: vi.fn(), close: vi.fn() })) } }),
}))
import ReportTermsViolationButton from '../../src/components/ReportTermsViolationButton'
describe('ReportTermsViolationButton', () => {
it('opens modal on click', () => {
const { getByText } = render(
<FilePizzaQueryClientProvider>
<ReportTermsViolationButton uploaderPeerID="peer" slug="slug" />
</FilePizzaQueryClientProvider>,
)
fireEvent.click(getByText('Report suspicious pizza delivery'))
expect(getByText('Found a suspicious delivery?')).toBeInTheDocument()
})
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
vi.mock("next-view-transitions", () => ({ Link: (p: any) => <a {...p}>{p.children}</a> }))
import { vi } from "vitest"
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import ReturnHome from '../../src/components/ReturnHome'
describe('ReturnHome', () => {
it('links to home', () => {
const { getByText } = render(<ReturnHome />)
expect(getByText(/Serve up/).getAttribute('href')).toBe('/')
})
})

@ -0,0 +1,19 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { act } from 'react'
import { describe, it, expect } from 'vitest'
import Spinner from '../../src/components/Spinner'
import { setRotating } from '../../src/hooks/useRotatingSpinner'
describe('Spinner', () => {
it('reflects rotating state', () => {
// @ts-ignore
act(() => { setRotating(true) })
// @ts-ignore
const { getByLabelText } = render(<Spinner />)
expect(getByLabelText('Rotating pizza')).toBeInTheDocument()
// @ts-ignore
act(() => { setRotating(false) })
})
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import StartButton from '../../src/components/StartButton'
describe('StartButton', () => {
it('calls handler', () => {
const fn = vi.fn()
const { getByText } = render(<StartButton onClick={fn} />)
fireEvent.click(getByText('Start'))
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,19 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import StopButton from '../../src/components/StopButton'
describe('StopButton', () => {
it('labels correctly when downloading', () => {
const { getByText } = render(<StopButton onClick={() => {}} isDownloading />)
expect(getByText('Stop Download')).toBeInTheDocument()
})
it('calls handler', () => {
const fn = vi.fn()
const { getByText } = render(<StopButton onClick={fn} />)
fireEvent.click(getByText('Stop Upload'))
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,13 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import TermsAcceptance from '../../src/components/TermsAcceptance'
describe('TermsAcceptance', () => {
it('opens modal', () => {
const { getByText } = render(<TermsAcceptance />)
fireEvent.click(getByText('our terms'))
expect(getByText('FilePizza Terms')).toBeInTheDocument()
})
})

@ -0,0 +1,17 @@
/// <reference types="@testing-library/jest-dom" />
Object.defineProperty(window, "matchMedia", { value: () => ({ matches: false, addListener: () => {}, removeListener: () => {} }) })
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ThemeProvider } from '../../src/components/ThemeProvider'
describe('ThemeProvider', () => {
it('renders children', () => {
const { getByText } = render(
<ThemeProvider>
<span>child</span>
</ThemeProvider>,
)
expect(getByText('child')).toBeInTheDocument()
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import TitleText from '../../src/components/TitleText'
describe('TitleText', () => {
it('renders children', () => {
const { getByText } = render(<TitleText>hello</TitleText>)
expect(getByText('hello')).toBeInTheDocument()
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import TypeBadge from '../../src/components/TypeBadge'
describe('TypeBadge', () => {
it('renders type', () => {
const { getByText } = render(<TypeBadge type="image/png" />)
expect(getByText('image/png')).toBeInTheDocument()
})
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import UnlockButton from '../../src/components/UnlockButton'
describe('UnlockButton', () => {
it('calls onClick', () => {
const fn = vi.fn()
const { getByText } = render(<UnlockButton onClick={fn} />)
fireEvent.click(getByText('Unlock'))
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,15 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import UploadFileList from '../../src/components/UploadFileList'
describe('UploadFileList', () => {
it('calls onRemove', () => {
const fn = vi.fn()
const files = [{ fileName: 'a.txt', type: 'text/plain' }]
const { getByText } = render(<UploadFileList files={files} onRemove={fn} />)
fireEvent.click(getByText('✕'))
expect(fn).toHaveBeenCalledWith(0)
})
})

@ -0,0 +1,35 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
var mockUseUploaderChannel: any
vi.mock('../../src/components/WebRTCProvider', () => ({
useWebRTCPeer: () => ({ peer: { id: '1' }, stop: vi.fn() }),
}))
vi.mock('../../src/hooks/useUploaderChannel', () => ({
useUploaderChannel: (...args: any[]) => mockUseUploaderChannel(...args),
}))
vi.mock('../../src/hooks/useUploaderConnections', () => ({ useUploaderConnections: () => [] }))
vi.mock('react-qr-code', () => ({ default: () => <div>QR</div> }))
vi.mock('../../src/components/CopyableInput', () => ({ CopyableInput: () => <div>Input</div> }))
vi.mock('../../src/components/ConnectionListItem', () => ({ ConnectionListItem: () => <div>Item</div> }))
vi.mock('../../src/components/StopButton', () => ({ default: () => <button>Stop</button> }))
import Uploader from '../../src/components/Uploader'
describe('Uploader', () => {
it('shows loading when channel loading', () => {
mockUseUploaderChannel = vi.fn().mockReturnValueOnce({
isLoading: true,
error: null,
longSlug: undefined,
shortSlug: undefined,
longURL: undefined,
shortURL: undefined,
})
const { getByText } = render(<Uploader files={[]} password="" onStop={() => {}} />)
expect(getByText('Creating channel...')).toBeInTheDocument()
})
})

@ -0,0 +1,24 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ iceServers: [] })))) )
vi.mock('peerjs', () => ({
default: class { id = 'peer1'; on(event: string, cb: (id: string) => void) { if (event === 'open') cb('peer1') } off() {} },
}))
import WebRTCProvider from '../../src/components/WebRTCProvider'
const Child = () => <div>child</div>
describe('WebRTCProvider', () => {
it('renders children after init', async () => {
const { getByText } = render(
<WebRTCProvider>
<Child />
</WebRTCProvider>,
)
await waitFor(() => expect(getByText('child')).toBeInTheDocument())
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Wordmark from '../../src/components/Wordmark'
describe('Wordmark', () => {
it('renders svg', () => {
const { getByLabelText } = render(<Wordmark />)
expect(getByLabelText('FilePizza logo')).toBeInTheDocument()
})
})

@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { isFinalChunk, MAX_CHUNK_SIZE } from '../../src/hooks/useUploaderConnections'
describe('isFinalChunk', () => {
it('marks last chunk when file size is exact multiple of chunk size', () => {
const fileSize = MAX_CHUNK_SIZE * 2
// when offset points to start of last chunk
expect(isFinalChunk(MAX_CHUNK_SIZE, fileSize)).toBe(true)
})
it('returns false for middle chunks', () => {
const fileSize = MAX_CHUNK_SIZE * 3 + 123
expect(isFinalChunk(MAX_CHUNK_SIZE, fileSize)).toBe(false)
})
})

@ -0,0 +1,21 @@
import { describe, it, expect, vi } from 'vitest'
import {
setRotating,
addRotationListener,
removeRotationListener,
getRotating,
} from '../../src/hooks/useRotatingSpinner'
describe('useRotatingSpinner state helpers', () => {
it('notifies listeners on state change', () => {
const listener = vi.fn()
addRotationListener(listener)
setRotating(true)
expect(listener).toHaveBeenCalledWith(true)
expect(getRotating()).toBe(true)
setRotating(false)
expect(listener).toHaveBeenCalledWith(false)
expect(getRotating()).toBe(false)
removeRotationListener(listener)
})
})

@ -24,7 +24,11 @@
"name": "next" "name": "next"
} }
], ],
"strictNullChecks": true "strictNullChecks": true,
"types": [
"vitest/globals",
"@testing-library/jest-dom"
]
}, },
"include": [ "include": [
"tailwind.config.js", "tailwind.config.js",
@ -32,6 +36,8 @@
"src/**/*.js", "src/**/*.js",
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",
"tests/**/*.ts",
"tests/**/*.tsx",
".next/types/**/*.ts" ".next/types/**/*.ts"
], ],
"exclude": [ "exclude": [

@ -0,0 +1,16 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './vitest.setup.ts',
exclude: ['tests/e2e/**', '**/node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
})

@ -0,0 +1 @@
import '@testing-library/jest-dom'
Loading…
Cancel
Save