Add e2e file transfer test with playwright (#289)

* Add e2e test for file transfers

* fix

* fmt
pull/253/merge
Alex Kern 5 months ago committed by GitHub
parent ec70693b54
commit 106ebb972a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -8,7 +8,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]'",

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

@ -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,6 +16,7 @@ export function CopyableInput({
<InputLabel>{label}</InputLabel> <InputLabel>{label}</InputLabel>
<div className="flex w-full"> <div className="flex w-full">
<input <input
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" 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,6 +7,7 @@ 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-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" 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"
> >

@ -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,11 +11,15 @@ 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-linear-to-b from-green-500 to-green-600' ? 'bg-linear-to-b from-green-500 to-green-600'
@ -24,7 +28,10 @@ export default function ProgressBar({
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>

@ -7,6 +7,7 @@ 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-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" 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"
> >

@ -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,7 +5,13 @@ 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'
@ -72,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) => {
@ -228,10 +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 final = isFinalChunk(offset, file.size) const final = isFinalChunk(offset, file.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,
@ -285,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
} }
}) })
@ -312,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,

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

@ -0,0 +1,93 @@
/// <reference types="@playwright/test" />
import { test, expect } from '@playwright/test'
import {
createTestFile,
uploadFile,
startUpload,
downloadFile,
verifyFileIntegrity,
verifyTransferCompletion,
createBrowserContexts,
monitorChunkProgress,
verifyPreciseProgress,
} from './helpers'
interface TestCase {
name: string
fileSizeMultiplier: number
expectedChunks: number
fillChar: string
}
const CHUNK_SIZE = 256 * 1024 // 256 KB
const testCases: TestCase[] = [
{
name: 'tiny file (basic transfer)',
fileSizeMultiplier: 0.1, // ~26KB
expectedChunks: 1,
fillChar: 'T',
},
{
name: 'small file (single chunk)',
fileSizeMultiplier: 0.5, // 128KB
expectedChunks: 1,
fillChar: 'S',
},
{
name: 'medium file (3 chunks)',
fileSizeMultiplier: 2.5, // 640KB
expectedChunks: 3,
fillChar: 'M',
},
{
name: 'large file (4 chunks)',
fileSizeMultiplier: 4, // 1024KB
expectedChunks: 4,
fillChar: 'L',
},
{
name: 'extra large file (7 chunks)',
fileSizeMultiplier: 6.5, // ~1664KB
expectedChunks: 7,
fillChar: 'X',
},
]
for (const testCase of testCases) {
test(`file transfer: ${testCase.name}`, async ({ browser }) => {
const fileSize = Math.floor(CHUNK_SIZE * testCase.fileSizeMultiplier)
const testFile = createTestFile(
`test-${testCase.fillChar.toLowerCase()}-${testCase.expectedChunks}chunks.txt`,
testCase.fillChar.repeat(fileSize)
)
const { uploaderPage, downloaderPage, cleanup } = await createBrowserContexts(browser)
try {
// Set up precise chunk and progress monitoring
const monitor = await monitorChunkProgress(uploaderPage, downloaderPage, fileSize)
await uploadFile(uploaderPage, testFile)
const shareUrl = await startUpload(uploaderPage)
const downloadPath = await downloadFile(downloaderPage, shareUrl, testFile)
await verifyFileIntegrity(downloadPath, testFile)
await verifyTransferCompletion(downloaderPage)
// Wait for all async progress captures to complete
await downloaderPage.waitForTimeout(1000)
// Verify precise progress tracking for both upload and download
verifyPreciseProgress(monitor.uploadChunks, testCase.expectedChunks, 'upload')
verifyPreciseProgress(monitor.downloadChunks, testCase.expectedChunks, 'download')
// Verify final completion shows exactly 100% on both sides
await expect(uploaderPage.locator('#progress-percentage')).toHaveText('100%')
await expect(downloaderPage.locator('#progress-percentage')).toHaveText('100%')
} finally {
await cleanup()
}
})
}

