Organize uploader storage and restore transfer readiness

This commit is contained in:
2026-10-14 19:15:46 +09:00
parent 69e7449ba7
commit c3573b605c
24 changed files with 966 additions and 449 deletions

View File

@@ -151,12 +151,12 @@ try {
`import {createHash} from 'node:crypto';const state=${JSON.stringify(fileState.pending)},base='http://127.0.0.1:${port}',path='/api/uploads/resumable/'+state.uploadId;const headers={Origin:'https://hop.test',Cookie:state.cookie,'X-CSRF-Token':state.csrf};const r=await fetch(base+path,{headers});const progress=await r.json();if(!r.ok||progress.offset!==8*1024*1024)throw Error('partial offset not preserved');const last=await fetch(base+path,{method:'PUT',headers:{...headers,'Content-Type':'application/octet-stream','Upload-Offset':String(progress.offset)},body:Buffer.alloc(7,120)});if(!last.ok)throw Error(await last.text());const complete=await fetch(base+path+'/complete',{method:'POST',headers:{...headers,'Content-Type':'application/json'},body:'{}'});if(!complete.ok)throw Error(await complete.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const item=(await list.json()).items.find(i=>i.file?.name==='resume.bin');const downloaded=await fetch(base+'/api/access/'+item.accessId+'/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const content=Buffer.from(await downloaded.arrayBuffer());if(!downloaded.ok||content.length!==8*1024*1024+7||!content.equals(Buffer.alloc(content.length,120)))throw Error('resumed content mismatch');console.log('resumed');`,
);
assert.equal(resumed, 'resumed');
// Completed physical paths and the original download URL survive another restart.
// Per-uploader physical paths and the original download URL survive another restart.
docker('restart', name);
wait();
assert.equal(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('completed download failed after restart');console.log(await r.text());`,
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('owner folder download failed after restart');console.log(await r.text());`,
),
'Docker file persistence',
);
@@ -172,14 +172,22 @@ try {
),
);
assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 });
const completed = resolve(folder, 'completed');
const stored = readdirSync(completed).find(
(key) => readFileSync(resolve(completed, key)).toString() === 'Docker file persistence',
);
assert(stored, 'downloaded anonymous file is visible in the host completed directory');
assert.match(stored, /^[a-f0-9-]{36}\.txt$/);
assert.equal(existsSync(resolve(folder, 'originals', stored)), false);
unlinkSync(resolve(completed, stored));
const originals = resolve(folder, 'originals');
const stored = readdirSync(originals)
.filter((key) => key.startsWith('guest-'))
.flatMap((owner) =>
readdirSync(resolve(originals, owner)).map((key) => ({
owner,
key,
path: resolve(originals, owner, key),
})),
)
.find(({ path }) => readFileSync(path).toString() === 'Docker file persistence');
assert(stored, 'uploaded file is visible under its guest owner in the host project');
assert.match(stored.owner, /^guest-[a-f0-9-]{36}$/);
assert.match(stored.key, /^[a-f0-9-]{36}\.txt$/);
assert.equal(existsSync(resolve(folder, 'completed')), false);
unlinkSync(stored.path);
const missing = JSON.parse(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify({status:r.status,body:await r.json()}));`,

View File

@@ -52,6 +52,16 @@ try {
const original = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation);
navigator.geolocation.getCurrentPosition = (...args) => {
window.__geoCalls++;
if (window.__holdGeo) {
const [success, failure, options] = args;
return original(
(position) => {
window.__releaseGeo = () => success(position);
},
failure,
options,
);
}
return original(...args);
};
});
@@ -104,6 +114,50 @@ try {
document.dispatchEvent(new Event('visibilitychange'));
});
await sender.locator('.peer-card').first().waitFor();
// A native file picker hides the page. Sending immediately on return must wait for
// the fresh location acknowledgement, without a failed first request or lost files.
await sender.locator('.peer-card').first().click();
await sender.evaluate(() => {
window.__holdGeo = true;
window.__geoVisible = false;
document.dispatchEvent(new Event('visibilitychange'));
});
await receiver.locator('button.peer-card').waitFor({ state: 'hidden' });
await sender
.locator('.send-dialog input[type="file"]')
.setInputFiles({
name: 'picker.txt',
mimeType: 'text/plain',
buffer: Buffer.from('file picker return'),
});
await sender.evaluate(() => {
window.__geoVisible = true;
document.dispatchEvent(new Event('visibilitychange'));
});
await sender.waitForFunction(() => Boolean(window.__releaseGeo));
let transferPosts = 0;
const countTransfer = (request) => {
if (request.method() === 'POST' && new URL(request.url()).pathname === '/api/transfer-requests')
transferPosts++;
};
sender.on('request', countTransfer);
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
await sender.waitForTimeout(250);
assert.equal(transferPosts, 0, '위치 복귀 전 요청을 보내지 않음');
assert.equal(await sender.locator('.error-toast').count(), 0, '첫 전송 오류 없음');
assert.match(await sender.locator('.selected-files').innerText(), /picker.txt/);
await sender.evaluate(() => {
window.__holdGeo = false;
window.__releaseGeo();
delete window.__releaseGeo;
});
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
const pickedDownload = receiver.waitForEvent('download');
await receiver.getByRole('button', { name: '수락', exact: true }).click();
assert.equal((await pickedDownload).suggestedFilename(), 'picker.txt');
assert.equal(transferPosts, 1, '파일 요청은 한 번만 생성');
sender.off('request', countTransfer);
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
await b.route('https://example.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>Nearby link</title>' }),
);

View File

@@ -336,8 +336,7 @@ try {
await receiver.locator('.current-vault').waitFor();
assert.equal(await receiver.locator('.content-submit').isEnabled(), true);
let removed = false;
for (const key of await readdir(runtime.service.storage.originals)) {
const path = resolve(runtime.service.storage.originals, key);
for await (const { path } of runtime.service.storage.layout.entries()) {
if ((await readFile(path)).equals(bytes)) {
await unlink(path);
removed = true;
@@ -392,6 +391,13 @@ try {
);
await sender.locator('.error-toast').waitFor({ state: 'hidden', timeout: 8000 });
await receiver.getByRole('button', { name: '로그아웃', exact: true }).click();
await receiver.locator('.nearby-section').waitFor();
assert.equal(
await receiver.locator('.account-auth').count(),
0,
'로그아웃 후 로그인 화면 대신 메인 기기 화면',
);
await receiver.locator('.login-button').click();
await receiver.locator('.account-auth').waitFor();
assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved'));
const signup = receiver.locator('.account-auth');

View File

@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { chromium } from '@playwright/test';
import { mkdtemp, rm, readFile } 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';
import { requests as table } from '../../apps/server/src/db/schema.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-reconnect-'));
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',
SAME_NETWORK_DISCOVERY_ENABLED: 'true',
LOCATION_DISCOVERY_ENABLED: 'false',
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({ viewport: { width: 1280, height: 900 } }),
b = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
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();
const receiverState = await receiver.evaluate(
async () => (await (await fetch('/api/device/me')).json()).device,
);
const guestId = runtime.service.deviceGuest(receiverState.id).guestId;
await sender.locator('.peer-card').first().click();
const bytes = Buffer.from('first-click transfer');
await sender
.locator('.send-dialog input[type="file"]')
.setInputFiles({ name: 'first.txt', mimeType: 'text/plain', buffer: bytes });
let hold = true;
runtime.io.use((socket, next) => {
if (hold && socket.data.guest.id === guestId) setTimeout(() => next(), 1200);
else next();
});
const serverSocket = [...runtime.io.sockets.sockets.values()].find(
(socket) => socket.data.guest.id === guestId,
);
serverSocket.conn.close();
await receiver.locator('.app-shell[data-online="false"]').waitFor();
const responses = [],
keys = [];
sender.on('response', (response) => {
if (
new URL(response.url()).pathname === '/api/transfer-requests' &&
response.request().method() === 'POST'
) {
responses.push(response.status());
keys.push(response.request().headers()['idempotency-key']);
}
});
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
await sender.getByRole('button', { name: '연결 확인 중', exact: true }).waitFor();
assert.equal(await sender.locator('.error-toast').count(), 0);
assert.match(await sender.locator('.selected-files').innerText(), /first.txt/);
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
hold = false;
assert.equal(responses[0], 409);
assert.equal(responses.at(-1), 200);
assert.equal(new Set(keys).size, 1, 'reconnect retry reuses one idempotency key');
assert.equal(runtime.service.db.select().from(table).all().length, 1, 'only one logical request');
const downloaded = receiver.waitForEvent('download');
await receiver.getByRole('button', { name: '수락', exact: true }).click();
const download = await downloaded;
assert.equal(download.suggestedFilename(), 'first.txt');
assert.deepEqual(await readFile(await download.path()), bytes);
assert.equal(await sender.locator('.error-toast').count(), 0);
assert.deepEqual(errors, []);
console.log(
'PASS: same-network peer reconnect on first file send, connection feedback, file draft retained, stable retry key, one request, accepted download on mobile.',
);
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}