mirror of https://github.com/kern/filepizza
Merge branch 'main' of github.com:kern/filepizza
# Conflicts: # package.json # pnpm-lock.yaml # src/app/page.tsxpull/313/head
commit
91f4ee5149
@ -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,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: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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…
Reference in New Issue