@ -0,0 +1,293 @@
import { Page, Browser, expect } from '@playwright/test'
import { createHash } from 'crypto'
import { writeFileSync, readFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
export interface TestFile {
name: string
content: string
path: string
checksum: string
}
export function createTestFile(fileName: string, content: string): TestFile {
const testFilePath = join(tmpdir(), fileName)
writeFileSync(testFilePath, content)
const checksum = createHash('sha256').update(content).digest('hex')
return {
name: fileName,
content,
path: testFilePath,
checksum,
}
}
export async function uploadFile(
page: Page,
testFile: TestFile,
): Promise<void> {
// Navigate to home page
await page.goto('http://localhost:3000/')
await expect(
page.getByText('Peer-to-peer file transfers in your browser.'),
).toBeVisible()
// Wait for drop zone button to be ready
await expect(page.locator('#drop-zone-button')).toBeVisible()
// Upload file using the file input and trigger change event
await page.evaluate(
({ testContent, testFileName }) => {
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement
if (input) {
const file = new File([testContent], testFileName, {
type: 'text/plain',
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
input.files = dataTransfer.files
// Manually trigger the change event
const event = new Event('change', { bubbles: true })
input.dispatchEvent(event)
}
},
{ testContent: testFile.content, testFileName: testFile.name },
)
// Wait for file to be processed and confirm upload page to appear
await expect(page.getByText(/You are about to start uploading/i)).toBeVisible(
{ timeout: 10000 },
)
}
export async function startUpload(page: Page): Promise<string> {
// Start sharing
await page.locator('#start-button').click()
// Wait for uploading state and get the share URL
await expect(page.getByText(/You are uploading/i)).toBeVisible({
timeout: 10000,
})
// Get the share URL from the copyable input (Long URL)
const shareUrlInput = page.locator('#copyable-input-long-url')
await expect(shareUrlInput).toBeVisible({ timeout: 5000 })
const shareUrl = await shareUrlInput.inputValue()
expect(shareUrl).toMatch(/http:\/\/localhost:3000\//)
return shareUrl
}
export async function downloadFile(
page: Page,
shareUrl: string,
testFile: TestFile,
): Promise<string> {
// Navigate to share URL
await page.goto(shareUrl)
// Wait for download page to load
await expect(page.getByText(testFile.name)).toBeVisible({
timeout: 10000,
})
// Start download
const downloadPromise = page.waitForEvent('download')
await page.locator('#download-button').click()
const download = await downloadPromise
// Verify download
expect(download.suggestedFilename()).toBe(testFile.name)
// Save downloaded file
const downloadPath = join(tmpdir(), `downloaded-${testFile.name}`)
await download.saveAs(downloadPath)
return downloadPath
}
export async function verifyFileIntegrity(
downloadPath: string,
testFile: TestFile,
): Promise<void> {
// Verify downloaded content and checksum
const downloadedContent = readFileSync(downloadPath, 'utf8')
expect(downloadedContent).toBe(testFile.content)
const downloadedChecksum = createHash('sha256')
.update(downloadedContent)
.digest('hex')
expect(downloadedChecksum).toBe(testFile.checksum)
}
export async function verifyTransferCompletion(
downloaderPage: Page,
): Promise<void> {
// Verify download completion on downloader side
await expect(downloaderPage.getByText(/You downloaded/i)).toBeVisible({
timeout: 10000,
})
}
export async function createBrowserContexts(browser: Browser): Promise<{
uploaderPage: Page
downloaderPage: Page
cleanup: () => Promise<void>
}> {
const uploaderContext = await browser.newContext()
const downloaderContext = await browser.newContext()
const uploaderPage = await uploaderContext.newPage()
const downloaderPage = await downloaderContext.newPage()
const cleanup = async () => {
await uploaderContext.close()
await downloaderContext.close()
}
return { uploaderPage, downloaderPage, cleanup }
}
export interface ProgressMonitor {
uploaderProgress: number
downloaderProgress: number
maxProgress: number
}
export interface ChunkProgressLog {
chunkNumber: number
fileName: string
offset: number
end: number
fileSize: number
final: boolean
progressPercentage: number
side: 'upload' | 'download'
}
export interface PreciseChunkMonitor {
uploadChunks: ChunkProgressLog[]
downloadChunks: ChunkProgressLog[]
}
export async function monitorChunkProgress(
uploaderPage: Page,
downloaderPage: Page,
expectedFileSize: number,
): Promise<PreciseChunkMonitor> {
const uploadChunks: ChunkProgressLog[] = []
const downloadChunks: ChunkProgressLog[] = []
uploaderPage.on('console', async (msg) => {
const text = msg.text()
if (text.includes('[UploaderConnections] received chunk ack')) {
// Parse ack log: "[UploaderConnections] received chunk ack: file.txt offset 0 bytes 262144"
const ackMatch = text.match(
/received chunk ack: (\S+) offset (\d+) bytes (\d+)/,
)
if (ackMatch) {
const [, fileName, offset, bytes] = ackMatch
// Calculate which chunk this corresponds to and expected progress
const chunkNumber = Math.floor(parseInt(offset) / (256 * 1024)) + 1
const chunkEnd = parseInt(offset) + parseInt(bytes)
const final = chunkEnd >= expectedFileSize
const progressPercentage = Math.round(
(chunkEnd / expectedFileSize) * 100,
)
uploadChunks.push({
chunkNumber,
fileName,
offset: parseInt(offset),
end: chunkEnd,
fileSize: expectedFileSize,
final,
progressPercentage,
side: 'upload',
})
}
}
})
downloaderPage.on('console', async (msg) => {
const text = msg.text()
if (
text.includes('[Downloader] received chunk') &&
!text.includes('finished receiving')
) {
// Parse log: "[Downloader] received chunk 1 for file.txt (0-262144) final=false"
const chunkMatch = text.match(
/received chunk (\d+) for (\S+) \((\d+)-(\d+)\) final=(\w+)/,
)
if (chunkMatch) {
const [, chunkNum, fileName, offset, end, final] = chunkMatch
// Calculate expected progress based on chunk data
const chunkEnd = parseInt(end)
const progressPercentage = Math.round(
(chunkEnd / expectedFileSize) * 100,
)
downloadChunks.push({
chunkNumber: parseInt(chunkNum),
fileName,
offset: parseInt(offset),
end: chunkEnd,
fileSize: expectedFileSize,
final: final === 'true',
progressPercentage,
side: 'download',
})
}
}
})
return {
uploadChunks,
downloadChunks,
}
}
export function verifyPreciseProgress(
chunks: ChunkProgressLog[],
expectedChunks: number,
side: 'upload' | 'download',
): void {
expect(chunks.length).toBe(expectedChunks)
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]
console.log(
`${side} chunk ${chunk.chunkNumber}: ${chunk.offset}-${chunk.end}/${chunk.fileSize} ` +
`progress=${chunk.progressPercentage}% final=${chunk.final}`,
)
// Verify chunks are received in order
expect(chunk.chunkNumber).toBe(i + 1)
// Verify progress is monotonically increasing
if (i > 0) {
expect(chunk.progressPercentage).toBeGreaterThanOrEqual(
chunks[i - 1].progressPercentage,
)
}
// For the final chunk, ensure we reach exactly 100%
if (chunk.final) {
expect(chunk.progressPercentage).toBe(100)
}
// Verify progress percentage is reasonable (0-100%)
expect(chunk.progressPercentage).toBeGreaterThanOrEqual(0)
expect(chunk.progressPercentage).toBeLessThanOrEqual(100)
}
}
Loading…
Cancel
Save