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

498 lines
23 KiB
JavaScript

import assert from 'node:assert/strict';
import { chromium } from '@playwright/test';
import { io } from 'socket.io-client';
import { mkdtemp, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
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-layout-'));
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',
OFFLINE_GRACE_MS: '150',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
});
const sockets = [];
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 context = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 1280, height: 1000 },
});
const page = await context.newPage(),
errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await context.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: async () => {
window.__layoutReads = (window.__layoutReads ?? 0) + 1;
return window.__layoutClipboard ?? 'https://example.com/layout';
},
writeText: async () => {},
},
});
});
let releaseStartup;
const startupGate = new Promise((resolve) => {
releaseStartup = resolve;
});
await page.route('**/api/device/snapshot', async (route) => {
await startupGate;
await route.continue();
});
await page.addInitScript(() => {
const query = navigator.permissions.query.bind(navigator.permissions);
Object.defineProperty(navigator, 'permissions', {
configurable: true,
value: {
query: (descriptor) =>
descriptor.name === 'clipboard-read' && window.__clipboardNeedsPaste
? Promise.reject(new TypeError('unsupported clipboard permission'))
: query(descriptor),
},
});
});
await page.goto(url);
await page.locator('.initial-device-loading').waitFor();
const loadingCard = await page.locator('.initial-device-loading .peer-card').boundingBox();
const loadingText = await page.locator('.initial-device-loading p').boundingBox();
assert(
loadingText.y - loadingCard.y - loadingCard.height >= 12,
'초기 안내 문구와 스켈레톤 사이 간격',
);
await page.screenshot({ path: 'tests/e2e/initial-loading.png' });
releaseStartup();
await page.locator('.initial-device-loading').waitFor({ state: 'hidden' });
await page.unroute('**/api/device/snapshot');
await page.locator('.app-shell[data-online="true"]').waitFor();
await page.locator('.wordmark .brand-logo svg').waitFor();
const letterColor = () =>
page
.locator('.wordmark path[fill="currentColor"]')
.first()
.evaluate((el) => getComputedStyle(el).fill);
const dotColor = () =>
page.locator('.wordmark path[fill="#00FFFF"]').evaluate((el) => getComputedStyle(el).fill);
assert.equal(await letterColor(), 'rgb(245, 245, 245)');
assert.equal(await dotColor(), 'rgb(0, 255, 255)');
await page.getByRole('button', { name: '더보기', exact: true }).click();
await page.getByRole('menuitem', { name: '라이트 테마로 전환', exact: true }).click();
assert.equal(await letterColor(), 'rgb(23, 23, 23)');
assert.equal(await dotColor(), 'rgb(0, 255, 255)');
await page.screenshot({ path: 'tests/e2e/brand-light.png' });
await page.getByRole('button', { name: '더보기', exact: true }).click();
await page.getByRole('menuitem', { name: '다크 테마로 전환', exact: true }).click();
assert.equal(
await page
.locator('.header-actions > .theme-toggle,.header-actions > .pwa-install-button')
.count(),
0,
);
const more = page.getByRole('button', { name: '더보기', exact: true });
await more.click();
await page.getByRole('menu', { name: '더보기', exact: true }).waitFor();
await page.keyboard.press('End');
assert.equal(await page.evaluate(() => document.activeElement.textContent.trim()), '서비스 정보');
await page.keyboard.press('ArrowUp');
assert.match(await page.evaluate(() => document.activeElement.textContent.trim()), /테마/);
await page.keyboard.press('Escape');
assert.equal(await more.getAttribute('aria-expanded'), 'false');
await more.click();
await page.getByRole('menuitem', { name: '서비스 정보', exact: true }).click();
await page.getByRole('dialog', { name: '서비스 정보', exact: true }).waitFor();
const clipIds = await page
.locator('.brand-logo clipPath')
.evaluateAll((nodes) => nodes.map((node) => node.id));
assert.equal(
new Set(clipIds).size,
clipIds.length,
'동시에 표시한 로고의 clipPath ID가 겹치지 않음',
);
assert.equal(await more.getAttribute('aria-expanded'), 'false');
await page.screenshot({ path: 'tests/e2e/service-info-desktop.png' });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: 'tests/e2e/service-info-mobile.png' });
await page.setViewportSize({ width: 1280, height: 1000 });
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
assert.equal(await page.getByText('어디로 보낼까요?', { exact: true }).count(), 0);
assert.equal(await page.locator('.devices-stage .spin,.section-heading h2 span').count(), 0);
await page.screenshot({ path: 'tests/e2e/main-empty-device.png', fullPage: true });
let releaseSnapshot;
const gate = new Promise((resolve) => {
releaseSnapshot = resolve;
});
let holdSnapshot = true;
await page.route('**/api/device/snapshot', async (route) => {
if (holdSnapshot) await gate;
await route.continue();
});
const modelSession = runtime.service.createGuest('Android');
const modelSocket = io(url, {
transports: ['websocket'],
auth: {
csrfToken: runtime.service.csrf(modelSession.token),
deviceInfo: { model: 'SM-S938B', platform: 'Android', manufacturer: 'untrusted' },
},
extraHeaders: { Origin: url, Cookie: `hop_guest=${modelSession.token}` },
});
sockets.push(modelSocket);
await page.locator('.peer-skeleton').waitFor();
assert.equal(
await page.locator('button.peer-card').count(),
0,
'정보 수신 전 클릭 가능한 기기가 없음',
);
await page.waitForTimeout(260); // Capture the settled layout after the single arrival transition.
await page.screenshot({ path: 'tests/e2e/main-device-skeleton.png', fullPage: true });
holdSnapshot = false;
releaseSnapshot();
await page.getByRole('button', { name: /SM-S938B/ }).waitFor();
assert.equal(runtime.service.presence.info(modelSession.guest.id).manufacturer, 'Samsung');
await page.locator('.peer-skeleton').waitFor({ state: 'hidden' });
runtime.service.patchDevice(modelSession.guest, { name: '내 Galaxy' });
await page.getByRole('button', { name: /내 Galaxy/ }).waitFor();
modelSocket.disconnect();
await page.locator('.device-leave-active').waitFor();
await page.locator('.peer-entry').waitFor({ state: 'hidden' });
assert.equal(runtime.service.presence.info(modelSession.guest.id), undefined);
await page.unroute('**/api/device/snapshot');
for (let index = 0; index < 12; index++) {
const session = runtime.service.createGuest();
runtime.service.patchDevice(session.guest, { name: `기기 ${index + 1}` });
const socket = io(url, {
transports: ['websocket'],
auth: { csrfToken: runtime.service.csrf(session.token) },
extraHeaders: { Origin: url, Cookie: `hop_guest=${session.token}` },
});
sockets.push(socket);
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
});
}
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
assert.equal(await page.locator('.workspace-grid,.send-panel,.send-dialog').count(), 0);
assert.equal(await page.locator('.desktop-nav,.mobile-nav,.connection-status').count(), 0);
for (const [width, columns] of [
[1280, 4],
[800, 3],
[390, 2],
]) {
await page.setViewportSize({ width, height: 900 });
assert.equal(
await page
.locator('.peer-grid')
.evaluate((node) => getComputedStyle(node).gridTemplateColumns.split(' ').length),
columns,
);
const grid = await page.locator('.peer-grid').boundingBox(),
mine = await page.locator('.device-footer').boundingBox();
assert(mine.y >= grid.y + grid.height, '내 기기는 푸터');
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
await page.screenshot({ path: `tests/e2e/layout-${width}.png`, fullPage: true });
}
await page.evaluate(() => {
window.__clipboardNeedsPaste = true;
});
const readsBeforeSelection = await page.evaluate(() => window.__layoutReads ?? 0);
await page.locator('.peer-card').first().click();
await page.getByLabel('보낼 텍스트', { exact: true }).waitFor();
assert.equal(
await page.evaluate(() => window.__layoutReads ?? 0),
readsBeforeSelection,
'명시적 확인이 필요한 환경에서는 카드 선택만으로 클립보드를 읽지 않음',
);
assert.equal(
await page
.locator('.peer-card')
.first()
.evaluate((el) => getComputedStyle(el).userSelect),
'none',
);
await page.getByRole('button', { name: '다시 가져오기', exact: true }).click();
await page.waitForFunction(
() => document.querySelector('.current-url')?.value === 'https://example.com/layout',
);
assert.equal(
await page.evaluate(() => window.__layoutReads),
readsBeforeSelection + 1,
'사용자가 붙여넣기를 선택하면 클립보드를 읽음',
);
await page.evaluate(() => {
window.__clipboardNeedsPaste = false;
});
const sheet = page.getByRole('dialog', { name: /에 보내기/ });
await sheet.waitFor();
const bounds = await sheet.boundingBox();
assert(Math.abs(bounds.y + bounds.height - 900) < 2, '모바일 하단 시트');
assert.equal(await page.locator('.content-submit').count(), 1);
assert.equal(
await page.getByRole('button', { name: '보관함에서 가져오기', exact: true }).count(),
0,
);
const chooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: '대신 파일 선택하기', exact: true }).click();
await (
await chooser
).setFiles({
name: 'replacement.txt',
mimeType: 'text/plain',
buffer: Buffer.from('replace the URL'),
});
await page.locator('.selected-files').filter({ hasText: 'replacement.txt' }).waitFor();
assert.equal(await page.getByLabel('보낼 URL').count(), 0, '파일이 링크를 대체');
assert.equal(await page.locator('.content-submit').count(), 1);
const fileBounds = await sheet.boundingBox();
assert.equal(fileBounds.height, bounds.height);
assert.equal(fileBounds.width, bounds.width);
await page.evaluate(() => {
window.__layoutClipboard = '';
});
await page.getByRole('button', { name: '대신 클립보드에서 가져오기', exact: true }).click();
await page.getByLabel('보낼 텍스트').waitFor();
assert.equal(await page.locator('.selected-files').count(), 0);
assert.equal(await page.locator('.content-submit').isDisabled(), true);
await page.locator('.current-url, .current-text').fill('https://example.com/manual');
assert.equal(await page.locator('.content-submit').isEnabled(), true);
const linkBounds = await sheet.boundingBox();
assert.equal(linkBounds.height, bounds.height);
assert.equal(linkBounds.width, bounds.width);
await page.screenshot({ path: 'tests/e2e/send-sheet.png' });
await page.getByRole('button', { name: '전송 화면 닫기' }).click();
await page.locator('.footer-device-summary').click();
const settings = page.getByRole('dialog', { name: '내 기기 설정', exact: true });
assert.equal(
await page
.getByLabel('기기 이름', { exact: true })
.evaluate((el) => document.activeElement === el),
false,
'모바일 설정에서 키보드를 자동으로 열지 않음',
);
assert.equal(
await page
.getByLabel('기기 이름', { exact: true })
.evaluate((el) => getComputedStyle(el).fontSize),
'16px',
);
const mobileInput = await page.getByLabel('기기 이름', { exact: true }).boundingBox();
const mobileSave = await settings
.getByRole('button', { name: '저장', exact: true })
.boundingBox();
assert.equal(mobileInput.height, mobileSave.height);
assert.equal(
await settings.getByRole('switch', { name: '다른 기기에서 발견 허용' }).count(),
0,
'발견 허용 설정을 표시하지 않음',
);
const settingsBounds = await settings.boundingBox();
assert(Math.abs(settingsBounds.y + settingsBounds.height - 900) < 2, '모바일 설정 하단 시트');
await page.getByLabel('기기 이름', { exact: true }).focus();
await page.evaluate(() => {
Object.defineProperty(visualViewport, 'height', { configurable: true, value: 480 });
visualViewport.dispatchEvent(new Event('resize'));
});
await page.waitForFunction(
() => document.documentElement.style.getPropertyValue('--hop-visible-height') === '480px',
);
const keyboardBounds = await settings.boundingBox();
assert(
keyboardBounds.y >= 0 && Math.abs(keyboardBounds.y + keyboardBounds.height - 480) < 2,
'키보드가 열린 가시 영역 안의 설정 시트',
);
await page.screenshot({ path: 'tests/e2e/device-settings-mobile-keyboard.png' });
await page.evaluate(() => {
delete visualViewport.height;
visualViewport.dispatchEvent(new Event('resize'));
});
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
await page.getByRole('button', { name: '로그인', exact: false }).first().click();
for (const input of [
page.getByLabel('이메일', { exact: true }),
page.getByLabel('비밀번호', { exact: true }),
]) {
assert.equal(await input.evaluate((el) => getComputedStyle(el).fontSize), '16px');
await input.focus();
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
}
await page.screenshot({ path: 'tests/e2e/login-mobile.png' });
await page.route('**/api/auth/login', (route) => route.abort('failed'));
await page.getByLabel('이메일', { exact: true }).fill('offline@hop.test');
await page.getByLabel('비밀번호', { exact: true }).fill('password-1234');
await page.locator('.account-auth').getByRole('button', { name: '로그인', exact: true }).click();
const networkToast = page.locator('.error-toast');
await networkToast.filter({ hasText: '서버에 연결하지 못했어요' }).waitFor();
const toastSpacing = await networkToast.evaluate((el) => {
const box = el.getBoundingClientRect(),
text = el.querySelector('span').getBoundingClientRect(),
icon = el.querySelector('button svg').getBoundingClientRect();
return { left: text.left - box.left, right: box.right - icon.right };
});
assert(Math.abs(toastSpacing.left - toastSpacing.right) < 1, '모바일 토스트 양쪽 여백');
const toastClose = await networkToast.getByRole('button', { name: '오류 닫기' }).boundingBox();
assert(toastClose.width >= 44 && toastClose.height >= 44, '닫기 버튼 터치 영역 유지');
await page.screenshot({ path: 'tests/e2e/error-toast-mobile.png' });
await networkToast.getByRole('button', { name: '오류 닫기' }).click();
await networkToast.waitFor({ state: 'hidden' });
await page.unroute('**/api/auth/login');
await more.click();
const menuBounds = await page.getByRole('menu', { name: '더보기', exact: true }).boundingBox();
assert(
menuBounds.x >= 0 && menuBounds.x + menuBounds.width <= 390,
'모바일 더보기 메뉴 가로 넘침 없음',
);
await page.screenshot({ path: 'tests/e2e/header-more-mobile.png' });
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'hop. 홈', exact: true }).click();
await page.setViewportSize({ width: 1280, height: 1000 });
await page.locator('.peer-card').first().click();
const dialog = await page.locator('.send-dialog').boundingBox();
assert(
Math.abs(dialog.x + dialog.width / 2 - 640) < 2 &&
Math.abs(dialog.y + dialog.height / 2 - 500) < 2,
'PC 중앙 모달',
);
await page.screenshot({ path: 'tests/e2e/send-dialog.png' });
await page.keyboard.press('Escape');
await sheet.waitFor({ state: 'hidden' });
await page.locator('.footer-device-summary').click();
const deviceModal = page.getByRole('dialog', { name: '내 기기 설정', exact: true });
const titleBounds = await deviceModal.locator('header').boundingBox();
const formBounds = await deviceModal.locator('.device-settings-form').boundingBox();
assert(
formBounds.y >= titleBounds.y + titleBounds.height + 12,
'제목과 기기 이름 폼이 겹치지 않음',
);
const desktopInput = await page.getByLabel('기기 이름', { exact: true }).boundingBox();
const desktopSave = await deviceModal
.getByRole('button', { name: '저장', exact: true })
.boundingBox();
assert.equal(desktopInput.height, desktopSave.height);
assert(Math.abs(desktopInput.y - desktopSave.y) < 1, 'PC 입력창과 버튼이 같은 위치에 정렬됨');
await page.screenshot({ path: 'tests/e2e/device-settings-desktop.png' });
await page.getByLabel('기기 이름', { exact: true }).fill('나의 테스트 기기');
assert.equal(await page.locator('.name-count').innerText(), '9/40');
await page.getByRole('button', { name: '저장', exact: true }).click();
await page.locator('.footer-device-name').filter({ hasText: '나의 테스트 기기' }).waitFor();
const oldSession = await page.evaluate(async () => {
const session = await (
await fetch('/api/guest/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
).json();
const response = await fetch('/api/device/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': session.csrfToken },
body: JSON.stringify({ discoverable: false }),
});
return { deviceId: session.device.id, ok: response.ok };
});
assert.equal(oldSession.ok, true);
await page.waitForFunction(
() =>
!document
.querySelector('.footer-device-summary .device-state-dot')
?.classList.contains('enabled'),
);
await page.reload();
await page.waitForFunction(() =>
document
.querySelector('.footer-device-summary .device-state-dot')
?.classList.contains('enabled'),
);
assert.doesNotMatch(
await page.locator('.footer-device-summary').innerText(),
/발견 허용|발견 꺼짐/,
);
assert.equal(await page.locator('.footer-device-name').innerText(), '나의 테스트 기기');
assert.equal(
await page.evaluate(async () => (await (await fetch('/api/device/snapshot')).json()).device.id),
oldSession.deviceId,
);
await page.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
assert.equal(await page.locator('.connection-choice').count(), 2);
assert.equal(await page.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).count(), 0);
assert.equal(await page.getByLabel('연결 코드', { exact: true }).count(), 0);
await page.screenshot({ path: 'tests/e2e/connection-choose-desktop.png' });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '연결 공간 참여', exact: true }).click();
const joinInput = page.getByLabel('연결 코드', { exact: true });
assert.equal(await joinInput.getAttribute('placeholder'), '초대 코드 4자리');
assert.equal(await joinInput.evaluate((el) => getComputedStyle(el).fontSize), '16px');
await joinInput.fill('ABC');
assert.equal(
await page.getByRole('button', { name: '참여하기', exact: true }).isDisabled(),
true,
);
await joinInput.fill('ABCD');
assert.equal(await page.getByRole('button', { name: '참여하기', exact: true }).isEnabled(), true);
await page.screenshot({ path: 'tests/e2e/connection-join-mobile.png' });
await page.getByRole('button', { name: '연결 방법 선택으로 돌아가기', exact: true }).click();
await page.getByRole('button', { name: '연결 공간 만들기', exact: true }).click();
await page.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).waitFor();
assert.equal(await page.getByLabel('연결 코드', { exact: true }).count(), 0);
await page.screenshot({ path: 'tests/e2e/connection-create-mobile.png' });
await page.getByRole('button', { name: '만들기', exact: true }).click();
assert.match(await page.locator('.invite-code').innerText(), /^[A-HJ-NP-Z2-9]{4}$/);
assert.equal(await page.locator('.local-qr-note,.invite-display .spin').count(), 0);
await page.screenshot({ path: 'tests/e2e/connection-invite-mobile.png' });
const code = (await page.locator('.invite-code').innerText()).replace(/\s/g, '');
const cookie = sockets[1].io.opts.extraHeaders.Cookie;
const token = cookie.split('=')[1];
const response = await fetch(`${url}/api/spaces/join`, {
method: 'POST',
headers: {
Origin: url,
Cookie: cookie,
'X-CSRF-Token': runtime.service.csrf(token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
assert.equal(response.status, 200);
await page.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 1);
await page.screenshot({ path: 'tests/e2e/main-single-device.png', fullPage: true });
assert.equal(await page.getByRole('button', { name: '다른 기기 연결', exact: true }).count(), 0);
await page.getByRole('button', { name: '방 나가기', exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
assert.deepEqual(errors, []);
console.log(
'PASS: 실제 12대 자동 발견, 4/3/2열과 줄바꿈, 푸터의 기기명 설정·발견 옵션 숨김·이전 비공개 세션의 재접속 후 발견 복구, 선택 전 전송 UI 없음, 고정 크기 PC 모달·모바일 하단 시트, 하나의 전송 버튼과 파일/링크 대체, Escape, QR 참여 시 모달 자동 닫힘, 방 안의 기기 격리·나가기 후 주변 복귀.',
);
} finally {
for (const socket of sockets) socket.disconnect();
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}