M9: PWA — 홈 화면에 추가, 오프라인 안내
- 매니페스트·아이콘(192/512/maskable/apple-touch, scripts/pwa-icons.ts로 다시 만들기) - 손으로 쓴 서비스 워커(apps/web/sw.js): 빌드 때 Vite 플러그인이 빌드 ID를 넣음. 페이지는 네트워크만(오프라인이면 offline.html), /assets/*만 캐시 먼저(200개까지), /api·/ws·/healthz·/internal은 손대지 않음 - 오프라인 안내 페이지, 방 밖에서도 "인터넷 연결이 없어요" 띠 - 홈 아래 "홈 화면에 추가" 카드(설치 버튼, iOS는 공유 메뉴 안내, 닫으면 기억) - 테스트: apps/web/src/sw.test.ts, e2e/pwa.e2e.ts(E2E_PORT), a11y.e2e.ts도 E2E_PORT 읽기 - 문서: 07 §11, 11 배포(no-cache 대상), README 확인 목록 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
187
e2e/pwa.e2e.ts
Normal file
187
e2e/pwa.e2e.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* E2E (docs/07 §11): manifest + icons, service worker install, offline page, offline banner,
|
||||
* "홈 화면에 추가" card. Run: bun e2e/pwa.e2e.ts (needs apps/web/dist built)
|
||||
*/
|
||||
import { chromium, type Page } from 'playwright-core';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from '../apps/server/src/config';
|
||||
import { startServer } from '../apps/server/src/server';
|
||||
|
||||
const PORT = Number(process.env.E2E_PORT ?? 4521);
|
||||
const ORIGIN = `http://localhost:${PORT}`;
|
||||
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
|
||||
const DIST = join(import.meta.dir, '../apps/web/dist');
|
||||
const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
if (!existsSync(join(DIST, 'sw.js'))) {
|
||||
console.error('apps/web/dist/sw.js가 없어요. 먼저 (cd apps/web && bunx vite build)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-'));
|
||||
const app = startServer({
|
||||
config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }),
|
||||
log: () => {},
|
||||
staticDir: DIST,
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true });
|
||||
let failed = false;
|
||||
const step = async (name: string, fn: () => Promise<void>) => {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await fn();
|
||||
console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`);
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
const assert = (ok: unknown, msg: string) => {
|
||||
if (!ok) throw new Error(msg);
|
||||
};
|
||||
|
||||
/** PNG width/height from the IHDR chunk. */
|
||||
function pngSize(buf: ArrayBuffer): string {
|
||||
const v = new DataView(buf);
|
||||
assert(v.getUint32(0) === 0x89504e47, 'not a PNG');
|
||||
return `${v.getUint32(16)}x${v.getUint32(20)}`;
|
||||
}
|
||||
|
||||
/** axe-core: no serious/critical violations (same bar as a11y.e2e.ts). */
|
||||
async function axe(p: Page, name: string) {
|
||||
await p.addScriptTag({ content: AXE });
|
||||
const res = await p.evaluate(async () => (window as any).axe.run(document, { resultTypes: ['violations'] }));
|
||||
const serious = res.violations.filter((v: any) => v.impact === 'serious' || v.impact === 'critical');
|
||||
assert(!serious.length, `${name} a11y: ${serious.map((v: any) => v.id).join(', ')}`);
|
||||
}
|
||||
|
||||
/** Every URL the app's caches hold (pages must never be among them). */
|
||||
const cachedUrls = (p: Page) =>
|
||||
p.evaluate(async () => {
|
||||
const out: string[] = [];
|
||||
for (const name of await caches.keys()) for (const r of await (await caches.open(name)).keys()) out.push(`${name} ${new URL(r.url).pathname}`);
|
||||
return out;
|
||||
});
|
||||
|
||||
try {
|
||||
await step('manifest·아이콘·sw.js 응답', async () => {
|
||||
const m = await fetch(`${ORIGIN}/manifest.webmanifest`);
|
||||
assert(m.ok && m.headers.get('content-type')?.startsWith('application/manifest+json'), `manifest: ${m.status} ${m.headers.get('content-type')}`);
|
||||
const man = (await m.json()) as { name: string; short_name: string; lang: string; start_url: string; display: string; theme_color: string; icons: { src: string; sizes: string; purpose?: string }[] };
|
||||
assert(man.name === '같이 놀자 보드게임' && man.short_name && man.lang === 'ko' && man.start_url === '/' && man.display === 'standalone', 'manifest fields');
|
||||
assert(/^#[0-9a-f]{6}$/i.test(man.theme_color), 'theme_color');
|
||||
const icons = [...man.icons, { src: '/icons/apple-touch-icon.png', sizes: '180x180' }];
|
||||
assert(man.icons.some((i) => i.sizes === '192x192') && man.icons.some((i) => i.sizes === '512x512' && i.purpose === 'maskable'), 'icon set');
|
||||
for (const i of icons) {
|
||||
const r = await fetch(ORIGIN + i.src);
|
||||
assert(r.ok && r.headers.get('content-type') === 'image/png', `${i.src}: ${r.status} ${r.headers.get('content-type')}`);
|
||||
const size = pngSize(await r.arrayBuffer());
|
||||
assert(size === i.sizes, `${i.src}: ${size} != ${i.sizes}`);
|
||||
}
|
||||
const sw = await fetch(`${ORIGIN}/sw.js`);
|
||||
assert(sw.ok && sw.headers.get('content-type')?.includes('javascript'), `sw.js type ${sw.headers.get('content-type')}`);
|
||||
assert(!(await sw.text()).includes("'__BUILD__'"), 'sw.js build id not filled in');
|
||||
// Must revalidate every time, or a deploy would not reach installed workers (docs/11).
|
||||
for (const [path, res] of [['/sw.js', sw], ['/manifest.webmanifest', m], ['/offline.html', await fetch(`${ORIGIN}/offline.html`)]] as const) {
|
||||
assert(res.ok && res.headers.get('cache-control') === 'no-cache', `${path} cache-control ${res.status} ${res.headers.get('cache-control')}`);
|
||||
}
|
||||
});
|
||||
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' });
|
||||
const page = await ctx.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
|
||||
await step('서비스 워커 등록·활성화 → 페이지 제어', async () => {
|
||||
await page.goto(ORIGIN + '/');
|
||||
await page.getByText('게임 고르기').waitFor();
|
||||
assert(await page.locator('link[rel="manifest"]').count(), 'manifest link');
|
||||
const state = await page.evaluate(async () => (await navigator.serviceWorker.ready).active?.state);
|
||||
assert(state === 'activated', `sw state ${state}`);
|
||||
await page.waitForFunction(() => !!navigator.serviceWorker.controller);
|
||||
await page.reload(); // now the page's own files go through the worker
|
||||
await page.getByText('게임 고르기').waitFor();
|
||||
const urls = await cachedUrls(page);
|
||||
assert(urls.some((u) => /^bg-shell-\w+ \/offline\.html$/.test(u)), `offline page not cached: ${urls.join(', ')}`);
|
||||
assert(urls.some((u) => /^bg-assets-v1 \/assets\/index-.+\.js$/.test(u)), `assets not cached: ${urls.join(', ')}`);
|
||||
const bad = urls.filter((u) => !/ \/(assets\/.+|offline\.html)$/.test(u));
|
||||
assert(!bad.length, `unexpected cache entries: ${bad.join(', ')}`);
|
||||
});
|
||||
|
||||
await step('오프라인: 앱 안 띠 → 새로 열면 오프라인 안내', async () => {
|
||||
await ctx.setOffline(true);
|
||||
await page.getByRole('status').getByText('인터넷 연결이 없어요').waitFor();
|
||||
await page.screenshot({ path: join(SHOTS, 'pwa-1-offline-banner.png') });
|
||||
await page.goto(ORIGIN + '/r/123456');
|
||||
await page.getByRole('heading', { name: '인터넷 연결이 없어요' }).waitFor();
|
||||
assert(page.url() === `${ORIGIN}/r/123456`, `url ${page.url()}`);
|
||||
await page.screenshot({ path: join(SHOTS, 'pwa-2-offline-page.png') });
|
||||
await axe(page, 'offline page');
|
||||
});
|
||||
|
||||
await step('다시 온라인: [다시 시도] → 앱이 열림', async () => {
|
||||
await ctx.setOffline(false);
|
||||
await page.getByRole('link', { name: '다시 시도' }).click();
|
||||
await page.waitForFunction(() => !!document.getElementById('root')?.childElementCount); // the app, not the offline page
|
||||
assert(page.url() === `${ORIGIN}/r/123456`, `url ${page.url()}`);
|
||||
await page.goto(ORIGIN + '/');
|
||||
await page.getByText('게임 고르기').waitFor();
|
||||
assert((await page.getByRole('status').filter({ hasText: '인터넷 연결이 없어요' }).count()) === 0, 'offline banner still shown');
|
||||
const bad = (await cachedUrls(page)).filter((u) => !/ \/(assets\/.+|offline\.html)$/.test(u));
|
||||
assert(!bad.length, `pages got cached: ${bad.join(', ')}`);
|
||||
});
|
||||
|
||||
await step('홈 화면에 추가: 설치 가능한 브라우저', async () => {
|
||||
await page.evaluate(() => {
|
||||
const e = Object.assign(new Event('beforeinstallprompt', { cancelable: true }), {
|
||||
prompt: async () => void ((window as { prompted?: boolean }).prompted = true),
|
||||
userChoice: Promise.resolve({ outcome: 'accepted' }),
|
||||
});
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
const card = page.getByRole('region', { name: '홈 화면에 추가' });
|
||||
await card.waitFor();
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: join(SHOTS, 'pwa-3-install-card.png') });
|
||||
await axe(page, 'install card');
|
||||
await card.getByRole('button', { name: '추가하기' }).click();
|
||||
await card.waitFor({ state: 'detached' });
|
||||
assert(await page.evaluate(() => (window as { prompted?: boolean }).prompted), 'prompt() not called');
|
||||
});
|
||||
if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`);
|
||||
await ctx.close();
|
||||
|
||||
await step('홈 화면에 추가: iPhone Safari 안내 → 닫으면 다시 안 보임', async () => {
|
||||
const ios = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
locale: 'ko-KR',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
|
||||
});
|
||||
const p = await ios.newPage();
|
||||
await p.goto(ORIGIN + '/');
|
||||
const card = p.getByRole('region', { name: '홈 화면에 추가' });
|
||||
await card.getByText('[홈 화면에 추가]').waitFor();
|
||||
assert((await card.getByRole('button', { name: '추가하기' }).count()) === 0, 'iOS has no install button');
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await p.screenshot({ path: join(SHOTS, 'pwa-4-install-ios.png') });
|
||||
await card.getByRole('button', { name: '닫기' }).click();
|
||||
await card.waitFor({ state: 'detached' });
|
||||
await p.reload();
|
||||
await p.getByText('게임 고르기').waitFor();
|
||||
assert((await card.count()) === 0, 'dismissed card came back');
|
||||
await ios.close();
|
||||
});
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
console.error(e);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await app.stop();
|
||||
}
|
||||
console.log(failed ? 'E2E FAILED' : 'E2E PASSED');
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user