import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { chromium } from '@playwright/test'; import { createApp } from '../../apps/server/src/app.ts'; import { loadConfig } from '../../apps/server/src/config.ts'; import { users } from '../../apps/server/src/db/schema.ts'; const folder = await mkdtemp(resolve(tmpdir(), 'hop-admin-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', LOCATION_DISCOVERY_ENABLED: 'false', CLIENT_IP_SOURCE: 'cloudflare', TRUSTED_PROXY_CIDRS: '127.0.0.1,::1', SESSION_SECRET: 's'.repeat(40), DISCOVERY_SECRET: 'd'.repeat(40), }), webRoot: resolve('apps/web/dist'), }, { mailer: async () => {} }, ); // A NAS-sized disk verifies comma grouping in the rendered admin card. const realSummary = runtime.service.admin.summary.bind(runtime.service.admin); runtime.service.admin.summary = () => ({ ...realSummary(), diskFreeBytes: 7687e9 }); 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); const passwordHash = await runtime.service.auth.password('12345678'); for (const email of ['hello@noname.company', 'other@hop.test']) runtime.service.db .insert(users) .values({ id: randomUUID(), email, displayName: email, passwordHash, status: 'active', verifiedAt: Date.now(), createdAt: Date.now(), }) .run(); browser = await chromium.launch({ headless: true, ...(process.env.HOP_BROWSER_EXECUTABLE ? { executablePath: process.env.HOP_BROWSER_EXECUTABLE } : {}), }); const errors = []; async function page(width = 1280, ip = '8.8.4.4', country = 'KR') { const context = await browser.newContext({ viewport: { width, height: 900 }, isMobile: width < 600, hasTouch: width < 600, extraHTTPHeaders: { 'CF-Connecting-IP': ip, 'CF-IPCountry': country, 'CF-IPCity': country === 'CN' ? 'Beijing' : 'Daejeon', 'CF-Region': country === 'CN' ? 'Beijing' : 'Daejeon', }, }); const page = await context.newPage(); page.on('pageerror', (error) => errors.push(error.message)); await page.goto(url); await page.locator('.app-shell[data-online="true"]').waitFor(); return page; } const sender = await page(1280, '1.1.1.1', 'CN'), receiver = await page(390, '8.8.8.8', 'KR'); async function session(page) { return page.evaluate(async () => { const response = await fetch('/api/guest/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); return response.json(); }); } const a = await session(sender), b = await session(receiver); async function call(page, csrf, path, body, method = 'POST') { return page.evaluate( async ({ csrf, path, body, method }) => { const response = await fetch('/api' + path, { method, headers: { ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), 'X-CSRF-Token': csrf, 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify(body), }); const value = await response.json(); if (!response.ok) throw Error(value.message); return value; }, { csrf, path, body, method }, ); } const invite = await call(sender, a.csrfToken, '/spaces', {}); await call(receiver, b.csrfToken, '/spaces/join', { code: invite.code }); const transfer = await call(sender, a.csrfToken, '/transfer-requests', { targetDeviceId: b.device.id, kind: 'file', count: 1, byteSize: 4, files: [{ name: 'sample.mp4', size: 4, type: 'video/mp4' }], }); await call(receiver, b.csrfToken, '/transfer-requests/' + transfer.requestId + '/accept', {}); async function uploadFile(page, csrf, id, name, type) { await page.evaluate( async ({ csrf, id, name, type }) => { const body = new FormData(); body.append('file', new Blob(['test'], { type }), name); const response = await fetch('/api/uploads?requestId=' + id + '&fileIndex=0', { method: 'POST', headers: { 'X-CSRF-Token': csrf, 'Idempotency-Key': crypto.randomUUID() }, body, }); if (!response.ok) throw Error(await response.text()); }, { csrf, id, name, type }, ); } await uploadFile(sender, a.csrfToken, transfer.requestId, 'sample.mp4', 'video/mp4'); async function login(page, email) { await page.locator('.login-button').click(); await page.locator('.account-auth').waitFor(); await page.getByLabel('이메일', { exact: true }).fill(email); await page.getByLabel('비밀번호', { exact: true }).fill('12345678'); await page .locator('.account-auth') .getByRole('button', { name: '로그인', exact: true }) .click(); await page.locator('.account-workspace').waitFor(); } const normal = await page(); await login(normal, 'other@hop.test'); await normal.getByRole('button', { name: '더보기', exact: true }).click(); assert.equal(await normal.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0); assert.equal(await normal.evaluate(async () => (await fetch('/api/admin/summary')).status), 403); const admin = await page(); await login(admin, 'hello@noname.company'); await admin.getByRole('button', { name: '더보기', exact: true }).click(); await admin.getByRole('menuitem', { name: '관리자', exact: true }).click(); await admin.locator('.admin-stats').waitFor(); assert.equal( await admin.locator('.admin-stats article').nth(1).locator('strong').innerText(), '7,687.0GB', ); await admin.locator('.admin-file').filter({ hasText: 'sample.mp4' }).waitFor(); assert.match(await admin.locator('.admin-file').innerText(), /중국\(CN\) · Beijing/); assert.match(await admin.locator('.admin-file').innerText(), /1\.1\.1\.1/); assert.match(await admin.locator('.admin-file-owner').innerText(), /^[a-f0-9-]{36}\s+비로그인$/); assert.doesNotMatch(await admin.locator('.admin-file-owner').innerText(), /guest-|user-/); assert.match(await admin.locator('.admin-file').innerText(), /접속 지역 · 중국\(CN\) · Beijing/); assert.doesNotMatch(await admin.locator('.admin-file').innerText(), /최근 접속/); assert.match( await admin.locator('.admin-file footer small').innerText(), /^\[보관 만료\] \d{4}년 \d{1,2}월 \d{1,2}일 \d{2}:\d{2} 까지$/, ); assert.equal(await admin.locator('.admin-upload-control').count(), 0); // Prepare a stored account file and an unsent file draft before pausing. const normalSessionBefore = await session(normal); const policyFile = await call(normal, normalSessionBefore.csrfToken, '/vault', { kind: 'file', files: [{ name: 'policy-file.txt', size: 4, type: 'text/plain' }], }); await uploadFile( normal, normalSessionBefore.csrfToken, policyFile.requestId, 'policy-file.txt', 'text/plain', ); await normal.getByRole('button', { name: '더보기', exact: true }).click(); await normal.getByRole('tab', { name: '임시 저장', exact: true }).click(); const policyCard = normal.locator('.compact-item').filter({ hasText: 'policy-file.txt' }); await policyCard.waitFor(); await normal.getByRole('button', { name: '보관함에 담기', exact: true }).click(); await normal.getByRole('button', { name: '파일 저장', exact: true }).click(); await sender .locator('.peer-card') .filter({ hasText: '#' + b.device.code }) .click(); const composer = sender.locator('.send-dialog'); await composer .locator('input[type=file]') .setInputFiles({ name: 'draft.txt', mimeType: 'text/plain', buffer: Buffer.from('draft') }); await composer.locator('.selected-files').waitFor(); await admin.getByRole('button', { name: '네트워크', exact: true }).click(); async function banFiles(value) { await admin.getByRole('button', { name: '수정', exact: true }).click(); const toggle = admin.getByRole('switch', { name: '파일 전송 금지', exact: true }); if ((await toggle.getAttribute('aria-checked')) !== String(value)) await toggle.click(); await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click(); await admin.getByRole('button', { name: '수정', exact: true }).waitFor(); } await banFiles(true); assert.equal(runtime.service.admin.state.uploadsPaused, true); await composer .getByText('파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.', { exact: true }) .waitFor(); assert.equal(await composer.locator('input[type=file]').count(), 0); assert.equal( await composer.getByRole('button', { name: '대신 파일 선택하기', exact: true }).count(), 0, ); assert.equal(await composer.locator('.selected-files').count(), 0); await normal.getByLabel('보낼 텍스트', { exact: true }).waitFor(); assert.equal(await normal.locator('input[type=file]').count(), 0); assert.equal( await policyCard.getByRole('button', { name: '기기로 보내기', exact: true }).count(), 0, ); assert.equal(await policyCard.getByRole('button', { name: '다운로드', exact: true }).count(), 1); await normal.getByRole('button', { name: '다른 종류 선택', exact: true }).click(); assert.equal(await normal.getByRole('button', { name: '파일 저장', exact: true }).count(), 0); await normal.getByRole('button', { name: '보관함 저장 닫기', exact: true }).click(); const pausedGuest = await page(390); await pausedGuest.locator('.file-policy-note').waitFor(); await pausedGuest.context().close(); const message = '파일 제한 중에도 한글 日本語 😀\n 텍스트를 전송합니다.'; await composer.getByLabel('보낼 텍스트', { exact: true }).fill(message); await composer.locator('.unified-composer').evaluate((element) => { const data = new DataTransfer(); data.items.add(new File(['ignored'], 'blocked.txt', { type: 'text/plain' })); element.dispatchEvent( new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: data }), ); element.dispatchEvent( new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }), ); }); assert.equal(await composer.getByLabel('보낼 텍스트', { exact: true }).inputValue(), message); await sender.screenshot({ path: 'tests/e2e/file-policy-sender.png', fullPage: true }); await composer.getByRole('button', { name: '전송하기', exact: true }).click(); await receiver.getByRole('button', { name: '수락', exact: true }).click(); await receiver.getByRole('dialog', { name: '받은 텍스트', exact: true }).waitFor(); assert.equal(await receiver.getByLabel('받은 텍스트 내용', { exact: true }).innerText(), message); await receiver.getByRole('button', { name: '받은 텍스트 닫기', exact: true }).click(); await composer.waitFor({ state: 'hidden' }); // Unpausing updates existing clients without a reload and preserves typed text. await sender .locator('.peer-card') .filter({ hasText: '#' + b.device.code }) .click(); await composer.getByLabel('보낼 텍스트', { exact: true }).fill('작성 중인 내용은 유지'); await banFiles(false); await composer.getByRole('button', { name: '대신 파일 선택하기', exact: true }).waitFor(); assert.equal( await composer.getByLabel('보낼 텍스트', { exact: true }).inputValue(), '작성 중인 내용은 유지', ); await composer.getByRole('button', { name: '전송 화면 닫기', exact: true }).click(); const removed = ( await normal.evaluate(async () => (await (await fetch('/api/vault')).json()).items) ).find((item) => item.file?.name === 'policy-file.txt'); const adminSession = await session(admin); await call(admin, adminSession.csrfToken, '/admin/files/' + removed.file.id, undefined, 'DELETE'); await admin.getByRole('button', { name: '용량 설정', exact: true }).click(); const capacityForm = admin.locator('.admin-settings'); assert.equal(await capacityForm.locator('input').count(), 0); assert.match(await capacityForm.innerText(), /로그인 보관함 계정당 한도[\s\S]*2\.1GB/); await admin.getByRole('button', { name: '수정', exact: true }).click(); const rowInputs = await admin.locator('.admin-settings-grid input').evaluateAll((inputs) => inputs.slice(0, 2).map((input) => ({ top: input.getBoundingClientRect().top, height: input.getBoundingClientRect().height, })), ); assert.equal(rowInputs[0].top, rowInputs[1].top); assert.equal(rowInputs[0].height, rowInputs[1].height); await admin.screenshot({ path: 'tests/e2e/admin-capacity-edit-desktop.png', fullPage: true }); await admin.getByRole('button', { name: '설정 저장', exact: true }).click(); await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor(); assert.equal( runtime.service.admin.state.totalStorageLimitMb, 20480, 'unmodified rounded GB keeps exact stored quota', ); assert.equal(runtime.service.admin.state.accountBytes, 2147483648); await admin.getByRole('button', { name: '수정', exact: true }).click(); await admin.getByLabel('동시에 처리할 업로드 수', { exact: true }).fill('8'); await admin.getByRole('button', { name: '취소', exact: true }).click(); assert.equal(runtime.service.admin.state.maxConcurrentUploads, 4); assert.equal(await capacityForm.locator('input').count(), 0); await admin.getByRole('button', { name: '수정', exact: true }).click(); await admin.getByLabel('동시에 처리할 업로드 수', { exact: true }).fill('3'); await admin.getByLabel('전체 파일 저장 한도 (GB)', { exact: true }).fill('16'); await admin.getByLabel('로그인 보관함 계정당 한도 (GB)', { exact: true }).fill('3'); await admin.getByLabel('기기 세션당 업로드 한도 (GB)', { exact: true }).fill('4'); await admin.getByLabel('기기 전송 업로드 전체 한도 (GB)', { exact: true }).fill('12'); await admin.getByLabel('파일 한 개 한도 (GB)', { exact: true }).fill('1'); await admin.getByLabel('NAS 최소 여유 공간 (GB)', { exact: true }).fill('0'); await admin.getByRole('button', { name: '설정 저장', exact: true }).click(); await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor(); assert.equal(runtime.service.admin.state.maxConcurrentUploads, 3); assert.equal(runtime.service.admin.state.totalStorageLimitMb, Math.round(16e9 / 1048576)); for (const [key, expected] of Object.entries({ accountBytes: 3e9, deviceBytes: 4e9, devicesTotalBytes: 12e9, perFileBytes: 1e9, minDiskFreeBytes: 0, })) assert.equal(runtime.service.admin.state[key], expected); assert.equal(await capacityForm.locator('input').count(), 0); await admin.screenshot({ path: 'tests/e2e/admin-capacity-desktop.png', fullPage: true }); await admin.getByRole('button', { name: '네트워크', exact: true }).click(); assert.equal(await admin.locator('.network-auto-fields input').count(), 0); assert.equal(await admin.getByRole('switch').count(), 0); await admin.getByRole('button', { name: '수정', exact: true }).click(); await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('50'); await admin.getByRole('button', { name: '취소', exact: true }).click(); assert.equal(await admin.getByRole('switch').count(), 0); await admin.getByRole('button', { name: '수정', exact: true }).click(); async function addRule(page, list, kind, value) { await page.getByRole('button', { name: list + ' 추가', exact: true }).click(); const modal = page.getByRole('dialog', { name: list + ' 추가', exact: true }); await modal.getByLabel('등록 대상').selectOption(kind); await modal .getByLabel(kind === 'country' ? '국가 코드' : 'IP 주소 또는 IP 대역', { exact: true }) .fill(value); await modal.getByRole('button', { name: '추가', exact: true }).click(); await modal.waitFor({ state: 'hidden' }); } await addRule(admin, '블랙리스트', 'country', 'cn'); await addRule(admin, '블랙리스트', 'ip', '9.9.9.*'); await addRule(admin, '화이트리스트', 'country', 'KR'); await addRule(admin, '화이트리스트', 'ip', '8.8.8.*'); await addRule(admin, '화이트리스트', 'ip', '4.4.4.4'); await admin.getByRole('checkbox', { name: '4.4.4.4 허용 규칙 선택', exact: true }).check(); await admin .locator('section[aria-labelledby="whitelist-title"]') .getByRole('button', { name: '선택 항목 제거', exact: true }) .click(); await admin.getByRole('switch', { name: '자동 블랙리스트 사용', exact: true }).click(); await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('3'); await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click(); await admin.getByText('네트워크 설정을 저장했어요.', { exact: true }).waitFor(); assert.equal(await admin.getByRole('switch').count(), 0); assert.equal(runtime.service.admin.state.autoLoginFailures, 3); assert.deepEqual(runtime.service.admin.state.blockedIps, ['9.9.9.0/24']); assert.deepEqual(runtime.service.admin.state.allowedCountries, ['KR']); assert.deepEqual(runtime.service.admin.state.allowedIps, ['8.8.8.0/24']); assert.equal(runtime.service.admin.state.autoBlockEnabled, true); const attacker = await page(390, '8.8.8.9', 'KR'), attackSession = await session(attacker); await attacker.evaluate(async (csrf) => { for (let i = 0; i < 3; i++) await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, body: JSON.stringify({ email: 'missing@hop.test', password: 'incorrect' }), }); }, attackSession.csrfToken); await admin.getByRole('button', { name: '자동 차단 목록 새로고침', exact: true }).click(); await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).waitFor(); await admin.screenshot({ path: 'tests/e2e/network-desktop.png', fullPage: true }); await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).check(); await admin.getByRole('button', { name: '선택 차단 해제', exact: true }).click(); await admin.getByText('선택한 IP의 자동 차단을 해제했어요.', { exact: true }).waitFor(); assert.equal(await attacker.evaluate(async () => (await fetch('/api/device/me')).status), 200); assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']); assert.equal(await sender.evaluate(async () => (await fetch('/api/device/me')).status), 403); await admin.getByRole('button', { name: '저장 파일', exact: true }).click(); await admin.screenshot({ path: 'tests/e2e/admin-desktop.png', fullPage: true }); await admin.getByRole('button', { name: '원본 삭제', exact: true }).click(); await admin.getByRole('alertdialog').waitFor(); await admin.getByRole('button', { name: '취소', exact: true }).click(); assert.equal(await admin.locator('.admin-file').count(), 1); await admin.getByRole('button', { name: '원본 삭제', exact: true }).click(); await admin.getByRole('button', { name: '원본 삭제 확인', exact: true }).click(); await admin.getByText('저장된 파일이 없습니다.', { exact: true }).waitFor(); assert.equal(runtime.service.admin.summary().storedBytes, 0); const normalSession = await session(normal); const owned = await call(normal, normalSession.csrfToken, '/vault', { kind: 'file', files: [{ name: 'account-owner.txt', size: 4, type: 'text/plain' }], }); await uploadFile( normal, normalSession.csrfToken, owned.requestId, 'account-owner.txt', 'text/plain', ); await admin.getByRole('button', { name: '새로고침', exact: true }).click(); const ownedCard = admin.locator('.admin-file').filter({ hasText: 'account-owner.txt' }); await ownedCard.waitFor(); const ownerLabel = await ownedCard.locator('.admin-file-owner').innerText(); assert.match(ownerLabel, /^[a-f0-9-]{36}\s+other@hop\.test$/); assert.doesNotMatch(ownerLabel, /user-|guest-|\[/); await admin.screenshot({ path: 'tests/e2e/admin-file-account.png', fullPage: true }); await admin.getByRole('button', { name: '관리 기록', exact: true }).click(); await admin.getByText('원본 파일 삭제', { exact: true }).first().waitFor(); const changedRow = admin .locator('.admin-audit article') .filter({ hasText: '동시에 처리할 업로드 수 4 > 3' }); await changedRow.waitFor(); await changedRow.getByRole('checkbox').check(); await admin.getByRole('button', { name: '선택 기록 삭제', exact: true }).click(); await admin.getByText('선택한 관리 기록을 삭제했어요.', { exact: true }).waitFor(); assert.equal(await changedRow.count(), 0); await admin.getByRole('button', { name: '사용자', exact: true }).click(); await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).waitFor(); assert.equal(await admin.getByRole('menuitem', { name: '계정 삭제', exact: true }).count(), 0); await admin.screenshot({ path: 'tests/e2e/admin-users.png', fullPage: true }); await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).click(); await admin.getByRole('menuitem', { name: '비밀번호 재설정 메일 보내기', exact: true }).click(); await admin .getByRole('alertdialog') .getByRole('button', { name: '메일 보내기', exact: true }) .click(); await admin.getByText('비밀번호 재설정 메일을 보냈어요.', { exact: true }).waitFor(); await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).click(); await admin.getByRole('menuitem', { name: '계정 삭제', exact: true }).click(); const deletion = admin.getByRole('alertdialog', { name: '계정을 삭제할까요?', exact: true }); assert.equal( await deletion.getByRole('button', { name: '계정 삭제 확인', exact: true }).isDisabled(), true, ); await deletion.getByLabel('확인을 위해 대상 이메일 입력', { exact: true }).fill('other@hop.test'); await deletion.getByRole('button', { name: '계정 삭제 확인', exact: true }).click(); await admin.getByText('계정을 삭제했어요.', { exact: true }).waitFor(); assert.equal( await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).count(), 0, ); await admin.getByRole('button', { name: '관리 기록', exact: true }).click(); await admin.getByText('사용자 계정 삭제', { exact: true }).waitFor(); await admin.getByText('비밀번호 재설정 메일 발송', { exact: true }).waitFor(); await admin.getByRole('button', { name: '전체 기록 삭제', exact: true }).click(); await admin .getByRole('alertdialog') .getByRole('button', { name: '전체 삭제 확인', exact: true }) .click(); await admin.getByText('관리 기록이 없습니다.', { exact: true }).waitFor(); const mobile = await page(390); await login(mobile, 'hello@noname.company'); await mobile.getByRole('button', { name: '더보기', exact: true }).click(); await mobile.getByRole('menuitem', { name: '관리자', exact: true }).click(); await mobile.locator('.admin-stats').waitFor(); await mobile.getByRole('button', { name: '용량 설정', exact: true }).click(); assert.equal( await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, ); assert.equal(await mobile.locator('.admin-settings input').count(), 0); await mobile.getByRole('button', { name: '수정', exact: true }).click(); assert.equal(await mobile.getByLabel('파일 한 개 한도 (GB)', { exact: true }).inputValue(), '1'); assert.equal( await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, ); await mobile.screenshot({ path: 'tests/e2e/admin-capacity-mobile.png', fullPage: true }); await mobile.getByRole('button', { name: '취소', exact: true }).click(); await mobile.getByRole('button', { name: '네트워크', exact: true }).click(); assert.equal( await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, ); assert.equal(await mobile.getByRole('switch').count(), 0); await mobile.getByRole('button', { name: '수정', exact: true }).click(); await mobile.getByRole('button', { name: '블랙리스트 추가', exact: true }).click(); const ruleModal = mobile.getByRole('dialog', { name: '블랙리스트 추가', exact: true }); assert.equal( await ruleModal .getByLabel('국가 코드', { exact: true }) .evaluate((element) => parseFloat(getComputedStyle(element).fontSize) >= 16), true, ); await ruleModal.getByLabel('국가 코드', { exact: true }).fill('JP'); await mobile.screenshot({ path: 'tests/e2e/network-rule-mobile.png', fullPage: true }); await ruleModal.getByRole('button', { name: '규칙 추가 닫기' }).click(); await mobile.screenshot({ path: 'tests/e2e/admin-mobile.png', fullPage: true }); await mobile.getByRole('button', { name: '메인으로', exact: true }).click(); await mobile.locator('.nearby-section').waitFor(); await mobile.locator('.login-button').click(); await mobile.getByRole('button', { name: '로그아웃', exact: true }).click(); await mobile.locator('.nearby-section').waitFor(); await mobile.getByRole('button', { name: '더보기', exact: true }).click(); assert.equal(await mobile.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0); assert.deepEqual(errors, []); console.log( 'PASS: verified hello-only menu and server authorization, admin storage/location/IP overview, file-only ban/live menu updates and clipboard-only transfer, comma-separated GB, aligned capacity inputs, saved quotas and country/IP rules, delete confirmation/audit, mobile layout and font size, logout privilege removal.', ); } finally { await browser?.close(); await runtime.app.close(); await rm(folder, { recursive: true, force: true }); }