import assert from 'node:assert/strict'; import { chromium } from '@playwright/test'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { createApp } from '../../apps/server/src/app.ts'; import { loadConfig } from '../../apps/server/src/config.ts'; const folder = await mkdtemp(resolve(tmpdir(), 'hop-pwa-')); 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'), }); 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: 390, height: 844 }, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1', }); const page = await context.newPage(); await page.addInitScript(() => { window.addEventListener( 'beforeinstallprompt', (event) => { if (!event.__hopTest) { event.preventDefault(); event.stopImmediatePropagation(); } }, { capture: true }, ); }); await page.goto(url); await page.locator('.app-shell[data-online="true"]').waitFor(); const manifest = await (await context.request.get(`${url}/manifest.webmanifest`)).json(); assert.equal(manifest.display, 'standalone'); assert.equal(manifest.scope, '/'); for (const icon of manifest.icons) assert.equal((await context.request.get(url + icon.src)).status(), 200); await page.waitForFunction(() => navigator.serviceWorker.controller !== null); assert.deepEqual(await page.evaluate(() => caches.keys()), []); await page.getByRole('button', { name: '더보기', exact: true }).click(); const install = page.getByRole('menuitem', { name: 'hop. 앱 설치', exact: true }); await install.click(); await page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor(); assert.match(await page.locator('.install-help').innerText(), /Safari.*공유/s); await page.screenshot({ path: 'tests/e2e/pwa-ios.png' }); await page.keyboard.press('Escape'); await page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor({ state: 'hidden' }); await page.setViewportSize({ width: 320, height: 700 }); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); await page.evaluate(() => { window.__installCalls = 0; const event = new Event('beforeinstallprompt', { cancelable: true }); event.__hopTest = true; event.prompt = async () => { window.__installCalls++; }; event.userChoice = Promise.resolve({ outcome: 'accepted' }); window.dispatchEvent(event); }); await page.getByRole('button', { name: '더보기', exact: true }).click(); await install.click(); assert.equal(await page.evaluate(() => window.__installCalls), 1); await install.waitFor({ state: 'hidden' }); await page.reload(); assert.equal(await page.evaluate(async () => (await fetch('/api/device/me')).status), 200); await context.setOffline(true); await page.goto(url, { waitUntil: 'domcontentloaded' }); assert.match(await page.locator('body').innerText(), /인터넷 연결이 필요/); assert.deepEqual(await page.evaluate(() => caches.keys()), []); console.log( 'PASS: 설치 manifest·아이콘, 실제 Service Worker, iPhone 홈 화면 안내, 설치 이벤트와 설치 후 숨김, 320px 헤더, 온라인 API와 개인정보 캐시 없음, 오프라인 안내', ); } finally { await browser?.close(); await runtime.app.close(); await rm(folder, { recursive: true, force: true }); }