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({ permissions: ['geolocation'], geolocation: origin, viewport: { width: 1280, height: 900 }, }); const b = await browser.newContext({ 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++; 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(); await b.route('https://example.com/**', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: '