249 lines
11 KiB
JavaScript
249 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { chromium, expect } from '@playwright/test';
|
|
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-receive-controls-'));
|
|
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),
|
|
PAIR_REQUESTS_PER_MINUTE: '100',
|
|
GUEST_REQUESTS_PER_MINUTE: '100',
|
|
IP_REQUESTS_PER_MINUTE: '200',
|
|
}),
|
|
webRoot: resolve('apps/web/dist'),
|
|
});
|
|
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 a = await browser.newContext({ locale: 'ko-KR', viewport: { width: 1280, height: 900 } }),
|
|
b = await browser.newContext({ locale: 'ko-KR', viewport: { width: 390, height: 844 } });
|
|
const sender = await a.newPage(),
|
|
receiver = await b.newPage(),
|
|
errors = [];
|
|
sender.setDefaultTimeout(10000);
|
|
receiver.setDefaultTimeout(10000);
|
|
for (const page of [sender, receiver]) page.on('pageerror', (err) => errors.push(err.message));
|
|
await Promise.all([sender.goto(url), receiver.goto(url)]);
|
|
await sender.locator('.peer-card').first().waitFor();
|
|
await receiver.locator('.peer-card').first().waitFor();
|
|
async function api(page, path, method = 'GET', body) {
|
|
return page.evaluate(
|
|
async ({ path, method, body }) => {
|
|
if (!window.__testCsrf) {
|
|
const s = await fetch('/api/guest/session', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '{}',
|
|
});
|
|
window.__testCsrf = (await s.json()).csrfToken;
|
|
}
|
|
const response = await fetch(`/api${path}`, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': window.__testCsrf,
|
|
'Idempotency-Key': crypto.randomUUID(),
|
|
},
|
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
if (!response.ok) throw Error(`${response.status}: ${await response.text()}`);
|
|
return response.json();
|
|
},
|
|
{ path, method, body },
|
|
);
|
|
}
|
|
const recipientId = (await api(receiver, '/device/me')).device.id;
|
|
async function request(kind, size = 10) {
|
|
return (
|
|
await api(sender, '/transfer-requests', 'POST', {
|
|
targetDeviceId: recipientId,
|
|
kind,
|
|
byteSize: size,
|
|
count: 1,
|
|
...(kind === 'file'
|
|
? {
|
|
files: [
|
|
{
|
|
name: 'Screenshot_20261012_152625_Samsung Browser.jpg',
|
|
size,
|
|
type: 'image/jpeg',
|
|
},
|
|
],
|
|
}
|
|
: {}),
|
|
})
|
|
).requestId;
|
|
}
|
|
for (const [kind, size, label] of [
|
|
['text', 10, '텍스트'],
|
|
['url', 20, '링크'],
|
|
['file', 1333196, '파일'],
|
|
['file', 100000, '파일'],
|
|
['file', 1700000000, '파일'],
|
|
]) {
|
|
const id = await request(kind, size);
|
|
const card = receiver.getByRole('dialog', { name: '전송 수락 요청' });
|
|
await expect(card.locator('.consent-kind')).toHaveText(`${label} 전송 요청`);
|
|
assert.equal(await card.locator('.request-note').count(), 0);
|
|
assert.equal(await card.locator('.incoming-icon svg').count(), 1);
|
|
const ratio = await card.evaluate((el) => {
|
|
const a = el.querySelector('.incoming-actions .primary').getBoundingClientRect(),
|
|
b = el.querySelector('.incoming-actions .button:not(.primary)').getBoundingClientRect();
|
|
return a.width / b.width;
|
|
});
|
|
assert(Math.abs(ratio - 2) < 0.02);
|
|
assert.equal(await card.getByText('기기 이름은 신원이 인증된 표시가 아닙니다.').count(), 0);
|
|
if (kind === 'file') {
|
|
await expect(card.locator('.request-file-size')).toHaveText(
|
|
size === 1700000000 ? '1.6GB' : size === 100000 ? '97.7KB' : '1.3MB',
|
|
);
|
|
const spacing = await card.locator('.request-files').evaluate((el) => ({
|
|
padding: getComputedStyle(el).paddingLeft,
|
|
margin: getComputedStyle(el).marginLeft,
|
|
overflow: el.scrollWidth > el.clientWidth,
|
|
}));
|
|
assert.deepEqual(spacing, { padding: '0px', margin: '0px', overflow: false });
|
|
assert(
|
|
!(await card
|
|
.locator('.incoming-info p')
|
|
.textContent()
|
|
.then((v) => /바이트|MB|GB/.test(v))),
|
|
'single file size is shown once',
|
|
);
|
|
if (size === 1333196)
|
|
await receiver.screenshot({ path: 'tests/e2e/receive-file-mobile.png' });
|
|
}
|
|
if (kind === 'text') {
|
|
await receiver.setViewportSize({ width: 1280, height: 900 });
|
|
const desktop = await card.evaluate((el) => {
|
|
const a = el.querySelector('.incoming-actions .primary').getBoundingClientRect(),
|
|
b = el.querySelector('.incoming-actions .button:not(.primary)').getBoundingClientRect();
|
|
return a.width / b.width;
|
|
});
|
|
assert(Math.abs(desktop - 2) < 0.02);
|
|
await receiver.screenshot({ path: 'tests/e2e/receive-text-desktop.png' });
|
|
await receiver.setViewportSize({ width: 390, height: 844 });
|
|
}
|
|
await api(sender, `/transfer-requests/${id}/cancel`, 'POST', {});
|
|
await card.waitFor({ state: 'hidden' });
|
|
}
|
|
await sender.locator('.peer-card').first().click();
|
|
await sender
|
|
.getByLabel('보낼 텍스트', { exact: true })
|
|
.fill('제한이 걸려도 이 초안은 유지됩니다.');
|
|
await sender.route('**/api/transfer-requests', (route) =>
|
|
route.fulfill({
|
|
status: 429,
|
|
contentType: 'application/json',
|
|
headers: { 'Retry-After': '3' },
|
|
body: JSON.stringify({
|
|
code: 'RATE_LIMIT',
|
|
message: '전송 요청이 많아요.',
|
|
retryAfterSeconds: 3,
|
|
limitScope: 'pair',
|
|
}),
|
|
}),
|
|
);
|
|
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
|
const submit = sender.locator('.content-submit');
|
|
await expect(submit).toBeDisabled();
|
|
await expect(submit).toHaveText(/\d+초 후 전송 가능/);
|
|
await expect(sender.getByLabel('보낼 텍스트', { exact: true })).toBeEnabled();
|
|
await expect(sender.getByLabel('보낼 텍스트', { exact: true })).toHaveValue(
|
|
'제한이 걸려도 이 초안은 유지됩니다.',
|
|
);
|
|
await expect(submit).toHaveText('전송하기', { timeout: 5500 });
|
|
await expect(submit).toBeEnabled();
|
|
await sender.unroute('**/api/transfer-requests');
|
|
await sender.getByRole('button', { name: '전송 화면 닫기' }).click();
|
|
await request('text');
|
|
const card = receiver.getByRole('dialog', { name: '전송 수락 요청' });
|
|
await card.getByRole('button', { name: '이 기기 차단', exact: true }).click();
|
|
assert.equal(
|
|
(await api(receiver, '/device/blocks')).blocks.length,
|
|
0,
|
|
'first click does not block',
|
|
);
|
|
await card.locator('.consent-footer').getByRole('button', { name: '취소', exact: true }).click();
|
|
await card.getByRole('button', { name: '이 기기 차단', exact: true }).click();
|
|
await card.getByRole('button', { name: '차단하기', exact: true }).click();
|
|
await card.waitFor({ state: 'hidden' });
|
|
await expect(receiver.locator('.peer-card')).toHaveCount(0);
|
|
await receiver.getByRole('button', { name: '내 기기 설정', exact: true }).click();
|
|
await receiver.getByRole('button', { name: '관리', exact: true }).click();
|
|
const blocked = receiver.getByRole('dialog', { name: '차단한 기기', exact: true });
|
|
await expect(blocked.locator('.blocked-device-list li')).toHaveCount(1);
|
|
await blocked.getByRole('button', { name: '차단 해제', exact: true }).click();
|
|
await expect(blocked.getByText('차단한 기기가 없어요.')).toBeVisible();
|
|
await expect(receiver.locator('.peer-card')).toHaveCount(1);
|
|
assert.equal((await api(receiver, '/device/blocks')).blocks.length, 0);
|
|
await blocked.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
|
|
const recipientName = (await api(receiver, '/device/me')).device.name;
|
|
// Reject the first request before its POST response reaches the sender. This
|
|
// must still close the composer even if it never observed a pending snapshot.
|
|
await sender.route('**/api/transfer-requests', async (route) => {
|
|
const response = await route.fetch();
|
|
assert.equal(response.status(), 200);
|
|
const { requestId } = await response.json();
|
|
await api(receiver, `/transfer-requests/${requestId}/reject`, 'POST', {});
|
|
await route.fulfill({ response });
|
|
});
|
|
for (const kind of ['text', 'file', 'url']) {
|
|
await sender.locator('.peer-card').first().click();
|
|
if (kind === 'file')
|
|
await sender.getByLabel('보낼 파일 선택', { exact: true }).setInputFiles({
|
|
name: 'declined.txt',
|
|
mimeType: 'text/plain',
|
|
buffer: Buffer.from('test'),
|
|
});
|
|
else
|
|
await sender
|
|
.getByLabel('보낼 텍스트', { exact: true })
|
|
.fill(kind === 'url' ? 'https://example.com/declined' : '빠른 거절도 놓치지 않습니다.');
|
|
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
|
if (kind !== 'text') {
|
|
const incoming = receiver.getByRole('dialog', { name: '전송 수락 요청' });
|
|
await incoming.getByRole('button', { name: '거절', exact: true }).click();
|
|
}
|
|
await expect(sender.locator('.send-dialog')).toHaveCount(0);
|
|
await expect(sender.locator('.toast')).toHaveText(`${recipientName}에서 전송을 거절했어요.`);
|
|
if (kind === 'text') await sender.unroute('**/api/transfer-requests');
|
|
}
|
|
// History must not replay rejection notifications after a reload; the fourth
|
|
// request now gets the real pair cooldown and visible remaining seconds.
|
|
await sender.reload();
|
|
await sender.locator('.peer-card').first().waitFor();
|
|
await expect(sender.locator('.toast')).toHaveCount(0);
|
|
await sender.locator('.peer-card').first().click();
|
|
await sender.getByLabel('보낼 텍스트', { exact: true }).fill('세 번 거절 이후의 대기');
|
|
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
|
await expect(sender.locator('.content-submit')).toBeDisabled();
|
|
await expect(sender.locator('.content-submit')).toHaveText(/\d+초 후 전송 가능/);
|
|
await expect(sender.locator('.error-toast')).toContainText('연속 3회 거절');
|
|
assert.deepEqual(errors, []);
|
|
console.log(
|
|
'PASS: receive icons/sizes, desktop/mobile 2:1 controls, retry countdown/draft preservation, block/unblock, first two rejections allow retries, text/file/link rejection closes sender and notifies, decision before HTTP response and reload deduplication, third rejection triggers pair cooldown.',
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await runtime.app.close();
|
|
await rm(folder, { recursive: true, force: true });
|
|
}
|