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-account-e2e-')), mails = []; 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: '10', GUEST_REQUESTS_PER_MINUTE: '30', IP_REQUESTS_PER_MINUTE: '60', REGISTRATION_MODE: 'open', SESSION_SECRET: 's'.repeat(40), DISCOVERY_SECRET: 'd'.repeat(40), }), webRoot: resolve('apps/web/dist'), }, { mailer: async (mail) => { mails.push(mail); }, }, ); 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); runtime.service.cfg.appUrl = url; browser = await chromium.launch({ headless: true, ...(process.env.HOP_BROWSER_EXECUTABLE ? { executablePath: process.env.HOP_BROWSER_EXECUTABLE } : {}), }); const a = await browser.newContext({ viewport: { width: 1440, height: 1000 } }), b = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, }); await b.route('https://example.com/**', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: 'Account URL

계정 수신 링크

', }), ); const sender = await a.newPage(), receiver = await b.newPage(); const errors = []; for (const page of [sender, receiver]) page.on('pageerror', (e) => errors.push(e.message)); async function register(page, email, name) { await page.goto(url); await page.locator('.device-name').waitFor(); const deviceName = await page.locator('.footer-device-name').innerText(); await page.locator('.login-button').click(); await page.getByRole('button', { name: '이메일로 가입', exact: true }).click(); const auth = page.locator('.account-auth'); await auth.getByLabel('이메일', { exact: true }).fill(email); await auth.getByLabel('표시 이름', { exact: true }).fill(name); await auth.getByLabel('비밀번호', { exact: true }).fill('1234'); await auth.getByLabel('비밀번호 확인', { exact: true }).fill('1234'); await auth.getByRole('button', { name: '가입하고 인증 메일 받기' }).click(); await auth.locator('.auth-message').waitFor(); const token = mails.find((m) => m.to === email).text.match(/#verify=([A-Za-z0-9_-]+)/)[1]; await page.goto(`${url}/#verify=${token}`); await page.getByRole('button', { name: '이메일 인증 완료하기' }).click(); await auth.getByRole('button', { name: '로그인', exact: true }).waitFor(); await auth.getByLabel('이메일', { exact: true }).fill(email); await auth.getByLabel('비밀번호', { exact: true }).fill('1234'); await auth.getByRole('button', { name: '로그인', exact: true }).click(); await page.locator('.account-workspace').waitFor(); return await page.locator('.footer-device-name').innerText(); } const senderName = await register(sender, 'alice@example.com', 'Alice'); await register(receiver, 'bob@example.com', 'Bob'); async function navigate(page, view) { if (view === '기기') return page.getByRole('button', { name: 'hop. 홈', exact: true }).click(); await page.locator('.login-button').click(); await page .getByRole('tab', { name: view === '받은 항목' ? '받은 항목' : '임시 저장', exact: true }) .click(); } await navigate(sender, '기기'); await navigate(receiver, '기기'); assert.equal(await sender.locator('.hero').count(), 0); await sender.getByRole('button', { name: '다른 기기 연결', exact: true }).click(); await sender.getByRole('button', { name: '연결 공간 만들기' }).click(); await sender.getByRole('button', { name: '만들기', exact: true }).click(); const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, ''); await sender.getByRole('button', { name: '대화상자 닫기' }).click(); 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 receiver.locator('.modal-backdrop').waitFor({ state: 'hidden' }); await sender.locator('.peer-card').first().click(); await sender.locator('.current-url, .current-text').fill('https://example.com/received'); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); const automaticallyOpened = receiver.waitForEvent('popup'); await receiver.getByRole('button', { name: '수락', exact: true }).click(); const receivedPage = await automaticallyOpened; await receivedPage.waitForURL('https://example.com/received'); await receivedPage.close(); await navigate(receiver, '받은 항목'); await receiver .locator('.item-body') .filter({ hasText: 'https://example.com/received' }) .waitFor(); assert.match(await receiver.locator('.dated-items h2').innerText(), /년.*월.*일 받은 항목/); assert.equal(await receiver.getByRole('button', { name: /이번 기기/ }).count(), 0); await navigate(sender, '보관함'); await sender.getByRole('button', { name: '보관함에 담기', exact: true }).click(); await sender .getByRole('dialog', { name: '보관함에 담기' }) .getByRole('button', { name: '클립보드 저장', exact: true }) .click(); assert.equal( await sender.getByRole('dialog', { name: '보관함에 담기' }).locator('input[type=file]').count(), 0, ); await sender.getByLabel('한 줄 메모', { exact: true }).fill('나중에 읽을 글'); await sender.locator('.current-url, .current-text').fill('https://example.com/saved'); await sender.getByRole('button', { name: '저장하기', exact: true }).click(); await sender.locator('.item-card').filter({ hasText: '나중에 읽을 글' }).waitFor(); await sender.getByRole('button', { name: '메모 수정', exact: true }).click(); await sender.getByLabel('한 줄 메모 수정').fill('업무 링크'); await sender.locator('.note-editor').getByRole('button', { name: '저장', exact: true }).click(); await sender.locator('.item-note').filter({ hasText: '업무 링크' }).waitFor(); await navigate(sender, '기기'); assert.equal(await sender.locator('.footer-device-name').innerText(), senderName); await sender.locator('.peer-card').first().click(); const beforeSource = await sender.locator('.send-dialog').boundingBox(); const requestsBeforeSelection = await sender.evaluate( async () => (await (await fetch('/api/transfer-requests')).json()).requests.length, ); await sender.getByRole('button', { name: '보관함에서 가져오기', exact: true }).click(); await sender.locator('.send-dialog .vault-choice').filter({ hasText: '업무 링크' }).click(); const afterSource = await sender.locator('.send-dialog').boundingBox(); assert.equal(afterSource.height, beforeSource.height); assert.equal(afterSource.width, beforeSource.width); assert.equal(await sender.locator('.content-submit').count(), 1); assert.equal( await sender.evaluate( async () => (await (await fetch('/api/transfer-requests')).json()).requests.length, ), requestsBeforeSelection, '보관함 선택만으로 전송하지 않음', ); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); const storedLinkOpened = receiver.waitForEvent('popup'); await receiver.getByRole('button', { name: '수락', exact: true }).click(); const storedLinkPage = await storedLinkOpened; await storedLinkPage.waitForURL('https://example.com/saved'); await storedLinkPage.close(); await receiver.locator('.item-body').filter({ hasText: 'https://example.com/saved' }).waitFor(); await navigate(sender, '보관함'); await sender.getByRole('button', { name: '보관함에 담기', exact: true }).click(); await sender .getByRole('dialog', { name: '보관함에 담기' }) .getByRole('button', { name: '파일 저장', exact: true }) .click(); assert.equal( await sender.getByRole('dialog', { name: '보관함에 담기' }).getByLabel('보낼 URL').count(), 0, ); await sender.getByLabel('한 줄 메모', { exact: true }).fill('업무 자료'); const bytes = Buffer.from('브라우저 보관함 파일'); await sender .locator('input[type=file]') .setInputFiles({ name: 'saved.txt', mimeType: 'text/plain', buffer: bytes }); await sender.getByRole('button', { name: '저장하기', exact: true }).click(); await sender.locator('.item-card').filter({ hasText: 'saved.txt' }).waitFor(); const uploads = []; sender.on('request', (request) => { if (new URL(request.url()).pathname.startsWith('/api/uploads')) uploads.push(request.url()); }); await navigate(sender, '기기'); await sender.locator('.peer-card').first().click(); await sender.getByRole('button', { name: '보관함에서 가져오기', exact: true }).click(); await sender.locator('.send-dialog .vault-choice').filter({ hasText: '업무 자료' }).click(); await sender.getByRole('button', { name: '전송하기', exact: true }).click(); const downloaded = receiver.waitForEvent('download'); void downloaded.catch(() => {}); await receiver.getByRole('button', { name: '수락', exact: true }).click(); const download = await downloaded; assert.equal(download.suggestedFilename(), 'saved.txt'); const { readFile } = await import('node:fs/promises'); assert.deepEqual(await readFile(await download.path()), bytes); assert.equal(uploads.length, 0, '저장한 파일을 브라우저에서 재업로드하지 않음'); await receiver.locator('.item-card').filter({ hasText: 'saved.txt' }).waitFor(); await navigate(sender, '보관함'); const fileRow = sender.locator('.item-card').filter({ hasText: 'saved.txt' }); await fileRow.getByRole('button', { name: '계정 목록에서 삭제' }).click(); await fileRow.waitFor({ state: 'hidden' }); await sender.getByRole('button', { name: '실행 취소', exact: true }).click(); await fileRow.waitFor(); await sender.screenshot({ path: 'tests/e2e/account-desktop.png', fullPage: true }); await receiver.screenshot({ path: 'tests/e2e/account-mobile.png', fullPage: true }); assert.equal( await receiver.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, ); const saved = await sender.evaluate(async () => (await (await fetch('/api/vault')).json()).items); assert(saved.every((item) => item.expiresAt - item.createdAt <= 7 * 86400000)); await receiver.getByRole('button', { name: '로그아웃', exact: true }).click(); await receiver.locator('.account-auth').waitFor(); assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved')); assert.deepEqual(errors, []); console.log( 'PASS: 1234 가입·인증·로그인, 로그인 기기 수신의 날짜별 기록, 직접 링크/파일 저장과 메모 수정, 보관함 자료의 수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료, 삭제/복원, 로그아웃 권한 격리, 모바일 화면. 실제 Resend 발송은 포함하지 않음.', ); } 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 }); }