449 lines
22 KiB
JavaScript
449 lines
22 KiB
JavaScript
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({ locale: 'ko-KR', viewport: { width: 1440, height: 1050 } }),
|
|
b = await browser.newContext({
|
|
locale: 'ko-KR',
|
|
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: '다른 기기 연결', exact: true }).click();
|
|
await sender.getByRole('button', { name: '연결 공간 만들기' }).click();
|
|
await sender.getByRole('button', { name: '만들기', exact: true }).click();
|
|
await sender.locator('.invite-code').waitFor();
|
|
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
|
|
await receiver.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
|
|
await receiver.getByRole('button', { name: '연결 공간 참여', 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.locator('.modal-backdrop').waitFor({ state: 'hidden' });
|
|
await receiver.locator('.modal-backdrop').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,
|
|
secret,
|
|
);
|
|
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
|
|
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.getByLabel('보낼 텍스트', { 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/resumable/*', async (route) => {
|
|
if (route.request().method() !== 'PUT') return route.continue();
|
|
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.setViewportSize({ width: 390, height: 844 });
|
|
await sender.locator('.file-transfer-progress progress').waitFor();
|
|
const listText = await sender.locator('.content-pane').innerText();
|
|
assert.equal(listText.split('browser.txt').length - 1, 1, '파일명은 목록에 한 번만 표시');
|
|
assert(!listText.includes('전송 중입니다.'), '진행률이 있으면 전송 중 안내를 반복하지 않음');
|
|
assert.equal(await sender.locator('.composer-progress').count(), 0);
|
|
await sender.screenshot({ path: 'tests/e2e/file-progress-mobile.png' });
|
|
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/resumable/*');
|
|
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.setViewportSize({ width: 1440, height: 1050 });
|
|
await sender.screenshot({ path: 'tests/e2e/desktop.png', fullPage: true });
|
|
await sender.getByRole('button', { name: '더보기', exact: true }).click();
|
|
await sender.getByRole('menuitem', { 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 sender.getByRole('menuitem', { 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({ locale: 'ko-KR', 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: '다른 기기 연결', exact: true }).click();
|
|
await urlReceiver.getByRole('button', { name: '연결 공간 참여', 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.locator('.modal-backdrop').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.getByLabel('보낼 텍스트', { exact: true }).waitFor();
|
|
const urls = ['https://example.com/path?x=1', 'http://192.168.1.10/private'];
|
|
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) =>
|
|
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();
|
|
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))),
|
|
);
|
|
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.evaluate(() => {
|
|
window.__hopClipboard = 'https://example.com/blocked';
|
|
});
|
|
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
|
|
await second.waitForFunction(
|
|
() => document.querySelector('.current-url')?.value === '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 API 호환과 클립보드 입력, 수락 후 자동 새 탭 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 });
|
|
}
|