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 }); }