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

449 lines
22 KiB
JavaScript

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({ locale: 'ko-KR', viewport: { width: 1440, height: 1050 } }),
b = await browser.newContext({
locale: 'ko-KR',
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: '<!doctype html><title>Received link</title><h1>수신한 링크 열기 검증</h1>',
}),
);
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({ locale: 'ko-KR', 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: '<h1>수신 링크</h1>' }),
);
await c.route('http://192.168.1.10/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<h1>수신 링크 2</h1>' }),
);
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.evaluate(() => {
window.__hopClipboard = 'https://example.com/blocked';
});
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
await second.waitForFunction(
() => document.querySelector('.current-url')?.value === '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 });
}