Files
hop.noname.company/tests/e2e/nearby.mjs

237 lines
9.7 KiB
JavaScript

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: '<title>Nearby link</title>' }),
);
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 });
}