import assert from 'node:assert/strict'; import { chromium, expect } from '@playwright/test'; import { mkdtemp, rm } 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-receive-controls-')); 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), PAIR_REQUESTS_PER_MINUTE: '100', GUEST_REQUESTS_PER_MINUTE: '100', IP_REQUESTS_PER_MINUTE: '200', }), 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({ locale: 'ko-KR', viewport: { width: 1280, height: 900 } }), b = await browser.newContext({ locale: 'ko-KR', viewport: { width: 390, height: 844 } }); const sender = await a.newPage(), receiver = await b.newPage(), errors = []; sender.setDefaultTimeout(10000); receiver.setDefaultTimeout(10000); for (const page of [sender, receiver]) page.on('pageerror', (err) => errors.push(err.message)); await Promise.all([sender.goto(url), receiver.goto(url)]); await sender.locator('.peer-card').first().waitFor(); await receiver.locator('.peer-card').first().waitFor(); async function api(page, path, method = 'GET', body) { return page.evaluate( async ({ path, method, body }) => { if (!window.__testCsrf) { const s = await fetch('/api/guest/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); window.__testCsrf = (await s.json()).csrfToken; } const response = await fetch(`/api${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.__testCsrf, 'Idempotency-Key': crypto.randomUUID(), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); if (!response.ok) throw Error(`${response.status}: ${await response.text()}`); return response.json(); }, { path, method, body }, ); } const recipientId = (await api(receiver, '/device/me')).device.id; async function request(kind, size = 10) { return ( await api(sender, '/transfer-requests', 'POST', { targetDeviceId: recipientId, kind, byteSize: size, count: 1, ...(kind === 'file' ? { files: [ { name: 'Screenshot_20261012_152625_Samsung Browser.jpg', size, type: 'image/jpeg', }, ], } : {}), }) ).requestId; } for (const [kind, size, label] of [ ['text', 10, '텍스트'], ['url', 20, '링크'], ['file', 1333196, '파일'], ['file', 100000, '파일'], ['file', 1700000000, '파일'], ]) { const id = await request(kind, size); const card = receiver.getByRole('dialog', { name: '전송 수락 요청' }); await expect(card.locator('.consent-kind')).toHaveText(`${label} 전송 요청`); assert.equal(await card.locator('.request-note').count(), 0); assert.equal(await card.locator('.incoming-icon svg').count(), 1); const ratio = await card.evaluate((el) => { const a = el.querySelector('.incoming-actions .primary').getBoundingClientRect(), b = el.querySelector('.incoming-actions .button:not(.primary)').getBoundingClientRect(); return a.width / b.width; }); assert(Math.abs(ratio - 2) < 0.02); assert.equal(await card.getByText('기기 이름은 신원이 인증된 표시가 아닙니다.').count(), 0); if (kind === 'file') { await expect(card.locator('.request-file-size')).toHaveText( size === 1700000000 ? '1.6GB' : size === 100000 ? '97.7KB' : '1.3MB', ); const spacing = await card.locator('.request-files').evaluate((el) => ({ padding: getComputedStyle(el).paddingLeft, margin: getComputedStyle(el).marginLeft, overflow: el.scrollWidth > el.clientWidth, })); assert.deepEqual(spacing, { padding: '0px', margin: '0px', overflow: false }); assert( !(await card .locator('.incoming-info p') .textContent() .then((v) => /바이트|MB|GB/.test(v))), 'single file size is shown once', ); if (size === 1333196) await receiver.screenshot({ path: 'tests/e2e/receive-file-mobile.png' }); } if (kind === 'text') { await receiver.setViewportSize({ width: 1280, height: 900 }); const desktop = await card.evaluate((el) => { const a = el.querySelector('.incoming-actions .primary').getBoundingClientRect(), b = el.querySelector('.incoming-actions .button:not(.primary)').getBoundingClientRect(); return a.width / b.width; }); assert(Math.abs(desktop - 2) < 0.02); await receiver.screenshot({ path: 'tests/e2e/receive-text-desktop.png' }); await receiver.setViewportSize({ width: 390, height: 844 }); } await api(sender, `/transfer-requests/${id}/cancel`, 'POST', {}); await card.waitFor({ state: 'hidden' }); } await sender.locator('.peer-card').first().click(); await sender .getByLabel('보낼 텍스트', { exact: true }) .fill('제한이 걸려도 이 초안은 유지됩니다.'); await sender.route('**/api/transfer-requests', (route) => route.fulfill({ status: 429, contentType: 'application/json', headers: { 'Retry-After': '3' }, body: JSON.stringify({ code: 'RATE_LIMIT', message: '전송 요청이 많아요.', retryAfterSeconds: 3, limitScope: 'pair', }), }), ); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); const submit = sender.locator('.content-submit'); await expect(submit).toBeDisabled(); await expect(submit).toHaveText(/\d+초 후 전송 가능/); await expect(sender.getByLabel('보낼 텍스트', { exact: true })).toBeEnabled(); await expect(sender.getByLabel('보낼 텍스트', { exact: true })).toHaveValue( '제한이 걸려도 이 초안은 유지됩니다.', ); await expect(submit).toHaveText('전송하기', { timeout: 5500 }); await expect(submit).toBeEnabled(); await sender.unroute('**/api/transfer-requests'); await sender.getByRole('button', { name: '전송 화면 닫기' }).click(); await request('text'); const card = receiver.getByRole('dialog', { name: '전송 수락 요청' }); await card.getByRole('button', { name: '이 기기 차단', exact: true }).click(); assert.equal( (await api(receiver, '/device/blocks')).blocks.length, 0, 'first click does not block', ); await card.locator('.consent-footer').getByRole('button', { name: '취소', exact: true }).click(); await card.getByRole('button', { name: '이 기기 차단', exact: true }).click(); await card.getByRole('button', { name: '차단하기', exact: true }).click(); await card.waitFor({ state: 'hidden' }); await expect(receiver.locator('.peer-card')).toHaveCount(0); await receiver.getByRole('button', { name: '내 기기 설정', exact: true }).click(); await receiver.getByRole('button', { name: '관리', exact: true }).click(); const blocked = receiver.getByRole('dialog', { name: '차단한 기기', exact: true }); await expect(blocked.locator('.blocked-device-list li')).toHaveCount(1); await blocked.getByRole('button', { name: '차단 해제', exact: true }).click(); await expect(blocked.getByText('차단한 기기가 없어요.')).toBeVisible(); await expect(receiver.locator('.peer-card')).toHaveCount(1); assert.equal((await api(receiver, '/device/blocks')).blocks.length, 0); await blocked.getByRole('button', { name: '대화상자 닫기', exact: true }).click(); const recipientName = (await api(receiver, '/device/me')).device.name; // Reject the first request before its POST response reaches the sender. This // must still close the composer even if it never observed a pending snapshot. await sender.route('**/api/transfer-requests', async (route) => { const response = await route.fetch(); assert.equal(response.status(), 200); const { requestId } = await response.json(); await api(receiver, `/transfer-requests/${requestId}/reject`, 'POST', {}); await route.fulfill({ response }); }); for (const kind of ['text', 'file', 'url']) { await sender.locator('.peer-card').first().click(); if (kind === 'file') await sender.getByLabel('보낼 파일 선택', { exact: true }).setInputFiles({ name: 'declined.txt', mimeType: 'text/plain', buffer: Buffer.from('test'), }); else await sender .getByLabel('보낼 텍스트', { exact: true }) .fill(kind === 'url' ? 'https://example.com/declined' : '빠른 거절도 놓치지 않습니다.'); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); if (kind !== 'text') { const incoming = receiver.getByRole('dialog', { name: '전송 수락 요청' }); await incoming.getByRole('button', { name: '거절', exact: true }).click(); } await expect(sender.locator('.send-dialog')).toHaveCount(0); await expect(sender.locator('.toast')).toHaveText(`${recipientName}에서 전송을 거절했어요.`); if (kind === 'text') await sender.unroute('**/api/transfer-requests'); } // History must not replay rejection notifications after a reload; the fourth // request now gets the real pair cooldown and visible remaining seconds. await sender.reload(); await sender.locator('.peer-card').first().waitFor(); await expect(sender.locator('.toast')).toHaveCount(0); await sender.locator('.peer-card').first().click(); await sender.getByLabel('보낼 텍스트', { exact: true }).fill('세 번 거절 이후의 대기'); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); await expect(sender.locator('.content-submit')).toBeDisabled(); await expect(sender.locator('.content-submit')).toHaveText(/\d+초 후 전송 가능/); await expect(sender.locator('.error-toast')).toContainText('연속 3회 거절'); assert.deepEqual(errors, []); console.log( 'PASS: receive icons/sizes, desktop/mobile 2:1 controls, retry countdown/draft preservation, block/unblock, first two rejections allow retries, text/file/link rejection closes sender and notifies, decision before HTTP response and reload deduplication, third rejection triggers pair cooldown.', ); } finally { await browser?.close(); await runtime.app.close(); await rm(folder, { recursive: true, force: true }); }