216 lines
9.8 KiB
JavaScript
216 lines
9.8 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-clipboard-'));
|
|
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 },
|
|
permissions: ['clipboard-read', 'clipboard-write'],
|
|
});
|
|
const b = await browser.newContext({
|
|
locale: 'ko-KR',
|
|
viewport: { width: 390, height: 844 },
|
|
permissions: ['clipboard-read', 'clipboard-write'],
|
|
});
|
|
for (const context of [a, b])
|
|
await context.addInitScript(() => {
|
|
window.__clipboard = '';
|
|
window.__copied = null;
|
|
window.__readCalls = 0;
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
readText: async () => {
|
|
window.__readCalls++;
|
|
if (window.__denied) throw new DOMException('Denied', 'NotAllowedError');
|
|
return window.__clipboard;
|
|
},
|
|
writeText: async (value) => {
|
|
window.__copied = value;
|
|
},
|
|
},
|
|
});
|
|
});
|
|
await b.route('https://example.com/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>Received link</title>' }),
|
|
);
|
|
await b.route('https://www.example.com/**', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/html',
|
|
body: '<title>Received bare link</title>',
|
|
}),
|
|
);
|
|
const sender = await a.newPage(),
|
|
receiver = await b.newPage(),
|
|
errors = [];
|
|
for (const page of [sender, receiver])
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
await Promise.all([sender.goto(url), receiver.goto(url)]);
|
|
await sender.locator('.peer-card').first().waitFor();
|
|
await receiver.locator('.peer-card').first().waitFor();
|
|
const select = async (value) => {
|
|
await sender.evaluate((value) => {
|
|
window.__clipboard = value;
|
|
}, value);
|
|
await sender.locator('.peer-card').first().click();
|
|
await expect(sender.locator('.content-submit')).toBeEnabled();
|
|
assert.equal(await sender.locator('.content-submit').count(), 1);
|
|
};
|
|
for (const [value, address] of [
|
|
['https://example.com/article', 'https://example.com/article'],
|
|
['www.example.com', 'https://www.example.com/'],
|
|
]) {
|
|
await select(value);
|
|
await expect(sender.getByLabel('보낼 URL', { exact: true })).toHaveValue(value);
|
|
assert.equal(await sender.getByLabel('보낼 URL').evaluate((el) => el.tagName), 'INPUT');
|
|
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
|
const opened = receiver.waitForEvent('popup');
|
|
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
|
const tab = await opened;
|
|
await tab.waitForURL(address);
|
|
assert.equal(await tab.evaluate(() => window.opener), null);
|
|
await tab.close();
|
|
await receiver.getByRole('button', { name: '주소 복사', exact: true }).click();
|
|
assert.equal(await receiver.evaluate(() => window.__copied), address);
|
|
await receiver.getByRole('button', { name: '링크 수신 안내 닫기', exact: true }).click();
|
|
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
|
|
}
|
|
const values = [
|
|
'일반 문장으로 보내는 첫 번째 텍스트입니다.',
|
|
' 한글 日本語 😀👨👩👧👦\r\n\t두 번째 줄 \n마지막 줄\n',
|
|
'이 글에는 https://example.com/article 주소가 포함되어 있어요.',
|
|
'https://one.test/\nhttps://two.test/',
|
|
'장문을 여러 줄로 전달합니다. 日本語 😀\n'.repeat(500),
|
|
'<script>window.__clipboardXss = true</script>\n<div>HTML도 원문입니다.</div>',
|
|
];
|
|
for (const value of values) {
|
|
await select(value);
|
|
const input = sender.getByLabel('보낼 텍스트', { exact: true });
|
|
assert.equal(await input.evaluate((el) => el.tagName), 'TEXTAREA');
|
|
assert.equal(await sender.locator('.clipboard-count').count(), 1);
|
|
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
|
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
|
|
assert.equal(
|
|
await receiver.locator('.received-text-dialog').count(),
|
|
0,
|
|
'수락 전 본문을 표시하지 않음',
|
|
);
|
|
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
|
await receiver.getByRole('dialog', { name: '받은 텍스트', exact: true }).waitFor();
|
|
assert.equal(await receiver.locator('.received-text-preview').textContent(), value);
|
|
assert.equal(await receiver.evaluate(() => window.__clipboardXss), undefined);
|
|
assert.equal(b.pages().length, 1, '텍스트는 새 탭을 열지 않음');
|
|
await receiver.getByRole('button', { name: '클립보드에 복사', exact: true }).click();
|
|
assert.equal(await receiver.evaluate(() => window.__copied), value);
|
|
await receiver.getByText('클립보드에 복사했어요.', { exact: true }).waitFor();
|
|
if (value.length > 1000) {
|
|
assert.equal(
|
|
await receiver
|
|
.locator('.received-text-preview')
|
|
.evaluate((el) => el.scrollHeight > el.clientHeight),
|
|
true,
|
|
);
|
|
await receiver.screenshot({ path: 'tests/e2e/clipboard-receive-mobile.png' });
|
|
}
|
|
await receiver.getByRole('button', { name: '받은 텍스트 닫기', exact: true }).click();
|
|
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
|
|
}
|
|
// Editing survives focus and clipboard changes; only explicit refresh replaces it.
|
|
await select('initial clipboard');
|
|
const input = sender.getByLabel('보낼 텍스트', { exact: true });
|
|
await input.fill('내가 작성 중인 내용');
|
|
await sender.evaluate(() => {
|
|
window.__clipboard = 'new clipboard';
|
|
window.dispatchEvent(new Event('focus'));
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
});
|
|
await expect(input).toHaveValue('내가 작성 중인 내용');
|
|
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
|
|
await expect(input).toHaveValue('new clipboard');
|
|
await sender.evaluate(() => {
|
|
window.__clipboard = '';
|
|
});
|
|
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
|
|
await expect(input).toHaveValue('new clipboard');
|
|
await sender.evaluate(() => {
|
|
window.__denied = true;
|
|
});
|
|
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
|
|
await expect(input).toHaveValue('new clipboard');
|
|
// URL/textarea replacement preserves focus and accepts an explicit newline.
|
|
await input.focus();
|
|
await sender.keyboard.press('ControlOrMeta+A');
|
|
await sender.keyboard.type('https://example.com/manual');
|
|
await expect(sender.getByLabel('보낼 URL')).toHaveValue('https://example.com/manual');
|
|
await sender.keyboard.press('Enter');
|
|
await sender.keyboard.type('내용을 덧붙입니다.');
|
|
await expect(sender.getByLabel('보낼 텍스트')).toHaveValue(
|
|
'https://example.com/manual\n내용을 덧붙입니다.',
|
|
);
|
|
await sender.getByLabel('보낼 텍스트').fill('x'.repeat(256 * 1024 + 1));
|
|
await expect(sender.locator('.content-submit')).toBeDisabled();
|
|
await sender.getByLabel('보낼 텍스트').fill('');
|
|
await expect(sender.locator('.content-submit')).toBeDisabled();
|
|
assert.equal(
|
|
await sender.locator('.clipboard-pane').evaluate((el) => el.scrollTop),
|
|
0,
|
|
'입력창 포커스가 전송 영역 제목을 밀어내지 않음',
|
|
);
|
|
await sender.screenshot({ path: 'tests/e2e/clipboard-empty-desktop.png' });
|
|
await sender.getByLabel('보낼 텍스트').fill('권한이 없어도 직접 입력한 텍스트');
|
|
await expect(sender.locator('.content-submit')).toBeEnabled();
|
|
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
|
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
|
await receiver.getByRole('dialog', { name: '받은 텍스트' }).waitFor();
|
|
assert.equal(
|
|
await receiver.locator('.received-text-preview').textContent(),
|
|
'권한이 없어도 직접 입력한 텍스트',
|
|
);
|
|
await receiver.getByRole('button', { name: '받은 텍스트 닫기' }).click();
|
|
await receiver.reload();
|
|
await receiver.locator('.peer-card').first().waitFor();
|
|
assert.equal(
|
|
await receiver.locator('.received-text-dialog').count(),
|
|
0,
|
|
'익명 재접속에 과거 텍스트를 다시 표시하지 않음',
|
|
);
|
|
assert.deepEqual(errors, []);
|
|
console.log(
|
|
'PASS: 단일 URL·www 주소, 일반 문장·여러 줄·한글/일본어/이모지·URL 포함 문장·여러 URL, 수락 전 비공개, 수신 원문 미리보기·스크롤·복사, HTML 이스케이프, 명시적 새로 읽기와 수정 보존, 빈/거절된 클립보드의 직접 입력, URL/텍스트 편집 전환, 256KiB 제한, 익명 재조회 재실행 없음.',
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await runtime.app.close();
|
|
await rm(folder, { recursive: true, force: true });
|
|
}
|