import assert from 'node:assert/strict'; import { chromium } from '@playwright/test'; import { mkdtemp, rm, readdir, unlink } 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); assert.equal(await auth.getByLabel('비밀번호', { exact: true }).getAttribute('minlength'), '8'); assert.equal( await auth.getByLabel('비밀번호', { exact: true }).getAttribute('placeholder'), '8자 이상', ); await auth.getByLabel('비밀번호', { exact: true }).fill('1234567'); await auth.getByLabel('비밀번호 확인', { exact: true }).fill('1234567'); const beforeShortPassword = mails.length; await auth.getByRole('button', { name: '가입하고 인증 메일 받기' }).click(); assert.equal( await auth.getByLabel('비밀번호', { exact: true }).evaluate((el) => el.validity.tooShort), true, ); assert.equal(mails.length, beforeShortPassword); await auth.getByLabel('비밀번호', { exact: true }).fill('12345678'); await auth.getByLabel('비밀번호 확인', { exact: true }).fill('12345678'); 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 page.locator('.account-workspace').waitFor(); assert.equal( await page.evaluate(async () => (await (await fetch('/api/auth/me')).json()).account.email), email, '인증한 계정으로 자동 로그인', ); assert.equal(await auth.count(), 0); assert(!(await page.locator('body').innerText()).includes('로그인해 주세요')); assert.deepEqual( (await page.getByRole('tab').allTextContents()).map((text) => text.trim()), ['받은 항목', '임시 저장'], ); assert.equal( await page.getByRole('tab', { name: '받은 항목', exact: true }).getAttribute('aria-selected'), 'true', '보관함 기본 화면은 받은 항목', ); assert.equal(await page.locator('.account-heading p').count(), 0); await page.locator('.scope-note').filter({ hasText: '항목별 최대 7일 보관' }).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'); for (const [page, email, device] of [ [sender, 'alice@example.com', 'desktop'], [receiver, 'bob@example.com', 'mobile'], ]) { await page.getByRole('button', { name: '계정 설정', exact: true }).click(); const preferences = page.getByRole('dialog', { name: '계정 설정', exact: true }); await preferences.waitFor(); await page.keyboard.press('Shift+Tab'); assert.equal( await preferences .getByRole('button', { name: '비밀번호 변경', exact: true }) .evaluate((el) => document.activeElement === el), true, '포커스가 모달 밖으로 나가지 않음', ); await page.keyboard.press('Tab'); assert.equal(await preferences.locator('select,input').count(), 0, '항목 목록을 먼저 표시'); assert.equal(await preferences.locator('.preference-item').count(), 2); assert.equal( await preferences.getByRole('button', { name: '이메일 변경', exact: true }).count(), 0, '이메일 변경 버튼 숨김', ); assert.equal( await preferences.locator('.preference-item').first().locator('p').innerText(), email, ); const viewport = page.viewportSize(); const bounds = await preferences.boundingBox(); assert(bounds.x >= 0 && bounds.x + bounds.width <= viewport.width); if (device === 'mobile') assert( Math.abs(bounds.y + bounds.height - viewport.height) < 2, '계정 설정은 모바일 하단 시트', ); await page.screenshot({ path: `tests/e2e/account-settings-${device}.png` }); await preferences.getByRole('button', { name: '비밀번호 변경', exact: true }).click(); await preferences.getByLabel('현재 비밀번호', { exact: true }).waitFor(); assert.equal( await preferences .getByLabel('현재 비밀번호', { exact: true }) .evaluate((el) => document.activeElement === el), false, '입력창 자동 포커스 없음', ); const headerBounds = await preferences.locator('header').boundingBox(); const formBounds = await preferences.locator('form').boundingBox(); assert(formBounds.y >= headerBounds.y + headerBounds.height + 16, '제목과 변경 폼 간격'); for (const field of ['현재 비밀번호', '새 비밀번호', '새 비밀번호 확인']) { const input = preferences.getByLabel(field, { exact: true }); if (device === 'mobile') assert.equal(await input.evaluate((el) => getComputedStyle(el).fontSize), '16px'); await input.fill('draft1234'); const inputBounds = await input.boundingBox(); assert( inputBounds.x >= bounds.x && inputBounds.x + inputBounds.width <= bounds.x + bounds.width, ); } await page.screenshot({ path: `tests/e2e/account-password-${device}.png` }); await preferences.getByRole('button', { name: '계정 설정 목록으로', exact: true }).click(); await preferences.getByRole('button', { name: '비밀번호 변경', exact: true }).click(); assert.equal( await preferences.getByLabel('현재 비밀번호', { exact: true }).inputValue(), '', '항목 전환 시 비밀번호 초안 제거', ); await preferences.getByLabel('새 비밀번호', { exact: true }).fill('draft1234'); await page.keyboard.press('Escape'); await preferences.waitFor({ state: 'hidden' }); await page.getByRole('button', { name: '계정 설정', exact: true }).click(); await preferences.getByRole('button', { name: '비밀번호 변경', exact: true }).click(); assert.equal( await preferences.getByLabel('새 비밀번호', { exact: true }).inputValue(), '', '모달 닫기 시 개인정보 초안 제거', ); await preferences.getByRole('button', { name: '계정 설정 닫기', exact: true }).click(); assert.equal( await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, ); } 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(); assert.match( await sender.locator('.item-footer > span').first().innerText(), /^\[보관 만료\] \d{4}년 \d{1,2}월 \d{1,2}일 \d{2}:\d{2} 까지$/, ); 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 navigate(receiver, '기기'); await receiver.locator('.peer-card').first().click(); await receiver.getByRole('button', { name: '보관함에서 가져오기', exact: true }).click(); await receiver.locator('.vault-choice').filter({ hasText: 'saved.txt' }).click(); await receiver.locator('.current-vault').waitFor(); assert.equal(await receiver.locator('.content-submit').isEnabled(), true); let removed = false; for await (const { path } of runtime.service.storage.layout.entries()) { if ((await readFile(path)).equals(bytes)) { await unlink(path); removed = true; break; } } assert(removed, '보관함 원본을 서버 저장소에서 직접 삭제'); let unexpectedDownloads = 0; sender.on('download', () => unexpectedDownloads++); await fileRow.getByRole('button', { name: '다운로드', exact: true }).click(); await sender.locator('.error-toast').filter({ hasText: '다시 전송' }).waitFor(); const toastAlignment = await sender.locator('.error-toast').evaluate((element) => { const toast = element.getBoundingClientRect(), text = element.querySelector('span').getBoundingClientRect(), icon = element.querySelector('button svg').getBoundingClientRect(); return { left: text.left - toast.left, right: toast.right - icon.right, textCenter: text.top + text.height / 2, iconCenter: icon.top + icon.height / 2, }; }); assert( Math.abs(toastAlignment.left - toastAlignment.right) < 1, '오류 텍스트와 X 아이콘의 양쪽 여백 균형', ); assert( Math.abs(toastAlignment.textCenter - toastAlignment.iconCenter) < 1, '오류 텍스트와 X 아이콘의 세로 정렬', ); await sender.screenshot({ path: 'tests/e2e/account-missing-file.png', fullPage: true }); assert.equal(unexpectedDownloads, 0, '없는 파일을 다운로드하기 전에 안내'); await fileRow.waitFor({ state: 'hidden' }); await receiver.locator('.current-vault').waitFor({ state: 'hidden' }); await receiver .locator('.vault-choice') .filter({ hasText: 'saved.txt' }) .waitFor({ state: 'hidden' }); assert.equal( await receiver.locator('.content-submit').isDisabled(), true, '다른 기기의 열린 전송 선택도 삭제 알림으로 해제', ); await receiver.getByRole('button', { name: '전송 화면 닫기', exact: true }).click(); await navigate(receiver, '받은 항목'); for (const page of [sender, receiver]) assert.equal( await page.evaluate( async () => (await (await fetch('/api/account/storage')).json()).usedBytes, ), 0, ); await sender.locator('.error-toast').waitFor({ state: 'hidden', timeout: 8000 }); await receiver.getByRole('button', { name: '로그아웃', exact: true }).click(); await receiver.locator('.nearby-section').waitFor(); assert.equal( await receiver.locator('.account-auth').count(), 0, '로그아웃 후 로그인 화면 대신 메인 기기 화면', ); await receiver.locator('.login-button').click(); await receiver.locator('.account-auth').waitFor(); assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved')); const signup = receiver.locator('.account-auth'); await signup.getByRole('button', { name: '이메일로 가입', exact: true }).click(); await signup.getByLabel('이메일', { exact: true }).fill('carol@example.com'); await signup.getByLabel('표시 이름', { exact: true }).fill('Carol'); await signup.getByLabel('비밀번호', { exact: true }).fill('12345678'); await signup.getByLabel('비밀번호 확인', { exact: true }).fill('12345678'); await signup.getByRole('button', { name: '가입하고 인증 메일 받기' }).click(); await signup.locator('.auth-message').waitFor(); const carolToken = mails .find((mail) => mail.to === 'carol@example.com') .text.match(/#verify=([A-Za-z0-9_-]+)/)[1]; const oldAccountCookie = (await a.cookies(url)).find( (cookie) => cookie.name === 'hop_account', ).value; const verificationPage = await a.newPage(); verificationPage.on('pageerror', (error) => errors.push(error.message)); await verificationPage.goto(`${url}/#verify=${carolToken}`); await verificationPage.getByRole('button', { name: '이메일 인증 완료하기' }).waitFor(); await verificationPage.locator('.app-shell[data-online="true"]').waitFor(); await verificationPage.waitForFunction( () => document.querySelector('.account-auth button[type="submit"]')?.disabled === false, ); assert.equal( await verificationPage.evaluate( async () => (await (await fetch('/api/auth/me')).json()).account.email, ), 'alice@example.com', ); let delayedAuthLookup = false; await verificationPage.route('**/api/auth/me', async (route) => { const response = await route.fetch(); if (!delayedAuthLookup) { delayedAuthLookup = true; const stale = { ...(await response.json()), account: null }; await new Promise((resolve) => setTimeout(resolve, 260)); await route.fulfill({ response, json: stale }); } else await route.fulfill({ response }); }); await verificationPage.getByRole('button', { name: '이메일 인증 완료하기' }).click(); await verificationPage.locator('.account-workspace').waitFor(); await verificationPage.unroute('**/api/auth/me'); assert.equal( await verificationPage.evaluate( async () => (await (await fetch('/api/auth/me')).json()).account.email, ), 'carol@example.com', ); assert.equal( await verificationPage .getByRole('tab', { name: '받은 항목', exact: true }) .getAttribute('aria-selected'), 'true', ); assert.equal(await verificationPage.locator('.item-card,.account-auth').count(), 0); assert( !(await verificationPage.locator('body').innerText()).includes('https://example.com/saved'), ); const revokedAccount = await a.request.get(`${url}/api/auth/me`, { headers: { Cookie: `hop_account=${oldAccountCookie}` }, }); assert.equal((await revokedAccount.json()).account, null); const inaccessibleVault = await a.request.get(`${url}/api/vault`, { headers: { Cookie: `hop_account=${oldAccountCookie}` }, }); assert.equal(inaccessibleVault.status(), 401); assert.equal(verificationPage.url(), `${url}/`, '인증 토큰을 주소창에 남기지 않음'); await verificationPage.getByRole('button', { name: '계정 설정', exact: true }).click(); await verificationPage .getByRole('dialog', { name: '계정 설정', exact: true }) .getByText('carol@example.com', { exact: true }) .waitFor(); await verificationPage.screenshot({ path: 'tests/e2e/verification-switch.png' }); await verificationPage.close(); assert.deepEqual(errors, []); console.log( 'PASS: PC/모바일 가입 8자 규칙과 인증 자동 로그인, 다른 계정의 인증 링크에서 세션 교체·이전 쿠키 무효화·보관함 격리·지연된 인증 조회 경합, 받은 항목 기본 화면·계정 설정·날짜별 기록·저장/메모/수락 후 열기/다운로드·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 }); }