Use visible NAS upload storage and reconcile missing files

This commit is contained in:
2026-10-13 15:54:27 +09:00
parent 50e54bc4c5
commit 9e0f493228
26 changed files with 1075 additions and 119 deletions

View File

@@ -339,6 +339,25 @@ try {
);
}
await page.screenshot({ path: 'tests/e2e/login-mobile.png' });
await page.route('**/api/auth/login', (route) => route.abort('failed'));
await page.getByLabel('이메일', { exact: true }).fill('offline@hop.test');
await page.getByLabel('비밀번호', { exact: true }).fill('password-1234');
await page.locator('.account-auth').getByRole('button', { name: '로그인', exact: true }).click();
const networkToast = page.locator('.error-toast');
await networkToast.filter({ hasText: '서버에 연결하지 못했어요' }).waitFor();
const toastSpacing = await networkToast.evaluate((el) => {
const box = el.getBoundingClientRect(),
text = el.querySelector('span').getBoundingClientRect(),
icon = el.querySelector('button svg').getBoundingClientRect();
return { left: text.left - box.left, right: box.right - icon.right };
});
assert(Math.abs(toastSpacing.left - toastSpacing.right) < 1, '모바일 토스트 양쪽 여백');
const toastClose = await networkToast.getByRole('button', { name: '오류 닫기' }).boundingBox();
assert(toastClose.width >= 44 && toastClose.height >= 44, '닫기 버튼 터치 영역 유지');
await page.screenshot({ path: 'tests/e2e/error-toast-mobile.png' });
await networkToast.getByRole('button', { name: '오류 닫기' }).click();
await networkToast.waitFor({ state: 'hidden' });
await page.unroute('**/api/auth/login');
await more.click();
const menuBounds = await page.getByRole('menu', { name: '더보기', exact: true }).boundingBox();
assert(

View File

@@ -384,9 +384,13 @@ try {
window.__nativeOpen = window.open;
window.open = () => null;
});
await second.evaluate(() => {
window.__hopClipboard = 'https://example.com/blocked';
});
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
await second.locator('.current-url, .current-text').fill('https://example.com/blocked');
await second.waitForFunction(
() => document.querySelector('.current-url')?.value === 'https://example.com/blocked',
);
await second.getByRole('button', { name: '전송하기', exact: true }).click();
await urlReceiver.getByRole('button', { name: '수락', exact: true }).click();
await urlReceiver.locator('.delivery-blocked').waitFor();

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { chromium } from '@playwright/test';
import { mkdtemp, rm } from 'node:fs/promises';
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';
@@ -80,6 +80,10 @@ try {
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',
@@ -109,6 +113,11 @@ try {
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,
@@ -146,20 +155,19 @@ try {
}
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();
await preferences.getByRole('button', { name: '비밀번호 변경', exact: true }).click();
assert.equal(
await preferences.getByLabel('현재 비밀번호', { exact: true }).inputValue(),
'',
'항목 전환 시 비밀번호 초안 제거',
);
await preferences.getByLabel('새 이메일', { exact: true }).fill('new@example.com');
await page.screenshot({ path: `tests/e2e/account-email-${device}.png` });
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();
await preferences.getByRole('button', { name: '비밀번호 변경', exact: true }).click();
assert.equal(
await preferences.getByLabel('새 이메일', { exact: true }).inputValue(),
await preferences.getByLabel('새 비밀번호', { exact: true }).inputValue(),
'',
'모달 닫기 시 개인정보 초안 제거',
);
@@ -220,6 +228,10 @@ try {
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();
@@ -300,12 +312,74 @@ try {
);
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/모바일 계정 설정 목록·항목별 폼·간격·16px 입력·초안 제거·키보드 탐색, 가입·인증·로그인, 날짜별 기록, 직접 링크/파일 저장과 메모 수정, 보관함 자료의 수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료, 삭제/복원, 로그아웃 권한 격리. 실제 Resend 발송은 포함하지 않음.',
'PASS: 받은 항목 기본 화면·보관 안내, PC/모바일 계정 설정·입력·키보드 탐색, 가입·인증·로그인, 날짜별 기록, 직접 링크/파일 저장·메모·수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료·삭제/복원·로그아웃 권한 격리, 원본 수동 삭제 시 다운로드 전 안내·양쪽 목록/용량 정리·열린 전송 선택 해제. 실제 Resend 발송은 포함하지 않음.',
);
} catch (error) {
console.error(error);