Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
116
tests/e2e/device-menu.mjs
Normal file
116
tests/e2e/device-menu.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
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: 'QR · 코드로 연결', exact: true }).click();
|
||||
await host.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).check();
|
||||
await host.getByRole('button', { name: '연결 공간 만들기' }).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: 'QR · 코드로 연결', 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.getByRole('dialog', { name: 'QR · 코드로 연결' }).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('.stage-heading').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 });
|
||||
}
|
||||
180
tests/e2e/layout.mjs
Normal file
180
tests/e2e/layout.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from '@playwright/test';
|
||||
import { io } from 'socket.io-client';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createApp } from '../../apps/server/src/app.ts';
|
||||
import { loadConfig } from '../../apps/server/src/config.ts';
|
||||
const folder = await mkdtemp(resolve(tmpdir(), 'hop-layout-'));
|
||||
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'),
|
||||
});
|
||||
const sockets = [];
|
||||
let browser;
|
||||
try {
|
||||
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
|
||||
runtime.setDevOrigin(url);
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.HOP_BROWSER_EXECUTABLE
|
||||
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
|
||||
: {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
|
||||
const page = await context.newPage(),
|
||||
errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: async () => window.__layoutClipboard ?? 'https://example.com/layout',
|
||||
writeText: async () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto(url);
|
||||
await page.locator('.app-shell[data-online="true"]').waitFor();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
const session = runtime.service.createGuest();
|
||||
runtime.service.patchDevice(session.guest, { name: `기기 ${index + 1}` });
|
||||
const socket = io(url, {
|
||||
transports: ['websocket'],
|
||||
auth: { csrfToken: runtime.service.csrf(session.token) },
|
||||
extraHeaders: { Origin: url, Cookie: `hop_guest=${session.token}` },
|
||||
});
|
||||
sockets.push(socket);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('connect_error', reject);
|
||||
});
|
||||
}
|
||||
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
|
||||
assert.equal(await page.locator('.workspace-grid,.send-panel,.send-dialog').count(), 0);
|
||||
assert.equal(await page.locator('.desktop-nav,.mobile-nav,.connection-status').count(), 0);
|
||||
for (const [width, columns] of [
|
||||
[1280, 4],
|
||||
[800, 3],
|
||||
[390, 2],
|
||||
]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('.peer-grid')
|
||||
.evaluate((node) => getComputedStyle(node).gridTemplateColumns.split(' ').length),
|
||||
columns,
|
||||
);
|
||||
const grid = await page.locator('.peer-grid').boundingBox(),
|
||||
mine = await page.locator('.device-footer').boundingBox();
|
||||
assert(mine.y >= grid.y + grid.height, '내 기기는 푸터');
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
await page.screenshot({ path: `tests/e2e/layout-${width}.png`, fullPage: true });
|
||||
}
|
||||
await page.locator('.peer-card').first().click();
|
||||
const sheet = page.getByRole('dialog', { name: /에 보내기/ });
|
||||
await sheet.waitFor();
|
||||
const bounds = await sheet.boundingBox();
|
||||
assert(Math.abs(bounds.y + bounds.height - 900) < 2, '모바일 하단 시트');
|
||||
assert.equal(await page.locator('.content-submit').count(), 1);
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: '보관함에서 가져오기', exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: '대신 파일 선택하기', exact: true }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: 'replacement.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('replace the URL'),
|
||||
});
|
||||
await page.locator('.selected-files').filter({ hasText: 'replacement.txt' }).waitFor();
|
||||
assert.equal(await page.getByLabel('보낼 URL').count(), 0, '파일이 링크를 대체');
|
||||
assert.equal(await page.locator('.content-submit').count(), 1);
|
||||
const fileBounds = await sheet.boundingBox();
|
||||
assert.equal(fileBounds.height, bounds.height);
|
||||
assert.equal(fileBounds.width, bounds.width);
|
||||
await page.evaluate(() => {
|
||||
window.__layoutClipboard = '';
|
||||
});
|
||||
await page.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
|
||||
await page.getByLabel('보낼 URL').waitFor();
|
||||
assert.equal(await page.locator('.selected-files').count(), 0);
|
||||
assert.equal(await page.locator('.content-submit').isDisabled(), true);
|
||||
await page.getByLabel('보낼 URL').fill('https://example.com/manual');
|
||||
assert.equal(await page.locator('.content-submit').isEnabled(), true);
|
||||
const linkBounds = await sheet.boundingBox();
|
||||
assert.equal(linkBounds.height, bounds.height);
|
||||
assert.equal(linkBounds.width, bounds.width);
|
||||
await page.screenshot({ path: 'tests/e2e/send-sheet.png' });
|
||||
await page.getByRole('button', { name: '전송 화면 닫기' }).click();
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await page.locator('.peer-card').first().click();
|
||||
const dialog = await page.locator('.send-dialog').boundingBox();
|
||||
assert(
|
||||
Math.abs(dialog.x + dialog.width / 2 - 640) < 2 &&
|
||||
Math.abs(dialog.y + dialog.height / 2 - 500) < 2,
|
||||
'PC 중앙 모달',
|
||||
);
|
||||
await page.screenshot({ path: 'tests/e2e/send-dialog.png' });
|
||||
await page.keyboard.press('Escape');
|
||||
await sheet.waitFor({ state: 'hidden' });
|
||||
await page.locator('.footer-device-summary').click();
|
||||
await page.getByLabel('기기 이름', { exact: true }).fill('나의 테스트 기기');
|
||||
await page.getByRole('button', { name: '이름 저장', exact: true }).click();
|
||||
await page.locator('.footer-device-name').filter({ hasText: '나의 테스트 기기' }).waitFor();
|
||||
await page.locator('.footer-device-summary').click();
|
||||
await page.getByLabel('다른 기기에서 발견 허용').uncheck();
|
||||
await page.locator('.footer-device-summary').filter({ hasText: '발견 꺼짐' }).waitFor();
|
||||
await page.getByLabel('다른 기기에서 발견 허용').check();
|
||||
await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor();
|
||||
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
|
||||
await page.getByRole('button', { name: '연결 공간 만들기' }).click();
|
||||
const code = (await page.locator('.invite-code').innerText()).replace(/\s/g, '');
|
||||
const cookie = sockets[0].io.opts.extraHeaders.Cookie;
|
||||
const token = cookie.split('=')[1];
|
||||
const response = await fetch(`${url}/api/spaces/join`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Origin: url,
|
||||
Cookie: cookie,
|
||||
'X-CSRF-Token': runtime.service.csrf(token),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
await page.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 1);
|
||||
await page.screenshot({ path: 'tests/e2e/main-single-device.png', fullPage: true });
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole('button', { name: '방 나가기', exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: 실제 12대 자동 발견, 4/3/2열과 줄바꿈, 푸터의 내 기기 상태·이름/발견 설정, 선택 전 전송 UI 없음, 고정 크기 PC 모달·모바일 하단 시트, 하나의 전송 버튼과 파일/링크 대체, Escape, QR 참여 시 모달 자동 닫힘, 방 안의 기기 격리·나가기 후 주변 복귀.',
|
||||
);
|
||||
} finally {
|
||||
for (const socket of sockets) socket.disconnect();
|
||||
await browser?.close();
|
||||
await runtime.app.close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
409
tests/e2e/phase1.mjs
Normal file
409
tests/e2e/phase1.mjs
Normal file
@@ -0,0 +1,409 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createApp } from '../../apps/server/src/app.ts';
|
||||
import { loadConfig } from '../../apps/server/src/config.ts';
|
||||
const folder = await mkdtemp(resolve(tmpdir(), 'hop-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',
|
||||
PAIR_REQUESTS_PER_MINUTE: '5',
|
||||
GUEST_REQUESTS_PER_MINUTE: '10',
|
||||
IP_REQUESTS_PER_MINUTE: '40',
|
||||
SESSION_SECRET: 's'.repeat(40),
|
||||
DISCOVERY_SECRET: 'd'.repeat(40),
|
||||
}),
|
||||
webRoot: resolve('apps/web/dist'),
|
||||
});
|
||||
let browser;
|
||||
const requestCounts = {};
|
||||
try {
|
||||
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const { port } = runtime.app.server.address();
|
||||
const 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 a = await browser.newContext({ viewport: { width: 1440, height: 1050 } }),
|
||||
b = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
await a.addInitScript(() => {
|
||||
window.__hopClipboardReads = 0;
|
||||
window.__hopClipboard = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: async () => {
|
||||
window.__hopClipboardReads++;
|
||||
if (window.__hopClipboardDenied) throw new DOMException('Denied', 'NotAllowedError');
|
||||
return window.__hopClipboard;
|
||||
},
|
||||
writeText: async () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
await a.addInitScript(() => {
|
||||
if (!localStorage.getItem('hop-theme-version')) localStorage.setItem('hop-theme', 'system');
|
||||
});
|
||||
await b.route('https://example.com/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><title>Received link</title><h1>수신한 링크 열기 검증</h1>',
|
||||
}),
|
||||
);
|
||||
const sender = await a.newPage(),
|
||||
receiver = await b.newPage();
|
||||
const errors = [];
|
||||
for (const page of [sender, receiver])
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.startsWith('/api')) requestCounts[path] = (requestCounts[path] ?? 0) + 1;
|
||||
});
|
||||
sender.on('pageerror', (e) => errors.push(e.message));
|
||||
receiver.on('pageerror', (e) => errors.push(e.message));
|
||||
await Promise.all([sender.goto(url), receiver.goto(url)]);
|
||||
await sender.locator('.device-name').waitFor();
|
||||
await receiver.locator('.device-name').waitFor();
|
||||
await sender.locator('.app-shell[data-online="true"]').waitFor();
|
||||
await receiver.locator('.app-shell[data-online="true"]').waitFor();
|
||||
await sender.locator('.device-name').click();
|
||||
await sender.getByRole('textbox', { name: '기기 이름' }).fill('보내는 Mac');
|
||||
await sender.getByRole('button', { name: '이름 저장' }).click();
|
||||
await sender.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor();
|
||||
await sender.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
|
||||
await sender.getByRole('button', { name: '연결 공간 만들기' }).click();
|
||||
await sender.locator('.invite-code').waitFor();
|
||||
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
|
||||
await receiver.getByRole('button', { name: 'QR · 코드로 연결', 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 sender.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
|
||||
await receiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
|
||||
await sender.locator('.peer-card').first().waitFor();
|
||||
await sender.locator('.peer-card').first().click();
|
||||
const secret = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
|
||||
await sender.waitForFunction(
|
||||
(value) => document.querySelector('.current-url')?.value === value,
|
||||
secret,
|
||||
);
|
||||
assert.equal(await sender.evaluate(() => window.__hopClipboardReads), 1);
|
||||
await sender.evaluate(() => {
|
||||
window.__hopClipboard = 'https://example.com/focus-refresh';
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
});
|
||||
await sender.waitForFunction(
|
||||
(value) => document.querySelector('.current-url')?.value === value,
|
||||
'https://example.com/focus-refresh',
|
||||
);
|
||||
await sender.evaluate(() => {
|
||||
window.__hopClipboard = 'https://example.com/reselect-refresh';
|
||||
});
|
||||
await sender.getByRole('button', { name: '전송 화면 닫기' }).click();
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.waitForFunction(
|
||||
(value) => document.querySelector('.current-url')?.value === value,
|
||||
'https://example.com/reselect-refresh',
|
||||
);
|
||||
await sender.evaluate(() => {
|
||||
window.__hopClipboard = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
|
||||
});
|
||||
await sender.getByRole('button', { name: '클립보드 가져오기', exact: true }).click();
|
||||
await sender.waitForFunction(
|
||||
(value) => document.querySelector('.current-url')?.value === value,
|
||||
secret,
|
||||
);
|
||||
assert.equal(await sender.locator('.desktop-nav, .mobile-nav, .connection-status').count(), 0);
|
||||
assert.equal(await sender.getByRole('button', { name: '설정', exact: true }).count(), 0);
|
||||
assert.equal(await sender.locator('.input-tabs').count(), 0);
|
||||
assert.equal(await sender.getByRole('button', { name: '파일 선택', exact: true }).count(), 0);
|
||||
assert.equal(await sender.locator('.content-submit').count(), 1);
|
||||
const { requests: requestTable } = await import('../../apps/server/src/db/schema.ts');
|
||||
assert.equal(
|
||||
runtime.service.db.select().from(requestTable).all().length,
|
||||
0,
|
||||
'클립보드 읽기만으로 요청을 보내면 안 됨',
|
||||
);
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await receiver.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
|
||||
const cardBox = await receiver
|
||||
.locator('.incoming-card')
|
||||
.filter({ hasText: '수락' })
|
||||
.boundingBox();
|
||||
assert(
|
||||
cardBox && cardBox.y >= 0 && cardBox.y + cardBox.height <= 844,
|
||||
'스크롤 뒤 수신 요청이 뷰포트 안에 있어야 함',
|
||||
);
|
||||
assert.equal(
|
||||
await receiver
|
||||
.locator('.consent-card')
|
||||
.evaluate((node) => getComputedStyle(node).backgroundColor),
|
||||
'rgb(255, 255, 255)',
|
||||
);
|
||||
assert(Math.abs(cardBox.y + cardBox.height / 2 - 844 / 2) < 2, '수락 팝업은 화면 중앙');
|
||||
await receiver.screenshot({ path: 'tests/e2e/consent-mobile.png', fullPage: true });
|
||||
await sender.getByText('상대 기기의 수락을 기다립니다.', { exact: true }).waitFor();
|
||||
let releaseSnapshot, notifyCaptured;
|
||||
const captured = new Promise((resolve) => {
|
||||
notifyCaptured = resolve;
|
||||
});
|
||||
const held = new Promise((resolve) => {
|
||||
releaseSnapshot = resolve;
|
||||
});
|
||||
let heldOnce = false;
|
||||
await sender.route('**/api/device/snapshot', async (route) => {
|
||||
if (heldOnce || route.request().method() !== 'GET') return route.continue();
|
||||
heldOnce = true;
|
||||
const response = await route.fetch();
|
||||
const oldSnapshot = await response.json();
|
||||
assert(oldSnapshot.requests.some((r) => r.status === 'pending'));
|
||||
notifyCaptured();
|
||||
await held;
|
||||
await route.fulfill({ response, json: oldSnapshot });
|
||||
});
|
||||
await sender.evaluate(() => window.dispatchEvent(new Event('focus')));
|
||||
await Promise.race([
|
||||
captured,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(Error('지연 조회가 시작되지 않았습니다.')), 10000),
|
||||
),
|
||||
]);
|
||||
|
||||
assert(!(await receiver.locator('body').innerText()).includes(secret));
|
||||
const openedAutomatically = receiver.waitForEvent('popup');
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
releaseSnapshot();
|
||||
await sender
|
||||
.getByRole('button', { name: '전송 취소', exact: false })
|
||||
.waitFor({ state: 'hidden' });
|
||||
const external = await openedAutomatically;
|
||||
await external.waitForURL(secret);
|
||||
assert.equal(await external.evaluate(() => window.opener), null);
|
||||
await external.close();
|
||||
assert.equal(await receiver.locator('.arrival-card').count(), 0);
|
||||
assert.equal(await receiver.locator('.item-card').count(), 0);
|
||||
assert.equal(await receiver.getByRole('button', { name: /이번 기기/ }).count(), 0);
|
||||
assert.equal(await receiver.evaluate(() => window.__hopXss), undefined);
|
||||
const received = await receiver.evaluate(
|
||||
async () => (await (await fetch('/api/device/items')).json()).items,
|
||||
);
|
||||
assert(received.find((item) => item.url === secret)?.read, '자동 열기 후 읽음 표시');
|
||||
const cancelCountBefore = runtime.service.db.select().from(requestTable).all().length;
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await sender.locator('.link-send').filter({ hasText: '전송 취소' }).waitFor();
|
||||
await sender.locator('.link-send').filter({ hasText: '전송 취소' }).click();
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor({ state: 'hidden' });
|
||||
assert.equal(runtime.service.db.select().from(requestTable).all().length, cancelCountBefore + 1);
|
||||
assert.equal(runtime.service.db.select().from(requestTable).all().at(-1).status, 'cancelled');
|
||||
await sender.reload();
|
||||
await sender.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor();
|
||||
await sender.evaluate(() => {
|
||||
window.__hopClipboardDenied = true;
|
||||
});
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.getByRole('button', { name: '파일 선택', exact: true }).waitFor();
|
||||
assert.equal(await sender.locator('.clipboard-link-card').count(), 0);
|
||||
const fileBytes = Buffer.from('브라우저 파일 전달 검증');
|
||||
const dragged = await sender.evaluateHandle(() => {
|
||||
const data = new DataTransfer();
|
||||
data.items.add(new File(['브라우저 파일 전달 검증'], 'browser.txt', { type: 'text/plain' }));
|
||||
return data;
|
||||
});
|
||||
await sender.locator('.unified-composer').dispatchEvent('drop', { dataTransfer: dragged });
|
||||
await dragged.dispose();
|
||||
await sender.locator('.selected-files').getByText('browser.txt', { exact: true }).waitFor();
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
|
||||
const downloaded = receiver.waitForEvent('download');
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
const download = await downloaded;
|
||||
assert.equal(download.suggestedFilename(), 'browser.txt');
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
assert.deepEqual(await readFile(await download.path()), fileBytes);
|
||||
assert.equal(await receiver.locator('.arrival-card, .item-card').count(), 0);
|
||||
assert.equal(await sender.locator('.active-transfers, .upload-status').count(), 0);
|
||||
let uploadStarted, releaseUpload;
|
||||
const uploadCaptured = new Promise((resolve) => {
|
||||
uploadStarted = resolve;
|
||||
});
|
||||
const uploadHeld = new Promise((resolve) => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
await sender.route('**/api/uploads?*', async (route) => {
|
||||
uploadStarted();
|
||||
await uploadHeld;
|
||||
try {
|
||||
await route.abort('aborted');
|
||||
} catch {}
|
||||
});
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender
|
||||
.locator('input[type=file]')
|
||||
.setInputFiles({ name: 'browser.txt', mimeType: 'text/plain', buffer: fileBytes });
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
await Promise.race([
|
||||
uploadCaptured,
|
||||
new Promise((_, reject) => setTimeout(() => reject(Error('업로드 시작 대기 실패')), 10000)),
|
||||
]);
|
||||
await sender.locator('.send-button').filter({ hasText: '전송 취소' }).click();
|
||||
releaseUpload();
|
||||
await receiver.locator('.receiving-card').waitFor({ state: 'hidden' });
|
||||
await sender.locator('.send-button').filter({ hasText: '전송하기' }).waitFor();
|
||||
await sender.unroute('**/api/uploads?*');
|
||||
assert.equal(
|
||||
await sender.getByRole('alert').count(),
|
||||
0,
|
||||
'사용자 취소를 업로드 오류로 표시하지 않음',
|
||||
);
|
||||
await sender.getByRole('button', { name: '전송 화면 닫기' }).click();
|
||||
assert.equal(
|
||||
await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor),
|
||||
'rgb(0, 0, 0)',
|
||||
);
|
||||
await sender.screenshot({ path: 'tests/e2e/desktop.png', fullPage: true });
|
||||
await sender.getByRole('button', { name: '라이트 테마로 전환', exact: true }).click();
|
||||
assert.equal(
|
||||
await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor),
|
||||
'rgb(250, 250, 250)',
|
||||
);
|
||||
await sender.screenshot({ path: 'tests/e2e/light-desktop.png', fullPage: true });
|
||||
await sender.getByRole('button', { name: '다크 테마로 전환', exact: true }).click();
|
||||
await receiver.screenshot({ path: 'tests/e2e/mobile.png', fullPage: true });
|
||||
assert.equal(
|
||||
await receiver.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
true,
|
||||
'모바일 가로 넘침',
|
||||
);
|
||||
const second = await a.newPage();
|
||||
await second.goto(url);
|
||||
await second.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor();
|
||||
await sender.close();
|
||||
await receiver.getByRole('button', { name: 'hop. 홈', exact: true }).click();
|
||||
await receiver.locator('.peer-card').filter({ hasText: '보내는 Mac' }).waitFor();
|
||||
const c = await browser.newContext({ viewport: { width: 1366, height: 900 } }),
|
||||
urlReceiver = await c.newPage();
|
||||
urlReceiver.on('pageerror', (error) => errors.push(error.message));
|
||||
await urlReceiver.goto(url);
|
||||
await urlReceiver.locator('.device-name').waitFor();
|
||||
const receiverCode = (await urlReceiver.locator('.footer-device-code').innerText()).trim();
|
||||
await urlReceiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
|
||||
await urlReceiver.getByRole('textbox', { name: '연결 코드' }).fill(code);
|
||||
await urlReceiver.getByRole('button', { name: '참여', exact: true }).click();
|
||||
await urlReceiver.locator('.room-leave').waitFor();
|
||||
await urlReceiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
|
||||
await second.locator('.peer-card').filter({ hasText: receiverCode }).waitFor();
|
||||
await second.evaluate(() => {
|
||||
window.__hopClipboard = '일반 텍스트는 전송하지 않습니다.';
|
||||
});
|
||||
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
|
||||
await second.getByRole('button', { name: '파일 선택', exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await second.locator('.clipboard-link-card').count(),
|
||||
0,
|
||||
'일반 텍스트를 링크로 제안하면 안 됨',
|
||||
);
|
||||
await second.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
|
||||
const urls = ['https://example.com/path?x=1', 'http://192.168.1.10/private'];
|
||||
await second.getByRole('textbox', { name: '보낼 URL' }).fill(urls.join('\n'));
|
||||
await second.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await urlReceiver.getByRole('button', { name: '수락', exact: true }).waitFor();
|
||||
assert(!(await urlReceiver.locator('body').innerText()).includes(urls[0]));
|
||||
await c.route('https://example.com/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'text/html', body: '<h1>수신 링크</h1>' }),
|
||||
);
|
||||
await c.route('http://192.168.1.10/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'text/html', body: '<h1>수신 링크 2</h1>' }),
|
||||
);
|
||||
const opened = [];
|
||||
urlReceiver.on('popup', (page) => opened.push(page));
|
||||
await urlReceiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
await second
|
||||
.getByRole('button', { name: '전송 취소', exact: false })
|
||||
.waitFor({ state: 'hidden' });
|
||||
assert.equal(opened.length, 2);
|
||||
await Promise.all(
|
||||
opened.map((page) => page.waitForURL((address) => urls.includes(address.href))),
|
||||
);
|
||||
for (const address of urls) {
|
||||
const page = opened.find((page) => page.url() === address);
|
||||
assert(page, `자동으로 열린 링크: ${address}`);
|
||||
assert.equal(await page.evaluate(() => window.opener), null);
|
||||
await page.close();
|
||||
}
|
||||
await urlReceiver.evaluate(() => {
|
||||
window.__nativeOpen = window.open;
|
||||
window.open = () => null;
|
||||
});
|
||||
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
|
||||
await second.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
|
||||
await second.getByRole('textbox', { name: '보낼 URL' }).fill('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();
|
||||
assert.equal(c.pages().length, 1);
|
||||
await urlReceiver.evaluate(() => {
|
||||
window.open = window.__nativeOpen;
|
||||
});
|
||||
const manuallyOpened = urlReceiver.waitForEvent('popup');
|
||||
await urlReceiver
|
||||
.locator('.delivery-blocked')
|
||||
.getByRole('link', { name: /링크 열기/ })
|
||||
.click();
|
||||
const fallbackPage = await manuallyOpened;
|
||||
await fallbackPage.waitForURL('https://example.com/blocked');
|
||||
await fallbackPage.close();
|
||||
await urlReceiver.locator('.delivery-blocked').waitFor({ state: 'hidden' });
|
||||
await urlReceiver.reload();
|
||||
await urlReceiver.locator('.device-name').waitFor();
|
||||
assert.equal(c.pages().length, 1, '재조회나 새로고침으로 재실행하지 않음');
|
||||
assert.equal(await second.getByRole('alert').count(), 0, '정상 전송이 요청 제한에 걸리면 안 됨');
|
||||
const oldDevice = await second.locator('.device-name').innerText();
|
||||
const renewed = second.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === '/api/guest/session' &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 200,
|
||||
);
|
||||
runtime.service.cfg.sessionSecret = 'rotated-test-secret-'.repeat(3);
|
||||
for (const socket of runtime.io.sockets.sockets.values()) socket.conn.close();
|
||||
await renewed;
|
||||
await second.locator('.app-shell[data-online="true"]').waitFor();
|
||||
assert.equal(await second.locator('.device-name').innerText(), oldDevice);
|
||||
await second.locator('.device-name').click();
|
||||
await second.getByRole('textbox', { name: '기기 이름' }).fill('재연결된 Mac');
|
||||
await second.getByRole('button', { name: '이름 저장' }).click();
|
||||
await second.locator('.device-name').filter({ hasText: '재연결된 Mac' }).waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: 두 브라우저 QR·코드 연결, 수락 전 본문 비공개, 클립보드 URL·복수 URL 전달과 일반 텍스트 무시, 수락 후 자동 새 탭 URL 열기, opener 격리, 읽음 동기화, 이름 유지, 다중 탭·CSRF 재연결 복구, 390px 가로 넘침 없음, 실제 파일 업로드·자동 다운로드, 수락 중 지연된 과거 조회에도 전달 완료, 스크롤 뒤 중앙의 고대비 수락 팝업, 검정/라이트 전환, 버튼 자리의 전송 취소·익명 내역 숨김·새로고침 중복 실행 방지.',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(requestCounts);
|
||||
const { batches } = await import('../../apps/server/src/db/schema.ts');
|
||||
console.error(runtime.service.db.select({ status: batches.status }).from(batches).all());
|
||||
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 });
|
||||
}
|
||||
233
tests/e2e/phase3.mjs
Normal file
233
tests/e2e/phase3.mjs
Normal file
@@ -0,0 +1,233 @@
|
||||
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: '<!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();
|
||||
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: 'QR · 코드로 연결', exact: true }).click();
|
||||
await sender.getByRole('button', { name: '연결 공간 만들기' }).click();
|
||||
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
|
||||
await sender.getByRole('button', { name: '대화상자 닫기' }).click();
|
||||
await receiver.getByRole('button', { name: 'QR · 코드로 연결', 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.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
|
||||
await sender.getByLabel('보낼 URL').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.getByLabel('보낼 URL').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 === '/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 });
|
||||
}
|
||||
Reference in New Issue
Block a user