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-nearby-')); 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: '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 origin = { latitude: 37.5665, longitude: 126.978, accuracy: 20 }; const a = await browser.newContext({ locale: 'ko-KR', permissions: ['geolocation'], geolocation: origin, viewport: { width: 1280, height: 900 }, }); const b = await browser.newContext({ locale: 'ko-KR', permissions: ['geolocation'], geolocation: { ...origin, latitude: origin.latitude + 0.0004 }, viewport: { width: 390, height: 844 }, isMobile: true, }); for (const context of [a, b]) await context.addInitScript(() => { window.__geoVisible = true; window.__geoCalls = 0; Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => (window.__geoVisible ? 'visible' : 'hidden'), }); 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); }; }); const sender = await a.newPage(), receiver = await b.newPage(), errors = []; for (const page of [sender, receiver]) page.on('pageerror', (error) => errors.push(error.message)); let apiCalls = 0; sender.on('request', (request) => { if (new URL(request.url()).pathname.startsWith('/api/')) apiCalls++; }); await Promise.all([sender.goto(url), receiver.goto(url)]); await sender.locator('.app-shell[data-online="true"]').waitFor(); await receiver.locator('.app-shell[data-online="true"]').waitFor(); await sender.locator('button.peer-card').first().waitFor(); await receiver.locator('button.peer-card').first().waitFor(); assert.equal( await sender.getByRole('button', { name: '근처 기기 찾기', exact: true }).count(), 0, ); assert.equal(await sender.locator('.nearby-search-control').count(), 0); await new Promise((resolve) => setTimeout(resolve, 600)); const initialCalls = apiCalls; const initialGeoCalls = await sender.evaluate(() => window.__geoCalls); await new Promise((resolve) => setTimeout(resolve, 11000)); assert.equal(apiCalls, initialCalls, '전송 없는 대기 화면은 반복 API 검색 없음'); assert.equal( await sender.evaluate(() => window.__geoCalls), initialGeoCalls, '위치는 계속 검색하지 않음', ); assert(initialGeoCalls > 0, '허용된 위치로 자동 발견'); assert.match(await sender.locator('.peer-card').innerText(), /근처 기기/); assert.equal(runtime.service.nearby.points.size, 2); assert.equal( await receiver.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, ); await receiver.waitForTimeout(260); // Screenshot after the one-time arrival transition. await receiver.screenshot({ path: 'tests/e2e/nearby-mobile.png' }); await receiver.evaluate(() => { window.__geoVisible = false; document.dispatchEvent(new Event('visibilitychange')); }); await sender.locator('.peer-card').waitFor({ state: 'hidden' }); assert.equal(runtime.service.nearby.points.size, 1, '탭 숨김 시 메모리 위치 삭제'); await receiver.evaluate(() => { window.__geoVisible = true; 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: 'Nearby link' }), ); await receiver.evaluate(() => { const open = window.open.bind(window); window.open = (...args) => { const tab = open(...args); window.__geoVisible = false; document.dispatchEvent(new Event('visibilitychange')); return tab; }; }); await sender.locator('.peer-card').first().click(); await sender.locator('.current-url, .current-text').fill('https://example.com/nearby'); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); const opened = receiver.waitForEvent('popup'); void opened.catch(() => {}); await receiver.getByRole('button', { name: '수락', exact: true }).click(); const tab = await opened; await tab.waitForURL('https://example.com/nearby'); assert.equal(await tab.evaluate(() => window.opener), null); await tab.close(); assert.equal(runtime.service.nearby.points.size, 1, '새 탭 수락 시에도 좌표를 남기지 않음'); await receiver.evaluate(() => { window.__geoVisible = true; document.dispatchEvent(new Event('visibilitychange')); }); await sender.locator('.peer-card').first().waitFor(); async function visibilityCycle() { await receiver.evaluate(() => { window.__geoVisible = false; document.dispatchEvent(new Event('visibilitychange')); }); await sender.locator('button.peer-card').waitFor({ state: 'hidden' }); await receiver.evaluate(() => { window.__geoVisible = true; document.dispatchEvent(new Event('visibilitychange')); }); } await b.setGeolocation({ ...origin, latitude: origin.latitude + 0.02 }); await visibilityCycle(); await receiver.waitForFunction(() => window.__geoCalls >= 4); await sender.locator('button.peer-card').waitFor({ state: 'hidden' }); await receiver.evaluate(() => { navigator.geolocation.getCurrentPosition = (_success, error) => { window.__geoCalls++; error({ code: 1 }); }; }); await visibilityCycle(); await sender.waitForFunction(() => document.querySelectorAll('button.peer-card').length === 0); await new Promise((resolve) => setTimeout(resolve, 300)); assert.equal(runtime.service.nearby.points.size, 1); const deniedCalls = await receiver.evaluate(() => window.__geoCalls); await visibilityCycle(); await receiver.waitForTimeout(300); assert.equal( await receiver.evaluate(() => window.__geoCalls), deniedCalls, '권한 거절 후 반복 요청 없음', ); assert.equal( await receiver.getByRole('button', { name: '다른 기기 연결', exact: true }).count(), 1, ); assert.deepEqual(await sender.evaluate(() => caches.keys()), []); assert.deepEqual(errors, []); console.log( 'PASS: 위치 허용 후 자동 발견, 별도 검색 버튼/안내 없음, 대기 중 반복 API 없음, 100m 양쪽 동의 검색, 모바일, 숨김 시 위치 삭제와 복귀, 새 탭 수락, 거리 밖 제외, 권한 거절, 좌표 캐시 없음', ); } finally { await browser?.close(); await runtime.app.close(); await rm(folder, { recursive: true, force: true }); }