import assert from 'node:assert/strict'; import { chromium } from '@playwright/test'; import { mkdtemp, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { loadConfig } from '../../apps/server/src/config.ts'; import { createApp } from '../../apps/server/src/app.ts'; const folder = await mkdtemp(resolve(tmpdir(), 'hop-devices-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', 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 { port } = runtime.app.server.address(), 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 hostContext = await browser.newContext({ viewport: { width: 1440, height: 1050 } }), guestContext = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, }); const host = await hostContext.newPage(), guest = await guestContext.newPage(), errors = []; for (const page of [host, guest]) page.on('pageerror', (error) => errors.push(error.message)); await Promise.all([host.goto(url), guest.goto(url)]); async function rename(page, name) { await page.locator('.device-name').click(); await page.getByRole('textbox', { name: '기기 이름' }).fill(name); await page.getByRole('button', { name: '이름 저장' }).click(); await page.locator('.device-name').filter({ hasText: name }).waitFor(); } await rename(host, '방장 Mac'); await rename(guest, '외부 기기'); await host.getByRole('button', { name: '다른 기기 연결', exact: true }).click(); await host.getByRole('button', { name: '연결 공간 만들기' }).click(); await host.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).check(); await host.getByRole('button', { name: '만들기', exact: true }).click(); await host.locator('.invite-code').waitFor(); const code = (await host.locator('.invite-code').innerText()).replace(/\s/g, ''); await host.getByRole('button', { name: '대화상자 닫기' }).click(); await host.locator('.my-device-info .host-badge').waitFor(); await guest.getByRole('button', { name: '다른 기기 연결', exact: true }).click(); await guest.getByRole('button', { name: '연결 공간 참여', exact: true }).click(); await guest.getByRole('textbox', { name: '연결 코드' }).fill(code); await guest.getByRole('button', { name: '참여하기', exact: true }).click(); await guest.locator('.room-leave').waitFor(); await guest.locator('.modal-backdrop').waitFor({ state: 'hidden' }); const guestCard = host.locator('.peer-entry').filter({ hasText: '외부 기기' }); await guestCard.getByText('승인 대기', { exact: true }).waitFor(); assert.equal(await guestCard.locator('.peer-card').isDisabled(), true); await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click(); await guestCard.getByRole('menuitem', { name: '참여 승인', exact: true }).click(); await guestCard.getByText('온라인', { exact: true }).waitFor(); assert.equal(await guestCard.locator('.peer-card').isEnabled(), true); const hostCard = guest.locator('.peer-entry').filter({ hasText: '방장 Mac' }); await hostCard.locator('.host-badge').waitFor(); assert.equal(await guest.getByRole('button', { name: '방장 Mac 기기 더보기' }).count(), 0); for (const page of [host, guest]) { assert.equal(await page.locator('.spaces-list,.space-member').count(), 0); assert(!(await page.locator('body').innerText()).includes('참여 중')); } await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click(); const kick = guestCard.getByRole('menuitem', { name: '내보내기', exact: true }); await kick.waitFor(); const rgb = await kick.evaluate((node) => getComputedStyle(node).color); const components = rgb.match(/\d+/g).map(Number); assert(components[0] > components[1] && components[0] > components[2]); await host.keyboard.press('Escape'); assert.equal(await host.locator('.device-menu').count(), 0); await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click(); await host.locator('.section-heading h2').click(); assert.equal(await host.locator('.device-menu').count(), 0); await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click(); await host.screenshot({ path: 'tests/e2e/device-menu-desktop.png', fullPage: true }); await guest.screenshot({ path: 'tests/e2e/device-menu-mobile.png', fullPage: true }); await guestCard.getByRole('menuitem', { name: '내보내기', exact: true }).click(); await guestCard.waitFor({ state: 'hidden' }); await hostCard.waitFor({ state: 'hidden' }); assert.equal(await guest.locator('.room-leave').count(), 0); await host.getByRole('button', { name: '방 나가기', exact: true }).click(); await host.locator('.room-leave').waitFor({ state: 'hidden' }); await host.locator('.my-device-info .host-badge').waitFor({ state: 'hidden' }); assert.equal( await guest.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, ); assert.deepEqual(errors, []); console.log( 'PASS: HOST badge on current/peer devices; approval and red kick action in main card menu; non-host has no kick menu; Escape/outside close; no duplicate participant list; kick propagates to both browsers; space close and mobile viewport.', ); } catch (error) { console.error(error); 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 }); }