import assert from 'node:assert/strict'; import { chromium } from '@playwright/test'; import { resolve } from 'node:path'; import { mkdtemp, rm } from 'node:fs/promises'; 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-e2e-')); 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', PAIR_REQUESTS_PER_MINUTE: '5', GUEST_REQUESTS_PER_MINUTE: '10', IP_REQUESTS_PER_MINUTE: '40', SESSION_SECRET: 's'.repeat(40), DISCOVERY_SECRET: 'd'.repeat(40), }), webRoot: resolve('apps/web/dist'), }); let browser; const requestCounts = {}; try { await runtime.app.listen({ host: '127.0.0.1', port: 0 }); const { port } = runtime.app.server.address(); const url = `http://127.0.0.1:${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: 1440, height: 1050 } }), b = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, }); await a.addInitScript(() => { window.__hopClipboardReads = 0; window.__hopClipboard = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E'; Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText: async () => { window.__hopClipboardReads++; if (window.__hopClipboardDenied) throw new DOMException('Denied', 'NotAllowedError'); return window.__hopClipboard; }, writeText: async () => {}, }, }); }); await a.addInitScript(() => { if (!localStorage.getItem('hop-theme-version')) localStorage.setItem('hop-theme', 'system'); }); await b.route('https://example.com/**', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: 'Received link

수신한 링크 열기 검증

', }), ); const sender = await a.newPage(), receiver = await b.newPage(); const errors = []; for (const page of [sender, receiver]) page.on('request', (request) => { const path = new URL(request.url()).pathname; if (path.startsWith('/api')) requestCounts[path] = (requestCounts[path] ?? 0) + 1; }); sender.on('pageerror', (e) => errors.push(e.message)); receiver.on('pageerror', (e) => errors.push(e.message)); await Promise.all([sender.goto(url), receiver.goto(url)]); await sender.locator('.device-name').waitFor(); await receiver.locator('.device-name').waitFor(); await sender.locator('.app-shell[data-online="true"]').waitFor(); await receiver.locator('.app-shell[data-online="true"]').waitFor(); await sender.locator('.device-name').click(); await sender.getByRole('textbox', { name: '기기 이름' }).fill('보내는 Mac'); await sender.getByRole('button', { name: '저장' }).click(); await sender.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor(); await sender.getByRole('button', { name: '다른 기기 연결', exact: true }).click(); await sender.getByRole('button', { name: '연결 공간 만들기' }).click(); await sender.getByRole('button', { name: '만들기', exact: true }).click(); await sender.locator('.invite-code').waitFor(); const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, ''); await receiver.getByRole('button', { name: '다른 기기 연결', exact: true }).click(); await receiver.getByRole('button', { name: '연결 공간 참여', exact: true }).click(); await receiver.getByRole('textbox', { name: '연결 코드' }).fill(code); await receiver.getByRole('button', { name: '참여하기', exact: true }).click(); await receiver.locator('.room-leave').waitFor(); await sender.locator('.modal-backdrop').waitFor({ state: 'hidden' }); await receiver.locator('.modal-backdrop').waitFor({ state: 'hidden' }); await sender.locator('.peer-card').first().waitFor(); await sender.locator('.peer-card').first().click(); const secret = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E'; await sender.waitForFunction( (value) => document.querySelector('.current-url')?.value === value, secret, ); assert.equal(await sender.evaluate(() => window.__hopClipboardReads), 1); await sender.evaluate(() => { window.__hopClipboard = 'https://example.com/focus-refresh'; window.dispatchEvent(new Event('focus')); }); await sender.waitForFunction( (value) => document.querySelector('.current-url')?.value === value, secret, ); await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click(); await sender.waitForFunction( (value) => document.querySelector('.current-url')?.value === value, 'https://example.com/focus-refresh', ); await sender.evaluate(() => { window.__hopClipboard = 'https://example.com/reselect-refresh'; }); await sender.getByRole('button', { name: '전송 화면 닫기' }).click(); await sender.locator('.peer-card').first().click(); await sender.waitForFunction( (value) => document.querySelector('.current-url')?.value === value, 'https://example.com/reselect-refresh', ); await sender.evaluate(() => { window.__hopClipboard = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E'; }); await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click(); await sender.waitForFunction( (value) => document.querySelector('.current-url')?.value === value, secret, ); assert.equal(await sender.locator('.desktop-nav, .mobile-nav, .connection-status').count(), 0); assert.equal(await sender.getByRole('button', { name: '설정', exact: true }).count(), 0); assert.equal(await sender.locator('.input-tabs').count(), 0); assert.equal(await sender.getByRole('button', { name: '파일 선택', exact: true }).count(), 0); assert.equal(await sender.locator('.content-submit').count(), 1); const { requests: requestTable } = await import('../../apps/server/src/db/schema.ts'); assert.equal( runtime.service.db.select().from(requestTable).all().length, 0, '클립보드 읽기만으로 요청을 보내면 안 됨', ); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); await receiver.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); await receiver.getByRole('button', { name: '수락', exact: true }).waitFor(); const cardBox = await receiver .locator('.incoming-card') .filter({ hasText: '수락' }) .boundingBox(); assert( cardBox && cardBox.y >= 0 && cardBox.y + cardBox.height <= 844, '스크롤 뒤 수신 요청이 뷰포트 안에 있어야 함', ); assert.equal( await receiver .locator('.consent-card') .evaluate((node) => getComputedStyle(node).backgroundColor), 'rgb(255, 255, 255)', ); assert(Math.abs(cardBox.y + cardBox.height / 2 - 844 / 2) < 2, '수락 팝업은 화면 중앙'); await receiver.screenshot({ path: 'tests/e2e/consent-mobile.png', fullPage: true }); await sender.getByText('상대 기기의 수락을 기다립니다.', { exact: true }).waitFor(); let releaseSnapshot, notifyCaptured; const captured = new Promise((resolve) => { notifyCaptured = resolve; }); const held = new Promise((resolve) => { releaseSnapshot = resolve; }); let heldOnce = false; await sender.route('**/api/device/snapshot', async (route) => { if (heldOnce || route.request().method() !== 'GET') return route.continue(); heldOnce = true; const response = await route.fetch(); const oldSnapshot = await response.json(); assert(oldSnapshot.requests.some((r) => r.status === 'pending')); notifyCaptured(); await held; await route.fulfill({ response, json: oldSnapshot }); }); await sender.evaluate(() => window.dispatchEvent(new Event('focus'))); await Promise.race([ captured, new Promise((_, reject) => setTimeout(() => reject(Error('지연 조회가 시작되지 않았습니다.')), 10000), ), ]); assert(!(await receiver.locator('body').innerText()).includes(secret)); const openedAutomatically = receiver.waitForEvent('popup'); await receiver.getByRole('button', { name: '수락', exact: true }).click(); releaseSnapshot(); await sender .getByRole('button', { name: '전송 취소', exact: false }) .waitFor({ state: 'hidden' }); const external = await openedAutomatically; await external.waitForURL(secret); assert.equal(await external.evaluate(() => window.opener), null); await external.close(); assert.equal(await receiver.locator('.arrival-card').count(), 0); assert.equal(await receiver.locator('.item-card').count(), 0); assert.equal(await receiver.getByRole('button', { name: /이번 기기/ }).count(), 0); assert.equal(await receiver.evaluate(() => window.__hopXss), undefined); const received = await receiver.evaluate( async () => (await (await fetch('/api/device/items')).json()).items, ); assert(received.find((item) => item.url === secret)?.read, '자동 열기 후 읽음 표시'); const cancelCountBefore = runtime.service.db.select().from(requestTable).all().length; await sender.locator('.peer-card').first().click(); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); await sender.locator('.link-send').filter({ hasText: '전송 취소' }).waitFor(); await sender.locator('.link-send').filter({ hasText: '전송 취소' }).click(); await receiver.getByRole('button', { name: '수락', exact: true }).waitFor({ state: 'hidden' }); assert.equal(runtime.service.db.select().from(requestTable).all().length, cancelCountBefore + 1); assert.equal(runtime.service.db.select().from(requestTable).all().at(-1).status, 'cancelled'); await sender.reload(); await sender.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor(); await sender.evaluate(() => { window.__hopClipboardDenied = true; }); await sender.locator('.peer-card').first().click(); await sender.getByLabel('보낼 텍스트', { exact: true }).waitFor(); assert.equal(await sender.locator('.clipboard-link-card').count(), 0); const fileBytes = Buffer.from('브라우저 파일 전달 검증'); const dragged = await sender.evaluateHandle(() => { const data = new DataTransfer(); data.items.add(new File(['브라우저 파일 전달 검증'], 'browser.txt', { type: 'text/plain' })); return data; }); await sender.locator('.unified-composer').dispatchEvent('drop', { dataTransfer: dragged }); await dragged.dispose(); await sender.locator('.selected-files').getByText('browser.txt', { exact: true }).waitFor(); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); await receiver.getByRole('button', { name: '수락', exact: true }).waitFor(); const downloaded = receiver.waitForEvent('download'); await receiver.getByRole('button', { name: '수락', exact: true }).click(); const download = await downloaded; assert.equal(download.suggestedFilename(), 'browser.txt'); const { readFile } = await import('node:fs/promises'); assert.deepEqual(await readFile(await download.path()), fileBytes); assert.equal(await receiver.locator('.arrival-card, .item-card').count(), 0); assert.equal(await sender.locator('.active-transfers, .upload-status').count(), 0); let uploadStarted, releaseUpload; const uploadCaptured = new Promise((resolve) => { uploadStarted = resolve; }); const uploadHeld = new Promise((resolve) => { releaseUpload = resolve; }); await sender.route('**/api/uploads/resumable/*', async (route) => { if (route.request().method() !== 'PUT') return route.continue(); uploadStarted(); await uploadHeld; try { await route.abort('aborted'); } catch {} }); await sender.locator('.peer-card').first().click(); await sender .locator('input[type=file]') .setInputFiles({ name: 'browser.txt', mimeType: 'text/plain', buffer: fileBytes }); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); await receiver.getByRole('button', { name: '수락', exact: true }).click(); await Promise.race([ uploadCaptured, new Promise((_, reject) => setTimeout(() => reject(Error('업로드 시작 대기 실패')), 10000)), ]); await sender.setViewportSize({ width: 390, height: 844 }); await sender.locator('.file-transfer-progress progress').waitFor(); const listText = await sender.locator('.content-pane').innerText(); assert.equal(listText.split('browser.txt').length - 1, 1, '파일명은 목록에 한 번만 표시'); assert(!listText.includes('전송 중입니다.'), '진행률이 있으면 전송 중 안내를 반복하지 않음'); assert.equal(await sender.locator('.composer-progress').count(), 0); await sender.screenshot({ path: 'tests/e2e/file-progress-mobile.png' }); await sender.locator('.send-button').filter({ hasText: '전송 취소' }).click(); releaseUpload(); await receiver.locator('.receiving-card').waitFor({ state: 'hidden' }); await sender.locator('.send-button').filter({ hasText: '전송하기' }).waitFor(); await sender.unroute('**/api/uploads/resumable/*'); assert.equal( await sender.getByRole('alert').count(), 0, '사용자 취소를 업로드 오류로 표시하지 않음', ); await sender.getByRole('button', { name: '전송 화면 닫기' }).click(); assert.equal( await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor), 'rgb(0, 0, 0)', ); await sender.setViewportSize({ width: 1440, height: 1050 }); await sender.screenshot({ path: 'tests/e2e/desktop.png', fullPage: true }); await sender.getByRole('button', { name: '더보기', exact: true }).click(); await sender.getByRole('menuitem', { name: '라이트 테마로 전환', exact: true }).click(); assert.equal( await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor), 'rgb(250, 250, 250)', ); await sender.screenshot({ path: 'tests/e2e/light-desktop.png', fullPage: true }); await sender.getByRole('button', { name: '더보기', exact: true }).click(); await sender.getByRole('menuitem', { name: '다크 테마로 전환', exact: true }).click(); await receiver.screenshot({ path: 'tests/e2e/mobile.png', fullPage: true }); assert.equal( await receiver.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, '모바일 가로 넘침', ); const second = await a.newPage(); await second.goto(url); await second.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor(); await sender.close(); await receiver.getByRole('button', { name: 'hop. 홈', exact: true }).click(); await receiver.locator('.peer-card').filter({ hasText: '보내는 Mac' }).waitFor(); const c = await browser.newContext({ viewport: { width: 1366, height: 900 } }), urlReceiver = await c.newPage(); urlReceiver.on('pageerror', (error) => errors.push(error.message)); await urlReceiver.goto(url); await urlReceiver.locator('.device-name').waitFor(); const receiverCode = (await urlReceiver.locator('.footer-device-code').innerText()).trim(); await urlReceiver.getByRole('button', { name: '다른 기기 연결', exact: true }).click(); await urlReceiver.getByRole('button', { name: '연결 공간 참여', exact: true }).click(); await urlReceiver.getByRole('textbox', { name: '연결 코드' }).fill(code); await urlReceiver.getByRole('button', { name: '참여하기', exact: true }).click(); await urlReceiver.locator('.room-leave').waitFor(); await urlReceiver.locator('.modal-backdrop').waitFor({ state: 'hidden' }); await second.locator('.peer-card').filter({ hasText: receiverCode }).waitFor(); await second.evaluate(() => { window.__hopClipboard = '일반 텍스트는 전송하지 않습니다.'; }); await second.locator('.peer-card').filter({ hasText: receiverCode }).click(); await second.getByLabel('보낼 텍스트', { exact: true }).waitFor(); const urls = ['https://example.com/path?x=1', 'http://192.168.1.10/private']; await second.getByRole('button', { name: '전송 화면 닫기', exact: true }).click(); // Old API callers may still send multiple URLs; the new clipboard UI treats these as text. const sourceCookie = (await a.cookies(url)).find((cookie) => cookie.name === 'hop_guest'); const targetCookie = (await c.cookies(url)).find((cookie) => cookie.name === 'hop_guest'); const targetGuest = runtime.service.guest(targetCookie.value); const legacyHeaders = { Origin: url, 'X-CSRF-Token': runtime.service.csrf(sourceCookie.value), 'Idempotency-Key': crypto.randomUUID(), }; const legacyRequest = await a.request.post(`${url}/api/transfer-requests`, { headers: legacyHeaders, data: { targetDeviceId: runtime.service.device(targetGuest.id).id, kind: 'url', count: 2, byteSize: Buffer.byteLength(urls.join('\n')), }, }); assert.equal(legacyRequest.status(), 200); const legacyId = (await legacyRequest.json()).requestId; await urlReceiver.getByRole('button', { name: '수락', exact: true }).waitFor(); assert(!(await urlReceiver.locator('body').innerText()).includes(urls[0])); await c.route('https://example.com/**', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: '

수신 링크

' }), ); await c.route('http://192.168.1.10/**', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: '

수신 링크 2

' }), ); const opened = []; urlReceiver.on('popup', (page) => opened.push(page)); await urlReceiver.getByRole('button', { name: '수락', exact: true }).click(); const legacyComplete = await a.request.post(`${url}/api/transfers/${legacyId}/complete`, { headers: { ...legacyHeaders, 'Idempotency-Key': crypto.randomUUID() }, data: { urls }, }); assert.equal(legacyComplete.status(), 200); assert.equal(opened.length, 2); await Promise.all( opened.map((page) => page.waitForURL((address) => urls.includes(address.href))), ); for (const address of urls) { const page = opened.find((page) => page.url() === address); assert(page, `자동으로 열린 링크: ${address}`); assert.equal(await page.evaluate(() => window.opener), null); await page.close(); } await urlReceiver.evaluate(() => { window.__nativeOpen = window.open; window.open = () => null; }); await second.locator('.peer-card').filter({ hasText: receiverCode }).click(); await second.locator('.current-url, .current-text').fill('https://example.com/blocked'); await second.getByRole('button', { name: '전송하기', exact: true }).click(); await urlReceiver.getByRole('button', { name: '수락', exact: true }).click(); await urlReceiver.locator('.delivery-blocked').waitFor(); assert.equal(c.pages().length, 1); await urlReceiver.evaluate(() => { window.open = window.__nativeOpen; }); const manuallyOpened = urlReceiver.waitForEvent('popup'); await urlReceiver .locator('.delivery-blocked') .getByRole('link', { name: /링크 열기/ }) .click(); const fallbackPage = await manuallyOpened; await fallbackPage.waitForURL('https://example.com/blocked'); await fallbackPage.close(); await urlReceiver.locator('.delivery-blocked').waitFor({ state: 'hidden' }); await urlReceiver.reload(); await urlReceiver.locator('.device-name').waitFor(); assert.equal(c.pages().length, 1, '재조회나 새로고침으로 재실행하지 않음'); assert.equal(await second.getByRole('alert').count(), 0, '정상 전송이 요청 제한에 걸리면 안 됨'); const oldDevice = await second.locator('.device-name').innerText(); const renewed = second.waitForResponse( (response) => new URL(response.url()).pathname === '/api/guest/session' && response.request().method() === 'POST' && response.status() === 200, ); runtime.service.cfg.sessionSecret = 'rotated-test-secret-'.repeat(3); for (const socket of runtime.io.sockets.sockets.values()) socket.conn.close(); await renewed; await second.locator('.app-shell[data-online="true"]').waitFor(); assert.equal(await second.locator('.device-name').innerText(), oldDevice); await second.locator('.device-name').click(); await second.getByRole('textbox', { name: '기기 이름' }).fill('재연결된 Mac'); await second.getByRole('button', { name: '저장' }).click(); await second.locator('.device-name').filter({ hasText: '재연결된 Mac' }).waitFor(); assert.deepEqual(errors, []); console.log( 'PASS: 두 브라우저 QR·코드 연결, 수락 전 본문 비공개, 클립보드 URL·기존 복수 URL API 호환과 클립보드 입력, 수락 후 자동 새 탭 URL 열기, opener 격리, 읽음 동기화, 이름 유지, 다중 탭·CSRF 재연결 복구, 390px 가로 넘침 없음, 실제 파일 업로드·자동 다운로드, 수락 중 지연된 과거 조회에도 전달 완료, 스크롤 뒤 중앙의 고대비 수락 팝업, 검정/라이트 전환, 버튼 자리의 전송 취소·익명 내역 숨김·새로고침 중복 실행 방지.', ); } catch (error) { console.error(error); console.error(requestCounts); const { batches } = await import('../../apps/server/src/db/schema.ts'); console.error(runtime.service.db.select({ status: batches.status }).from(batches).all()); for (const context of browser?.contexts() ?? []) for (const page of context.pages()) console.error((await page.locator('body').innerText()).slice(0, 4000)); throw error; } finally { await browser?.close(); await runtime.app.close(); await rm(folder, { recursive: true, force: true }); }