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({ viewport: { width: 1280, height: 900 }, permissions: ['clipboard-read', 'clipboard-write'], }); const b = await browser.newContext({ 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: 'Received link' }), ); await b.route('https://www.example.com/**', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: 'Received bare link', }), ); 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), '\n
HTML도 원문입니다.
', ]; 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 }); }