395 lines
19 KiB
JavaScript
395 lines
19 KiB
JavaScript
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: '<!doctype html><title>Account URL</title><h1>계정 수신 링크</h1>',
|
|
}),
|
|
);
|
|
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();
|
|
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(),
|
|
/^보관 만료 · .+까지$/,
|
|
);
|
|
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 (const key of await readdir(runtime.service.storage.originals)) {
|
|
const path = resolve(runtime.service.storage.originals, key);
|
|
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('.account-auth').waitFor();
|
|
assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved'));
|
|
assert.deepEqual(errors, []);
|
|
console.log(
|
|
'PASS: 받은 항목 기본 화면·보관 안내, PC/모바일 계정 설정·입력·키보드 탐색, 가입·인증·로그인, 날짜별 기록, 직접 링크/파일 저장·메모·수락 후 자동 열기/다운로드, 파일 재업로드 없음, 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 });
|
|
}
|