import assert from 'node:assert/strict'; import { chromium } from '@playwright/test'; import { mkdtemp, open, rm, stat, readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { createApp } from '../../apps/server/src/app.ts'; import { loadConfig } from '../../apps/server/src/config.ts'; const folder = await mkdtemp(resolve(tmpdir(), 'hop-chunks-')); const runtime = await createApp({ ...loadConfig({ APP_URL: 'http://127.0.0.1', DATABASE_PATH: resolve(folder, 'db.sqlite'), UPLOAD_DIR: resolve(folder, 'uploads'), MIN_FREE_DISK_MB: '0', SESSION_SECRET: 's'.repeat(40), DISCOVERY_SECRET: 'd'.repeat(40), }), webRoot: resolve('apps/web/dist'), }); let browser; try { await runtime.app.listen({ host: '127.0.0.1', port: 0 }); const url = `http://127.0.0.1:${runtime.app.server.address().port}`; runtime.setDevOrigin(url); browser = await chromium.launch({ headless: true, ...(process.env.HOP_BROWSER_EXECUTABLE ? { executablePath: process.env.HOP_BROWSER_EXECUTABLE } : {}), }); const a = await browser.newContext(), b = await browser.newContext(); const sender = await a.newPage(), receiver = await b.newPage(), errors = []; for (const page of [sender, receiver]) page.on('pageerror', (error) => errors.push(error.message)); await Promise.all([sender.goto(url), receiver.goto(url)]); await sender.locator('.peer-card').first().waitFor(); await receiver.locator('.peer-card').first().waitFor(); const size = 101 * 1024 * 1024 + 23, source = resolve(folder, 'over-100MiB.bin'); const file = await open(source, 'w'); await file.truncate(size); await file.write(Buffer.from('actual large transfer'), 0, 21, size - 21); await file.close(); let lostReply = false; let failedAttempts = 0, allowRetry = false; const sizes = [], offsets = []; await sender.route('**/api/uploads/resumable/*', async (route) => { if (route.request().method() !== 'PUT') return route.continue(); const bytes = route.request().postDataBuffer(); if (lostReply && !allowRetry && failedAttempts < 3) { failedAttempts++; return route.abort('failed'); } sizes.push(bytes.length); offsets.push(Number(route.request().headers()['upload-offset'])); // Emulate a proxy with a per-request cap, and lose an acknowledged chunk response. if (bytes.length > 9 * 1024 * 1024) return route.fulfill({ status: 413, body: '{}' }); if (!lostReply) { await route.fetch(); lostReply = true; return route.abort('failed'); } return route.continue(); }); await sender.locator('.peer-card').first().click(); await sender.locator('input[type=file]').setInputFiles(source); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); const downloaded = receiver.waitForEvent('download', { timeout: 120000 }); void downloaded.catch(() => {}); await receiver.getByRole('button', { name: '수락', exact: true }).click(); const retry = sender.getByRole('button', { name: '다시 전송', exact: true }); await retry.waitFor({ timeout: 30000 }); assert.equal(failedAttempts, 3); allowRetry = true; await retry.click(); const download = await downloaded, path = await download.path(); assert.equal((await stat(path)).size, size); const handle = await open(path, 'r'), tail = Buffer.alloc(21); await handle.read(tail, 0, 21, size - 21); await handle.close(); assert.equal(tail.toString(), 'actual large transfer'); assert.equal(sizes.length, 13); assert(sizes.every((value) => value <= 8 * 1024 * 1024)); assert.equal(offsets[0], 0); assert.equal(offsets[1], 8 * 1024 * 1024, '응답이 유실돼도 이미 받은 조각을 다시 전송하지 않음'); assert.equal( sizes.reduce((sum, value) => sum + value, 0), size, ); await sender.locator('.send-dialog').waitFor({ state: 'hidden' }); assert.equal(errors.length, 0, errors.join('\n')); console.log( 'PASS: 두 실제 브라우저 자동 발견, 101MiB 파일의 8MiB 분할, 요청 크기 제한, 응답 유실 후 이어 보내기, 수락 후 자동 다운로드, 원본 크기·내용 검증', ); } finally { await browser?.close(); await runtime.app.close(); await rm(folder, { recursive: true, force: true }); }