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({ 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); await page.getByRole('switch', { name: '다른 기기에서 발견 허용' }).waitFor(); assert.equal( await page .locator('.discovery-setting') .evaluate((el) => getComputedStyle(el).borderBottomWidth), '0px', ); 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 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(); await page.locator('.footer-device-summary').click(); await page.getByLabel('다른 기기에서 발견 허용').uncheck(); await page.locator('.footer-device-summary').filter({ hasText: '발견 꺼짐' }).waitFor(); await page.getByLabel('다른 기기에서 발견 허용').check(); await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor(); await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click(); 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 }); }