235 lines
9.7 KiB
JavaScript
235 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({
|
|
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++;
|
|
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 });
|
|
}
|