181 lines
8.1 KiB
JavaScript
181 lines
8.1 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { chromium } from '@playwright/test';
|
|
import { io } from 'socket.io-client';
|
|
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-layout-'));
|
|
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),
|
|
}),
|
|
webRoot: resolve('apps/web/dist'),
|
|
});
|
|
const sockets = [];
|
|
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 context = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
|
|
const page = await context.newPage(),
|
|
errors = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
await context.addInitScript(() => {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
readText: async () => window.__layoutClipboard ?? 'https://example.com/layout',
|
|
writeText: async () => {},
|
|
},
|
|
});
|
|
});
|
|
await page.goto(url);
|
|
await page.locator('.app-shell[data-online="true"]').waitFor();
|
|
for (let index = 0; index < 12; index++) {
|
|
const session = runtime.service.createGuest();
|
|
runtime.service.patchDevice(session.guest, { name: `기기 ${index + 1}` });
|
|
const socket = io(url, {
|
|
transports: ['websocket'],
|
|
auth: { csrfToken: runtime.service.csrf(session.token) },
|
|
extraHeaders: { Origin: url, Cookie: `hop_guest=${session.token}` },
|
|
});
|
|
sockets.push(socket);
|
|
await new Promise((resolve, reject) => {
|
|
socket.once('connect', resolve);
|
|
socket.once('connect_error', reject);
|
|
});
|
|
}
|
|
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
|
|
assert.equal(await page.locator('.workspace-grid,.send-panel,.send-dialog').count(), 0);
|
|
assert.equal(await page.locator('.desktop-nav,.mobile-nav,.connection-status').count(), 0);
|
|
for (const [width, columns] of [
|
|
[1280, 4],
|
|
[800, 3],
|
|
[390, 2],
|
|
]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
assert.equal(
|
|
await page
|
|
.locator('.peer-grid')
|
|
.evaluate((node) => getComputedStyle(node).gridTemplateColumns.split(' ').length),
|
|
columns,
|
|
);
|
|
const grid = await page.locator('.peer-grid').boundingBox(),
|
|
mine = await page.locator('.device-footer').boundingBox();
|
|
assert(mine.y >= grid.y + grid.height, '내 기기는 푸터');
|
|
assert.equal(
|
|
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
|
true,
|
|
);
|
|
await page.screenshot({ path: `tests/e2e/layout-${width}.png`, fullPage: true });
|
|
}
|
|
await page.locator('.peer-card').first().click();
|
|
const sheet = page.getByRole('dialog', { name: /에 보내기/ });
|
|
await sheet.waitFor();
|
|
const bounds = await sheet.boundingBox();
|
|
assert(Math.abs(bounds.y + bounds.height - 900) < 2, '모바일 하단 시트');
|
|
assert.equal(await page.locator('.content-submit').count(), 1);
|
|
assert.equal(
|
|
await page.getByRole('button', { name: '보관함에서 가져오기', exact: true }).count(),
|
|
0,
|
|
);
|
|
const chooser = page.waitForEvent('filechooser');
|
|
await page.getByRole('button', { name: '대신 파일 선택하기', exact: true }).click();
|
|
await (
|
|
await chooser
|
|
).setFiles({
|
|
name: 'replacement.txt',
|
|
mimeType: 'text/plain',
|
|
buffer: Buffer.from('replace the URL'),
|
|
});
|
|
await page.locator('.selected-files').filter({ hasText: 'replacement.txt' }).waitFor();
|
|
assert.equal(await page.getByLabel('보낼 URL').count(), 0, '파일이 링크를 대체');
|
|
assert.equal(await page.locator('.content-submit').count(), 1);
|
|
const fileBounds = await sheet.boundingBox();
|
|
assert.equal(fileBounds.height, bounds.height);
|
|
assert.equal(fileBounds.width, bounds.width);
|
|
await page.evaluate(() => {
|
|
window.__layoutClipboard = '';
|
|
});
|
|
await page.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
|
|
await page.getByLabel('보낼 URL').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');
|
|
assert.equal(await page.locator('.content-submit').isEnabled(), true);
|
|
const linkBounds = await sheet.boundingBox();
|
|
assert.equal(linkBounds.height, bounds.height);
|
|
assert.equal(linkBounds.width, bounds.width);
|
|
await page.screenshot({ path: 'tests/e2e/send-sheet.png' });
|
|
await page.getByRole('button', { name: '전송 화면 닫기' }).click();
|
|
await page.setViewportSize({ width: 1280, height: 1000 });
|
|
await page.locator('.peer-card').first().click();
|
|
const dialog = await page.locator('.send-dialog').boundingBox();
|
|
assert(
|
|
Math.abs(dialog.x + dialog.width / 2 - 640) < 2 &&
|
|
Math.abs(dialog.y + dialog.height / 2 - 500) < 2,
|
|
'PC 중앙 모달',
|
|
);
|
|
await page.screenshot({ path: 'tests/e2e/send-dialog.png' });
|
|
await page.keyboard.press('Escape');
|
|
await sheet.waitFor({ state: 'hidden' });
|
|
await page.locator('.footer-device-summary').click();
|
|
await page.getByLabel('기기 이름', { exact: true }).fill('나의 테스트 기기');
|
|
await page.getByRole('button', { name: '이름 저장', exact: true }).click();
|
|
await page.locator('.footer-device-name').filter({ hasText: '나의 테스트 기기' }).waitFor();
|
|
await page.locator('.footer-device-summary').click();
|
|
await page.getByLabel('다른 기기에서 발견 허용').uncheck();
|
|
await page.locator('.footer-device-summary').filter({ hasText: '발견 꺼짐' }).waitFor();
|
|
await page.getByLabel('다른 기기에서 발견 허용').check();
|
|
await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor();
|
|
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
|
|
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
|
|
await page.getByRole('button', { name: '연결 공간 만들기' }).click();
|
|
const code = (await page.locator('.invite-code').innerText()).replace(/\s/g, '');
|
|
const cookie = sockets[0].io.opts.extraHeaders.Cookie;
|
|
const token = cookie.split('=')[1];
|
|
const response = await fetch(`${url}/api/spaces/join`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Origin: url,
|
|
Cookie: cookie,
|
|
'X-CSRF-Token': runtime.service.csrf(token),
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
await page.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
|
|
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 1);
|
|
await page.screenshot({ path: 'tests/e2e/main-single-device.png', fullPage: true });
|
|
assert.equal(
|
|
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).count(),
|
|
0,
|
|
);
|
|
await page.getByRole('button', { name: '방 나가기', exact: true }).click();
|
|
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
|
|
assert.deepEqual(errors, []);
|
|
console.log(
|
|
'PASS: 실제 12대 자동 발견, 4/3/2열과 줄바꿈, 푸터의 내 기기 상태·이름/발견 설정, 선택 전 전송 UI 없음, 고정 크기 PC 모달·모바일 하단 시트, 하나의 전송 버튼과 파일/링크 대체, Escape, QR 참여 시 모달 자동 닫힘, 방 안의 기기 격리·나가기 후 주변 복귀.',
|
|
);
|
|
} finally {
|
|
for (const socket of sockets) socket.disconnect();
|
|
await browser?.close();
|
|
await runtime.app.close();
|
|
await rm(folder, { recursive: true, force: true });
|
|
}
|