Add plain text clipboard transfers and refine input feedback
This commit is contained in:
@@ -26,6 +26,8 @@ try {
|
||||
'-e',
|
||||
'APP_URL=https://hop.test',
|
||||
'-e',
|
||||
'PAIR_REQUESTS_PER_MINUTE=4',
|
||||
'-e',
|
||||
`SESSION_SECRET=${randomBytes(32).toString('hex')}`,
|
||||
'-e',
|
||||
`DISCOVERY_SECRET=${randomBytes(32).toString('hex')}`,
|
||||
@@ -47,7 +49,7 @@ try {
|
||||
);
|
||||
const fileState = JSON.parse(
|
||||
node(
|
||||
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
|
||||
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
|
||||
),
|
||||
);
|
||||
docker('restart', name);
|
||||
@@ -59,6 +61,13 @@ try {
|
||||
);
|
||||
assert.equal(result.device.name, 'Docker 저장 검증');
|
||||
assert.equal(result.device.id, state.id);
|
||||
const preservedText = JSON.parse(
|
||||
node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('text list failed');const item=(await r.json()).items.find(i=>i.accessId===${JSON.stringify(fileState.text.accessId)});if(!item)throw Error('text not preserved');console.log(JSON.stringify(item.text));`,
|
||||
),
|
||||
);
|
||||
assert.equal(preservedText, fileState.text.value);
|
||||
|
||||
const restored = node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('download failed');console.log(await r.text());`,
|
||||
);
|
||||
@@ -73,7 +82,7 @@ try {
|
||||
assert.equal(integrity, 'ok');
|
||||
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
|
||||
console.log(
|
||||
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file persistence and partial chunk resume after restart, SQLite integrity, no host ports published.',
|
||||
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
|
||||
|
||||
213
tests/e2e/clipboard.mjs
Normal file
213
tests/e2e/clipboard.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
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: '<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 });
|
||||
}
|
||||
@@ -39,12 +39,46 @@ try {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: async () => window.__layoutClipboard ?? 'https://example.com/layout',
|
||||
readText: async () => {
|
||||
window.__layoutReads = (window.__layoutReads ?? 0) + 1;
|
||||
return window.__layoutClipboard ?? 'https://example.com/layout';
|
||||
},
|
||||
writeText: async () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
let releaseStartup;
|
||||
const startupGate = new Promise((resolve) => {
|
||||
releaseStartup = resolve;
|
||||
});
|
||||
await page.route('**/api/device/snapshot', async (route) => {
|
||||
await startupGate;
|
||||
await route.continue();
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const query = navigator.permissions.query.bind(navigator.permissions);
|
||||
Object.defineProperty(navigator, 'permissions', {
|
||||
configurable: true,
|
||||
value: {
|
||||
query: (descriptor) =>
|
||||
descriptor.name === 'clipboard-read' && window.__clipboardNeedsPaste
|
||||
? Promise.reject(new TypeError('unsupported clipboard permission'))
|
||||
: query(descriptor),
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto(url);
|
||||
await page.locator('.initial-device-loading').waitFor();
|
||||
const loadingCard = await page.locator('.initial-device-loading .peer-card').boundingBox();
|
||||
const loadingText = await page.locator('.initial-device-loading p').boundingBox();
|
||||
assert(
|
||||
loadingText.y - loadingCard.y - loadingCard.height >= 12,
|
||||
'초기 안내 문구와 스켈레톤 사이 간격',
|
||||
);
|
||||
await page.screenshot({ path: 'tests/e2e/initial-loading.png' });
|
||||
releaseStartup();
|
||||
await page.locator('.initial-device-loading').waitFor({ state: 'hidden' });
|
||||
await page.unroute('**/api/device/snapshot');
|
||||
await page.locator('.app-shell[data-online="true"]').waitFor();
|
||||
await page.locator('.wordmark .brand-logo svg').waitFor();
|
||||
const letterColor = () =>
|
||||
@@ -178,7 +212,36 @@ try {
|
||||
);
|
||||
await page.screenshot({ path: `tests/e2e/layout-${width}.png`, fullPage: true });
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.__clipboardNeedsPaste = true;
|
||||
});
|
||||
const readsBeforeSelection = await page.evaluate(() => window.__layoutReads ?? 0);
|
||||
await page.locator('.peer-card').first().click();
|
||||
await page.getByLabel('보낼 텍스트', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__layoutReads ?? 0),
|
||||
readsBeforeSelection,
|
||||
'명시적 확인이 필요한 환경에서는 카드 선택만으로 클립보드를 읽지 않음',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('.peer-card')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).userSelect),
|
||||
'none',
|
||||
);
|
||||
await page.getByRole('button', { name: '다시 가져오기', exact: true }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('.current-url')?.value === 'https://example.com/layout',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__layoutReads),
|
||||
readsBeforeSelection + 1,
|
||||
'사용자가 붙여넣기를 선택하면 클립보드를 읽음',
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__clipboardNeedsPaste = false;
|
||||
});
|
||||
const sheet = page.getByRole('dialog', { name: /에 보내기/ });
|
||||
await sheet.waitFor();
|
||||
const bounds = await sheet.boundingBox();
|
||||
@@ -206,11 +269,11 @@ try {
|
||||
await page.evaluate(() => {
|
||||
window.__layoutClipboard = '';
|
||||
});
|
||||
await page.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
|
||||
await page.getByLabel('보낼 URL').waitFor();
|
||||
await page.getByRole('button', { name: '대신 클립보드에서 가져오기', exact: true }).click();
|
||||
await page.getByLabel('보낼 텍스트').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');
|
||||
await page.locator('.current-url, .current-text').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);
|
||||
@@ -315,6 +378,7 @@ try {
|
||||
assert(Math.abs(desktopInput.y - desktopSave.y) < 1, 'PC 입력창과 버튼이 같은 위치에 정렬됨');
|
||||
await page.screenshot({ path: 'tests/e2e/device-settings-desktop.png' });
|
||||
await page.getByLabel('기기 이름', { exact: true }).fill('나의 테스트 기기');
|
||||
assert.equal(await page.locator('.name-count').innerText(), '9/40');
|
||||
await page.getByRole('button', { name: '저장', exact: true }).click();
|
||||
await page.locator('.footer-device-name').filter({ hasText: '나의 테스트 기기' }).waitFor();
|
||||
await page.locator('.footer-device-summary').click();
|
||||
|
||||
@@ -117,8 +117,8 @@ try {
|
||||
};
|
||||
});
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
|
||||
await sender.getByLabel('보낼 URL').fill('https://example.com/nearby');
|
||||
|
||||
await sender.locator('.current-url, .current-text').fill('https://example.com/nearby');
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
const opened = receiver.waitForEvent('popup');
|
||||
void opened.catch(() => {});
|
||||
|
||||
@@ -107,6 +107,11 @@ try {
|
||||
window.__hopClipboard = 'https://example.com/focus-refresh';
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
});
|
||||
await sender.waitForFunction(
|
||||
(value) => document.querySelector('.current-url')?.value === value,
|
||||
secret,
|
||||
);
|
||||
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
|
||||
await sender.waitForFunction(
|
||||
(value) => document.querySelector('.current-url')?.value === value,
|
||||
'https://example.com/focus-refresh',
|
||||
@@ -123,7 +128,7 @@ try {
|
||||
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.getByRole('button', { name: '다시 가져오기', exact: true }).click();
|
||||
await sender.waitForFunction(
|
||||
(value) => document.querySelector('.current-url')?.value === value,
|
||||
secret,
|
||||
@@ -218,7 +223,7 @@ try {
|
||||
window.__hopClipboardDenied = true;
|
||||
});
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.getByRole('button', { name: '파일 선택', exact: true }).waitFor();
|
||||
await sender.getByLabel('보낼 텍스트', { exact: true }).waitFor();
|
||||
assert.equal(await sender.locator('.clipboard-link-card').count(), 0);
|
||||
const fileBytes = Buffer.from('브라우저 파일 전달 검증');
|
||||
const dragged = await sender.evaluateHandle(() => {
|
||||
@@ -326,16 +331,29 @@ try {
|
||||
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();
|
||||
await second.getByLabel('보낼 텍스트', { exact: true }).waitFor();
|
||||
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 second.getByRole('button', { name: '전송 화면 닫기', exact: true }).click();
|
||||
// Old API callers may still send multiple URLs; the new clipboard UI treats these as text.
|
||||
const sourceCookie = (await a.cookies(url)).find((cookie) => cookie.name === 'hop_guest');
|
||||
const targetCookie = (await c.cookies(url)).find((cookie) => cookie.name === 'hop_guest');
|
||||
const targetGuest = runtime.service.guest(targetCookie.value);
|
||||
const legacyHeaders = {
|
||||
Origin: url,
|
||||
'X-CSRF-Token': runtime.service.csrf(sourceCookie.value),
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
};
|
||||
const legacyRequest = await a.request.post(`${url}/api/transfer-requests`, {
|
||||
headers: legacyHeaders,
|
||||
data: {
|
||||
targetDeviceId: runtime.service.device(targetGuest.id).id,
|
||||
kind: 'url',
|
||||
count: 2,
|
||||
byteSize: Buffer.byteLength(urls.join('\n')),
|
||||
},
|
||||
});
|
||||
assert.equal(legacyRequest.status(), 200);
|
||||
const legacyId = (await legacyRequest.json()).requestId;
|
||||
await urlReceiver.getByRole('button', { name: '수락', exact: true }).waitFor();
|
||||
assert(!(await urlReceiver.locator('body').innerText()).includes(urls[0]));
|
||||
await c.route('https://example.com/**', (route) =>
|
||||
@@ -347,9 +365,11 @@ try {
|
||||
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' });
|
||||
const legacyComplete = await a.request.post(`${url}/api/transfers/${legacyId}/complete`, {
|
||||
headers: { ...legacyHeaders, 'Idempotency-Key': crypto.randomUUID() },
|
||||
data: { urls },
|
||||
});
|
||||
assert.equal(legacyComplete.status(), 200);
|
||||
assert.equal(opened.length, 2);
|
||||
await Promise.all(
|
||||
opened.map((page) => page.waitForURL((address) => urls.includes(address.href))),
|
||||
@@ -365,8 +385,8 @@ try {
|
||||
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.locator('.current-url, .current-text').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();
|
||||
@@ -405,7 +425,7 @@ try {
|
||||
await second.locator('.device-name').filter({ hasText: '재연결된 Mac' }).waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: 두 브라우저 QR·코드 연결, 수락 전 본문 비공개, 클립보드 URL·복수 URL 전달과 일반 텍스트 무시, 수락 후 자동 새 탭 URL 열기, opener 격리, 읽음 동기화, 이름 유지, 다중 탭·CSRF 재연결 복구, 390px 가로 넘침 없음, 실제 파일 업로드·자동 다운로드, 수락 중 지연된 과거 조회에도 전달 완료, 스크롤 뒤 중앙의 고대비 수락 팝업, 검정/라이트 전환, 버튼 자리의 전송 취소·익명 내역 숨김·새로고침 중복 실행 방지.',
|
||||
'PASS: 두 브라우저 QR·코드 연결, 수락 전 본문 비공개, 클립보드 URL·기존 복수 URL API 호환과 클립보드 입력, 수락 후 자동 새 탭 URL 열기, opener 격리, 읽음 동기화, 이름 유지, 다중 탭·CSRF 재연결 복구, 390px 가로 넘침 없음, 실제 파일 업로드·자동 다운로드, 수락 중 지연된 과거 조회에도 전달 완료, 스크롤 뒤 중앙의 고대비 수락 팝업, 검정/라이트 전환, 버튼 자리의 전송 취소·익명 내역 숨김·새로고침 중복 실행 방지.',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -106,8 +106,8 @@ try {
|
||||
await receiver.locator('.room-leave').waitFor();
|
||||
await receiver.locator('.modal-backdrop').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.locator('.current-url, .current-text').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();
|
||||
@@ -125,14 +125,14 @@ try {
|
||||
await sender.getByRole('button', { name: '보관함에 담기', exact: true }).click();
|
||||
await sender
|
||||
.getByRole('dialog', { name: '보관함에 담기' })
|
||||
.getByRole('button', { name: '링크 저장', exact: true })
|
||||
.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.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();
|
||||
await sender.getByRole('button', { name: '메모 수정', exact: true }).click();
|
||||
|
||||
Reference in New Issue
Block a user