Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults

This commit is contained in:
2026-10-12 15:54:26 +09:00
parent 439c2ae61b
commit 464715aa69
43 changed files with 1647 additions and 137 deletions

106
tests/e2e/chunks.mjs Normal file
View File

@@ -0,0 +1,106 @@
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 });
}

View File

@@ -244,7 +244,8 @@ try {
const uploadHeld = new Promise((resolve) => {
releaseUpload = resolve;
});
await sender.route('**/api/uploads?*', async (route) => {
await sender.route('**/api/uploads/resumable/*', async (route) => {
if (route.request().method() !== 'PUT') return route.continue();
uploadStarted();
await uploadHeld;
try {
@@ -265,7 +266,7 @@ try {
releaseUpload();
await receiver.locator('.receiving-card').waitFor({ state: 'hidden' });
await sender.locator('.send-button').filter({ hasText: '전송하기' }).waitFor();
await sender.unroute('**/api/uploads?*');
await sender.unroute('**/api/uploads/resumable/*');
assert.equal(
await sender.getByRole('alert').count(),
0,

View File

@@ -183,7 +183,7 @@ try {
await sender.locator('.item-card').filter({ hasText: 'saved.txt' }).waitFor();
const uploads = [];
sender.on('request', (request) => {
if (new URL(request.url()).pathname === '/api/uploads') uploads.push(request.url());
if (new URL(request.url()).pathname.startsWith('/api/uploads')) uploads.push(request.url());
});
await navigate(sender, '기기');
await sender.locator('.peer-card').first().click();

86
tests/e2e/pwa.mjs Normal file
View File

@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { chromium } from '@playwright/test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-pwa-'));
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 context = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1',
});
const page = await context.newPage();
await page.addInitScript(() => {
window.addEventListener('beforeinstallprompt', (event) => {
if (!event.__hopTest) { event.preventDefault(); event.stopImmediatePropagation(); }
}, { capture: true });
});
await page.goto(url);
await page.locator('.app-shell[data-online="true"]').waitFor();
const manifest = await (await context.request.get(`${url}/manifest.webmanifest`)).json();
assert.equal(manifest.display, 'standalone');
assert.equal(manifest.scope, '/');
for (const icon of manifest.icons)
assert.equal((await context.request.get(url + icon.src)).status(), 200);
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
assert.deepEqual(await page.evaluate(() => caches.keys()), []);
const install = page.getByRole('button', { name: 'hop. 앱 설치', exact: true });
await install.click();
await page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor();
assert.match(await page.locator('.install-help').innerText(), /Safari.*공유/s);
await page.screenshot({ path: 'tests/e2e/pwa-ios.png' });
await page.keyboard.press('Escape');
await page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor({ state: 'hidden' });
await page.setViewportSize({ width: 320, height: 700 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await page.evaluate(() => {
window.__installCalls = 0;
const event = new Event('beforeinstallprompt', { cancelable: true });
event.__hopTest = true;
event.prompt = async () => {
window.__installCalls++;
};
event.userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event);
});
await install.click();
assert.equal(await page.evaluate(() => window.__installCalls), 1);
await install.waitFor({ state: 'hidden' });
await page.reload();
assert.equal(await page.evaluate(async () => (await fetch('/api/device/me')).status), 200);
await context.setOffline(true);
await page.goto(url, { waitUntil: 'domcontentloaded' });
assert.match(await page.locator('body').innerText(), /인터넷 연결이 필요/);
assert.deepEqual(await page.evaluate(() => caches.keys()), []);
console.log(
'PASS: 설치 manifest·아이콘, 실제 Service Worker, iPhone 홈 화면 안내, 설치 이벤트와 설치 후 숨김, 320px 헤더, 온라인 API와 개인정보 캐시 없음, 오프라인 안내',
);
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}