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:
@@ -20,6 +20,7 @@ bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chro
|
|||||||
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
||||||
bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지)
|
bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지)
|
||||||
bun e2e/a11y.e2e.ts # 접근성(axe) 검사
|
bun e2e/a11y.e2e.ts # 접근성(axe) 검사
|
||||||
|
bun e2e/pwa.e2e.ts # PWA: 매니페스트·아이콘, 서비스 워커, 오프라인 안내, 홈 화면에 추가
|
||||||
bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
|
bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
<meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" />
|
<meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="같이 놀자" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<title>같이 놀자 보드게임</title>
|
<title>같이 놀자 보드게임</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
apps/web/public/icons/apple-touch-icon.png
Normal file
BIN
apps/web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
apps/web/public/icons/icon-192.png
Normal file
BIN
apps/web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/web/public/icons/icon-512.png
Normal file
BIN
apps/web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/icons/icon-maskable-512.png
Normal file
BIN
apps/web/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
19
apps/web/public/manifest.webmanifest
Normal file
19
apps/web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "같이 놀자 보드게임",
|
||||||
|
"short_name": "같이 놀자",
|
||||||
|
"description": "광고 없이 가볍게, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임",
|
||||||
|
"lang": "ko",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f7f5f0",
|
||||||
|
"theme_color": "#f7f5f0",
|
||||||
|
"categories": ["games", "entertainment"],
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
95
apps/web/public/offline.html
Normal file
95
apps/web/public/offline.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#F7F5F0" />
|
||||||
|
<!-- Served by the service worker when a page can't load. No scripts (CSP blocks inline JS) and no other files. -->
|
||||||
|
<title>인터넷 연결이 없어요 · 같이 놀자 보드게임</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f7f5f0;
|
||||||
|
--surface: #fff;
|
||||||
|
--text: #1c1e21;
|
||||||
|
--text-sub: #4a4f57;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-text: #fff;
|
||||||
|
--focus: #f59e0b;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #16181d;
|
||||||
|
--surface: #20232a;
|
||||||
|
--text: #f2f3f5;
|
||||||
|
--text-sub: #b8bcc4;
|
||||||
|
--primary: #5b8def;
|
||||||
|
--primary-text: #0b1020;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 500 17px/1.5 -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 32px 24px;
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: var(--text-sub);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
a.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-text);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a.btn:focus-visible {
|
||||||
|
outline: 3px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon" aria-hidden="true">📡</div>
|
||||||
|
<h1>인터넷 연결이 없어요</h1>
|
||||||
|
<p>와이파이나 데이터가 켜져 있는지 확인한 뒤 [다시 시도]를 눌러 주세요.</p>
|
||||||
|
<a class="btn" href="">다시 시도</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
apps/web/src/components/InstallCard.tsx
Normal file
28
apps/web/src/components/InstallCard.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/** "홈 화면에 추가" offer (docs/07 §11). Hidden when already installed, dismissed, or not installable. */
|
||||||
|
import { dismissInstall, promptInstall, useInstallMode } from '../lib/pwa';
|
||||||
|
|
||||||
|
export function InstallCard() {
|
||||||
|
const mode = useInstallMode();
|
||||||
|
if (!mode) return null;
|
||||||
|
return (
|
||||||
|
<section className="card row" aria-label="홈 화면에 추가" style={{ gap: 12 }}>
|
||||||
|
<span style={{ fontSize: '1.8rem' }} aria-hidden>
|
||||||
|
📲
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: '1 1 220px' }}>
|
||||||
|
<strong>홈 화면에 추가해 보세요</strong>
|
||||||
|
<div className="muted small">
|
||||||
|
{mode === 'ios' ? 'Safari 공유 버튼(네모에 화살표)을 누르고 [홈 화면에 추가]를 골라 주세요.' : '앱처럼 아이콘을 눌러 바로 열 수 있어요.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mode === 'prompt' && (
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => void promptInstall()}>
|
||||||
|
추가하기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={dismissInstall}>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { formatRoomCode } from '@bg/shared/lite';
|
|||||||
import { useToasts, toast } from '../store/toast';
|
import { useToasts, toast } from '../store/toast';
|
||||||
import { useRoom } from '../net/socket';
|
import { useRoom } from '../net/socket';
|
||||||
import { haptic } from '../lib/feedback';
|
import { haptic } from '../lib/feedback';
|
||||||
|
import { useOnline } from '../lib/pwa';
|
||||||
|
|
||||||
const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626'];
|
const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626'];
|
||||||
|
|
||||||
@@ -138,7 +139,8 @@ export function Toasts() {
|
|||||||
export function ConnectionBanner() {
|
export function ConnectionBanner() {
|
||||||
const status = useRoom((s) => s.status);
|
const status = useRoom((s) => s.status);
|
||||||
const code = useRoom((s) => s.code);
|
const code = useRoom((s) => s.code);
|
||||||
if (!code) return null;
|
const online = useOnline();
|
||||||
|
if (!code) return online ? null : <div className="conn-banner" role="status">인터넷 연결이 없어요. 연결되면 다시 쓸 수 있어요.</div>;
|
||||||
if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>;
|
if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>;
|
||||||
if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>;
|
if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>;
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
93
apps/web/src/lib/pwa.ts
Normal file
93
apps/web/src/lib/pwa.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/** PWA glue (docs/07 §11): service worker registration, "홈 화면에 추가", online state. */
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'bg.installDismissed';
|
||||||
|
let deferred: BeforeInstallPromptEvent | null = null;
|
||||||
|
let dismissed = false;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const emit = () => listeners.forEach((l) => l());
|
||||||
|
|
||||||
|
/** Call once at startup, before React renders: the install event can fire very early. */
|
||||||
|
export function initPwa(): void {
|
||||||
|
try {
|
||||||
|
dismissed = localStorage.getItem(DISMISS_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
// Storage blocked: just show the offer again next time.
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault(); // we offer our own button instead of the browser's mini bar
|
||||||
|
deferred = e as BeforeInstallPromptEvent;
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
deferred = null;
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
// Dev builds never register: Vite serves unhashed modules that must not be cached.
|
||||||
|
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => void navigator.serviceWorker.register('/sw.js').catch(() => {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function standalone(): boolean {
|
||||||
|
return matchMedia('(display-mode: standalone)').matches || (navigator as { standalone?: boolean }).standalone === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios(): boolean {
|
||||||
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'prompt' = the browser can install with one tap, 'ios' = show Safari share-menu steps, null = hide. */
|
||||||
|
export type InstallMode = 'prompt' | 'ios' | null;
|
||||||
|
|
||||||
|
function installMode(): InstallMode {
|
||||||
|
if (dismissed || standalone()) return null;
|
||||||
|
if (deferred) return 'prompt';
|
||||||
|
return ios() ? 'ios' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribe = (l: () => void) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => void listeners.delete(l);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useInstallMode(): InstallMode {
|
||||||
|
return useSyncExternalStore(subscribe, installMode, () => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptInstall(): Promise<void> {
|
||||||
|
const e = deferred;
|
||||||
|
if (!e) return;
|
||||||
|
deferred = null; // a prompt event can be used only once
|
||||||
|
emit();
|
||||||
|
await e.prompt();
|
||||||
|
await e.userChoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissInstall(): void {
|
||||||
|
dismissed = true;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeOnline = (l: () => void) => {
|
||||||
|
window.addEventListener('online', l);
|
||||||
|
window.addEventListener('offline', l);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', l);
|
||||||
|
window.removeEventListener('offline', l);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useOnline(): boolean {
|
||||||
|
return useSyncExternalStore(subscribeOnline, () => navigator.onLine, () => true);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { useSession } from './store/session';
|
|||||||
import { toast } from './store/toast';
|
import { toast } from './store/toast';
|
||||||
import { Home } from './pages/Home';
|
import { Home } from './pages/Home';
|
||||||
import { SiteHeader } from './components/SiteHeader';
|
import { SiteHeader } from './components/SiteHeader';
|
||||||
|
import { initPwa } from './lib/pwa';
|
||||||
|
|
||||||
const JoinPage = lazy(() => import('./pages/JoinPage'));
|
const JoinPage = lazy(() => import('./pages/JoinPage'));
|
||||||
const RoomPage = lazy(() => import('./pages/RoomPage'));
|
const RoomPage = lazy(() => import('./pages/RoomPage'));
|
||||||
@@ -75,6 +76,7 @@ function Shell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initPwa();
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Sheet } from '../components/ui';
|
|||||||
import { NicknameForm } from '../components/NicknameForm';
|
import { NicknameForm } from '../components/NicknameForm';
|
||||||
import { CodeInput } from '../components/CodeInput';
|
import { CodeInput } from '../components/CodeInput';
|
||||||
import { GameGrid } from '../components/GameGrid';
|
import { GameGrid } from '../components/GameGrid';
|
||||||
|
import { InstallCard } from '../components/InstallCard';
|
||||||
import { gameName } from '../components/GameNotice';
|
import { gameName } from '../components/GameNotice';
|
||||||
import { api } from '../net/api';
|
import { api } from '../net/api';
|
||||||
import { useSession } from '../store/session';
|
import { useSession } from '../store/session';
|
||||||
@@ -110,6 +111,8 @@ export function Home() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InstallCard />
|
||||||
|
|
||||||
{picker && (
|
{picker && (
|
||||||
<Sheet onClose={() => setPicker(false)} label="게임 고르기">
|
<Sheet onClose={() => setPicker(false)} label="게임 고르기">
|
||||||
<h2>어떤 게임을 할까요?</h2>
|
<h2>어떤 게임을 할까요?</h2>
|
||||||
|
|||||||
137
apps/web/src/sw.test.ts
Normal file
137
apps/web/src/sw.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/** Service worker cache rules (docs/07 §11), run against fake caches/fetch. */
|
||||||
|
import { beforeEach, expect, test } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const ORIGIN = 'https://game.test';
|
||||||
|
const SRC = readFileSync(join(import.meta.dir, '../sw.js'), 'utf8');
|
||||||
|
|
||||||
|
class FakeCache {
|
||||||
|
map = new Map<string, Response>();
|
||||||
|
async match(req: { url: string } | string) {
|
||||||
|
return this.map.get(typeof req === 'string' ? new URL(req, ORIGIN).href : req.url)?.clone();
|
||||||
|
}
|
||||||
|
async put(req: { url: string }, res: Response) {
|
||||||
|
this.map.set(req.url, res);
|
||||||
|
}
|
||||||
|
async add(req: Request) {
|
||||||
|
this.put(req, await net(req));
|
||||||
|
}
|
||||||
|
async keys() {
|
||||||
|
return [...this.map.keys()].map((url) => ({ url }));
|
||||||
|
}
|
||||||
|
async delete(req: { url: string }) {
|
||||||
|
return this.map.delete(req.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stores: Map<string, FakeCache>;
|
||||||
|
let net: (req: { url: string }) => Promise<Response>;
|
||||||
|
let fetched: string[];
|
||||||
|
let handlers: Record<string, (e: unknown) => void>;
|
||||||
|
|
||||||
|
const caches = {
|
||||||
|
open: async (name: string) => stores.get(name) ?? stores.set(name, new FakeCache()).get(name)!,
|
||||||
|
keys: async () => [...stores.keys()],
|
||||||
|
delete: async (name: string) => stores.delete(name),
|
||||||
|
match: async (req: string, opts: { cacheName: string }) => stores.get(opts.cacheName)?.match(req),
|
||||||
|
};
|
||||||
|
const fetchFn = (req: { url: string }) => {
|
||||||
|
fetched.push(new URL(req.url).pathname);
|
||||||
|
return net(req);
|
||||||
|
};
|
||||||
|
class RelRequest extends Request {
|
||||||
|
constructor(url: string, init?: RequestInit) {
|
||||||
|
super(new URL(url, ORIGIN), init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stores = new Map();
|
||||||
|
fetched = [];
|
||||||
|
handlers = {};
|
||||||
|
net = async (req) => new Response(`body of ${new URL(req.url).pathname}`, { headers: { 'Content-Type': 'text/javascript' } });
|
||||||
|
const self = {
|
||||||
|
location: new URL(ORIGIN),
|
||||||
|
addEventListener: (type: string, fn: (e: unknown) => void) => (handlers[type] = fn),
|
||||||
|
skipWaiting: async () => {},
|
||||||
|
clients: { claim: async () => {} },
|
||||||
|
};
|
||||||
|
new Function('self', 'caches', 'fetch', 'Request', SRC)(self, caches, fetchFn, RelRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function lifecycle(type: 'install' | 'activate') {
|
||||||
|
const pending: Promise<unknown>[] = [];
|
||||||
|
handlers[type]!({ waitUntil: (p: Promise<unknown>) => pending.push(p) });
|
||||||
|
await Promise.all(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatches a fetch; returns undefined when the worker leaves the request to the browser. */
|
||||||
|
async function request(path: string, opts: { mode?: string; method?: string; origin?: string } = {}) {
|
||||||
|
let res: Promise<Response> | undefined;
|
||||||
|
const pending: Promise<unknown>[] = [];
|
||||||
|
handlers.fetch!({
|
||||||
|
request: { url: new URL(path, opts.origin ?? ORIGIN).href, method: opts.method ?? 'GET', mode: opts.mode ?? 'cors' },
|
||||||
|
respondWith: (p: Promise<Response>) => (res = p),
|
||||||
|
waitUntil: (p: Promise<unknown>) => pending.push(p),
|
||||||
|
});
|
||||||
|
const out = await res;
|
||||||
|
await Promise.all(pending);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('install keeps the offline page; activate drops old caches only', async () => {
|
||||||
|
stores.set('bg-shell-old', new FakeCache());
|
||||||
|
stores.set('bg-assets-v0', new FakeCache());
|
||||||
|
stores.set('someone-else', new FakeCache());
|
||||||
|
await lifecycle('install');
|
||||||
|
await lifecycle('activate');
|
||||||
|
const names = [...stores.keys()];
|
||||||
|
expect(names).toContain('someone-else');
|
||||||
|
expect(names.filter((n) => n.startsWith('bg-'))).toEqual([expect.stringMatching(/^bg-shell-/)]);
|
||||||
|
expect(await (await caches.match('/offline.html', { cacheName: names.find((n) => n.startsWith('bg-shell-'))! }))?.text()).toBe('body of /offline.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API, WebSocket, health, internal, non-GET and other origins are never handled', async () => {
|
||||||
|
await lifecycle('install');
|
||||||
|
for (const path of ['/api/me', '/api/admin/stats', '/ws', '/healthz', '/internal/metrics']) {
|
||||||
|
expect(await request(path)).toBeUndefined();
|
||||||
|
expect(await request(path, { mode: 'navigate' })).toBeUndefined();
|
||||||
|
}
|
||||||
|
expect(await request('/assets/a.js', { method: 'POST' })).toBeUndefined();
|
||||||
|
expect(await request('/assets/a.js', { origin: 'https://cdn.discordapp.com' })).toBeUndefined();
|
||||||
|
expect([...stores.keys()].filter((n) => n.startsWith('bg-assets'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pages come from the network and are never cached; offline shows the offline page', async () => {
|
||||||
|
await lifecycle('install');
|
||||||
|
net = async () => new Response('<!doctype html>v1', { headers: { 'Content-Type': 'text/html' } });
|
||||||
|
expect(await (await request('/r/123456', { mode: 'navigate' }))!.text()).toBe('<!doctype html>v1');
|
||||||
|
net = async () => new Response('<!doctype html>v2', { headers: { 'Content-Type': 'text/html' } });
|
||||||
|
expect(await (await request('/', { mode: 'navigate' }))!.text()).toBe('<!doctype html>v2');
|
||||||
|
net = () => Promise.reject(new TypeError('Failed to fetch'));
|
||||||
|
expect(await (await request('/', { mode: 'navigate' }))!.text()).toBe('body of /offline.html');
|
||||||
|
expect([...stores.values()].flatMap((c) => [...c.map.keys()]).map((u) => new URL(u).pathname)).toEqual(['/offline.html']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hashed assets are cache-first; HTML fallbacks and partial responses are not kept', async () => {
|
||||||
|
expect(await (await request('/assets/a-1.js'))!.text()).toBe('body of /assets/a-1.js');
|
||||||
|
expect(await (await request('/assets/a-1.js'))!.text()).toBe('body of /assets/a-1.js');
|
||||||
|
expect(fetched).toEqual(['/assets/a-1.js']);
|
||||||
|
|
||||||
|
net = async () => new Response('<!doctype html>', { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||||
|
await request('/assets/gone.js');
|
||||||
|
net = async () => new Response('part', { status: 206, headers: { 'Content-Type': 'audio/mpeg' } });
|
||||||
|
await request('/assets/s.mp3');
|
||||||
|
net = async () => new Response('err', { status: 500 });
|
||||||
|
await request('/assets/b.js');
|
||||||
|
expect([...stores.get('bg-assets-v1')!.map.keys()].map((u) => new URL(u).pathname)).toEqual(['/assets/a-1.js']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asset cache is bounded, oldest out first', async () => {
|
||||||
|
for (let i = 0; i < 205; i++) await request(`/assets/f${i}.js`);
|
||||||
|
const kept = [...stores.get('bg-assets-v1')!.map.keys()].map((u) => new URL(u).pathname);
|
||||||
|
expect(kept.length).toBe(200);
|
||||||
|
expect(kept[0]).toBe('/assets/f5.js');
|
||||||
|
expect(kept.at(-1)).toBe('/assets/f204.js');
|
||||||
|
});
|
||||||
68
apps/web/sw.js
Normal file
68
apps/web/sw.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Service worker (docs/07 §11). Built to dist/sw.js by vite.config.ts, which fills in VERSION with a build id,
|
||||||
|
* so every deploy with changed files installs a new worker.
|
||||||
|
* - Page loads: network first, never cached (index.html is always fresh online); offline → /offline.html.
|
||||||
|
* - /assets/* (hashed, immutable): cache first, at most MAX_ASSETS entries.
|
||||||
|
* - /api, /ws, /healthz, /internal and other origins: not touched at all.
|
||||||
|
*/
|
||||||
|
const VERSION = '__BUILD__';
|
||||||
|
const SHELL = `bg-shell-${VERSION}`;
|
||||||
|
const ASSETS = 'bg-assets-v1'; // hashed files never change, so they survive deploys
|
||||||
|
const OFFLINE_URL = '/offline.html';
|
||||||
|
const MAX_ASSETS = 200;
|
||||||
|
const BYPASS = /^\/(api|ws|healthz|internal)(\/|$)/;
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SHELL)
|
||||||
|
.then((cache) => cache.add(new Request(OFFLINE_URL, { cache: 'reload' })))
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
for (const key of await caches.keys()) {
|
||||||
|
if (key.startsWith('bg-') && key !== SHELL && key !== ASSETS) await caches.delete(key);
|
||||||
|
}
|
||||||
|
await self.clients.claim();
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin || BYPASS.test(url.pathname)) return;
|
||||||
|
if (req.mode === 'navigate') event.respondWith(page(req));
|
||||||
|
else if (url.pathname.startsWith('/assets/')) event.respondWith(asset(event, req));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function page(req) {
|
||||||
|
try {
|
||||||
|
return await fetch(req);
|
||||||
|
} catch {
|
||||||
|
const offline = await caches.match(OFFLINE_URL, { cacheName: SHELL });
|
||||||
|
return offline ?? new Response('인터넷 연결이 없어요.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function asset(event, req) {
|
||||||
|
const cache = await caches.open(ASSETS);
|
||||||
|
const hit = await cache.match(req);
|
||||||
|
if (hit) return hit;
|
||||||
|
const res = await fetch(req);
|
||||||
|
// A missing file falls back to index.html on the server; never keep that (or partial/error responses).
|
||||||
|
if (res.status === 200 && !(res.headers.get('Content-Type') ?? '').startsWith('text/html')) {
|
||||||
|
event.waitUntil(cache.put(req, res.clone()).then(() => trim(cache)));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trim(cache) {
|
||||||
|
const keys = await cache.keys();
|
||||||
|
for (const key of keys.slice(0, Math.max(0, keys.length - MAX_ASSETS))) await cache.delete(key);
|
||||||
|
}
|
||||||
@@ -1,8 +1,26 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
/** Emits dist/sw.js with a build id, so a deploy that changes files installs a new worker (docs/07 §11). */
|
||||||
|
function serviceWorker(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'bg-service-worker',
|
||||||
|
apply: 'build',
|
||||||
|
generateBundle(_, bundle) {
|
||||||
|
const src = readFileSync(new URL('./sw.js', import.meta.url), 'utf8');
|
||||||
|
const offline = readFileSync(new URL('./public/offline.html', import.meta.url), 'utf8');
|
||||||
|
const files = Object.keys(bundle).sort().join('\n');
|
||||||
|
const build = createHash('sha256').update(src).update(offline).update(files).digest('hex').slice(0, 12);
|
||||||
|
if (!src.includes("'__BUILD__'")) throw new Error('sw.js: VERSION placeholder missing');
|
||||||
|
this.emitFile({ type: 'asset', fileName: 'sw.js', source: src.replace("'__BUILD__'", `'${build}'`) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), serviceWorker()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
| `ConfirmSheet` | 아래에서 올라오는 확인창(큰 버튼 두 개) |
|
| `ConfirmSheet` | 아래에서 올라오는 확인창(큰 버튼 두 개) |
|
||||||
| `Card` | 트럼프/화투/게임 카드 공용 SVG, 크기 3단계 |
|
| `Card` | 트럼프/화투/게임 카드 공용 SVG, 크기 3단계 |
|
||||||
| `RulesSheet` | 게임 중 규칙 요약(그림), 현재 상황 도움말 |
|
| `RulesSheet` | 게임 중 규칙 요약(그림), 현재 상황 도움말 |
|
||||||
| `ConnectionBanner` | "다시 연결하는 중…" 띠 |
|
| `ConnectionBanner` | "다시 연결하는 중…" 띠, 인터넷이 끊기면 방 밖에서도 "인터넷 연결이 없어요" 띠 |
|
||||||
|
|
||||||
## 5. 초보자 도움
|
## 5. 초보자 도움
|
||||||
- 처음 하는 게임이면(기기 기준) 시작할 때 "30초 규칙 요약" 카드 3장(그림 위주), [건너뛰기].
|
- 처음 하는 게임이면(기기 기준) 시작할 때 "30초 규칙 요약" 카드 3장(그림 위주), [건너뛰기].
|
||||||
@@ -155,3 +155,20 @@
|
|||||||
- 결과 화면(승/패/무승부, 한 판 더 투표 중)
|
- 결과 화면(승/패/무승부, 한 판 더 투표 중)
|
||||||
- 계정(게스트/디스코드)
|
- 계정(게스트/디스코드)
|
||||||
- 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인)
|
- 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인)
|
||||||
|
|
||||||
|
## 11. PWA (홈 화면 추가·오프라인 안내)
|
||||||
|
- 매니페스트 `/manifest.webmanifest`: 이름 "같이 놀자 보드게임", 짧은 이름 "같이 놀자", `lang: ko`, 시작 `/`, `display: standalone`, 테마·배경 `#F7F5F0`(`--bg`). 아이콘 `public/icons/`: 192·512(favicon 그대로, 둥근 모서리), 512 maskable(가득 채움, 판은 가운데 안전 영역 안), apple-touch-icon 180. 다시 만들기: `bun scripts/pwa-icons.ts`(Chrome으로 그림).
|
||||||
|
- 서비스 워커: 손으로 쓴 `apps/web/sw.js`를 빌드 때 Vite 플러그인이 빌드 ID(파일 이름 해시 기준)를 넣어 `dist/sw.js`로 낸다. 배포 빌드에서만 등록한다(개발 서버에서는 등록 안 함).
|
||||||
|
|
||||||
|
| 요청 | 처리 |
|
||||||
|
|---|---|
|
||||||
|
| 페이지 이동(navigate) | 네트워크 먼저, 저장하지 않음. 네트워크 오류면 `/offline.html` |
|
||||||
|
| `/assets/*` (해시 파일) | 캐시 먼저(`bg-assets-v1`, 200개까지, 오래된 것부터 지움). 200 응답이고 HTML이 아닐 때만 저장(없는 파일은 서버가 index.html을 돌려주므로) |
|
||||||
|
| `/api/*`(관리자 API 포함), `/ws`, `/healthz`, `/internal/*`, 다른 출처, GET 아닌 요청 | 손대지 않음(저장 안 함) |
|
||||||
|
|
||||||
|
- 캐시: `bg-shell-<빌드ID>`(오프라인 안내 페이지 하나)와 `bg-assets-v1`. 새 워커는 바로 활성화(`skipWaiting` + `clients.claim`)하고, 활성화할 때 이 둘이 아닌 `bg-*` 캐시를 지운다. index.html은 어디에도 저장하지 않으므로 온라인이면 새 배포가 바로 보인다. 해시 파일은 내용이 바뀌지 않아 배포 뒤에도 남겨 둔다. 워커의 저장 방식을 바꾸면 `bg-assets-v1`의 숫자를 올린다.
|
||||||
|
- 오프라인 안내 페이지: 다른 파일 없이 혼자 그려지는 HTML(인라인 스타일, 밝게/어둡게). 스크립트는 없다(CSP가 인라인 스크립트를 막음). [다시 시도]는 같은 주소를 다시 연다. 자동 새로고침(meta refresh)은 접근성 검사(axe) 위반이라 쓰지 않는다.
|
||||||
|
- 앱 안: 인터넷이 끊기면 `ConnectionBanner`가 위쪽 띠로 알린다(방 안에서는 기존 재연결 문구).
|
||||||
|
- 홈 화면에 추가: 홈 아래쪽 카드. `beforeinstallprompt`를 잡아 [추가하기] 버튼을 보이고, iOS(이 이벤트 없음)는 "공유 버튼 → [홈 화면에 추가]" 안내만. 이미 앱으로 열었거나(standalone) [닫기]를 누르면(기기에 기억) 안 보인다.
|
||||||
|
- 서버: `/sw.js`·`/manifest.webmanifest`·`/offline.html`은 index.html과 같이 `no-cache`, manifest는 `application/manifest+json`(Bun 기본). CSP `default-src 'self'`가 워커·매니페스트를 허용해 따로 넣은 것은 없다.
|
||||||
|
- 확인: `apps/web/src/sw.test.ts`(캐시 규칙), `bun e2e/pwa.e2e.ts`(매니페스트·아이콘, 워커 활성화, 오프라인 띠·안내 페이지, 다시 온라인, 설치 카드).
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ deploy/
|
|||||||
- `encode zstd gzip`
|
- `encode zstd gzip`
|
||||||
- `/assets/*`: `Cache-Control: public, max-age=31536000, immutable` (파일명에 해시)
|
- `/assets/*`: `Cache-Control: public, max-age=31536000, immutable` (파일명에 해시)
|
||||||
- `index.html`: `no-cache` (배포 즉시 반영)
|
- `index.html`: `no-cache` (배포 즉시 반영)
|
||||||
|
- `/sw.js`·`/manifest.webmanifest`·`/offline.html`: `no-cache`(서비스 워커 갱신). 워커는 페이지와 `/api`·`/ws`를 저장하지 않는다(07 문서 11절).
|
||||||
- 보안 헤더: CSP(04 문서 7절), `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, HSTS.
|
- 보안 헤더: CSP(04 문서 7절), `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, HSTS.
|
||||||
- WebSocket 프록시는 Caddy 기본 지원. 연결 유지 시간 제한 없음.
|
- WebSocket 프록시는 Caddy 기본 지원. 연결 유지 시간 제한 없음.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { join } from 'node:path';
|
|||||||
import { loadConfig } from '../apps/server/src/config';
|
import { loadConfig } from '../apps/server/src/config';
|
||||||
import { startServer } from '../apps/server/src/server';
|
import { startServer } from '../apps/server/src/server';
|
||||||
|
|
||||||
const PORT = 4520;
|
const PORT = Number(process.env.E2E_PORT ?? 4520);
|
||||||
const ORIGIN = `http://localhost:${PORT}`;
|
const ORIGIN = `http://localhost:${PORT}`;
|
||||||
const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
|
const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
|
||||||
const app = startServer({
|
const app = startServer({
|
||||||
|
|||||||
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);
|
||||||
34
scripts/pwa-icons.ts
Normal file
34
scripts/pwa-icons.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Renders the PWA icons (docs/07 §11) from the favicon design with headless Chrome.
|
||||||
|
* bun scripts/pwa-icons.ts → apps/web/public/icons/*.png
|
||||||
|
* "any" icons keep the favicon's rounded corners; maskable/apple icons are full-bleed
|
||||||
|
* (the OS applies its own mask) with the board inside the 40% safe circle.
|
||||||
|
*/
|
||||||
|
import { chromium } from 'playwright-core';
|
||||||
|
import { mkdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const PUBLIC = join(import.meta.dir, '../apps/web/public');
|
||||||
|
const OUT = join(PUBLIC, 'icons');
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const rounded = readFileSync(join(PUBLIC, 'favicon.svg'), 'utf8');
|
||||||
|
// Same board as the favicon, shrunk to 80% so every stroke stays inside the safe circle.
|
||||||
|
const fullBleed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#E3B76B"/><g transform="translate(32 32) scale(0.8) translate(-32 -32)"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40" stroke="#7a5a22" stroke-width="2"/><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></g></svg>`;
|
||||||
|
|
||||||
|
const ICONS: [file: string, size: number, svg: string][] = [
|
||||||
|
['icon-192.png', 192, rounded],
|
||||||
|
['icon-512.png', 512, rounded],
|
||||||
|
['icon-maskable-512.png', 512, fullBleed],
|
||||||
|
['apple-touch-icon.png', 180, fullBleed],
|
||||||
|
];
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
for (const [file, size, svg] of ICONS) {
|
||||||
|
await page.setViewportSize({ width: size, height: size });
|
||||||
|
await page.setContent(`<style>html,body{margin:0;background:transparent}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`);
|
||||||
|
await page.screenshot({ path: join(OUT, file), omitBackground: true });
|
||||||
|
console.log(`✓ ${file}`);
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
Reference in New Issue
Block a user