Skip to content

Commit 797aaad

Browse files
임선규claude
andcommitted
feat: 오프라인 Phase 4 — Playwright 오프라인 E2E 테스트 + CI 게이트
오프라인은 평소에 안 쓰여 조용히 망가지기 쉬운 기능이라 자동 테스트로 보증한다. 테스트가 깨지면 배포가 차단된다 (deploy는 build job 성공 필요). 1. tests/offline.spec.ts - 온라인 워밍업 → SW 활성화 + 셸/핵심 청크 프리캐시 확인 - 오프라인 전환 + 새로고침 → 앱 셸 렌더링 + 오프라인 배지 확인 - A등급 탭 5개(계산기·매뉴얼·타이머·장비점검·법률)가 오프라인에서 전부 열리는지 확인 - SW 등록 일시 실패 시 재등록, claim 미적용 시 새로고침 (자가 복구) 2. ?tab= URL 파라미터 지원 (App.tsx) - manifest 바로가기('/?tab=wildfire')가 실제로는 무시되던 버그 수정 - 테스트의 탭 네비게이션 기반으로도 사용 3. CI (deploy.yml build job) - 빌드 후 chromium 설치 + playwright test 실행 - 실패 시 test-results 아티팩트 업로드 (7일 보관) 검증: 로컬 2회 연속 통과 (오프라인 시나리오 5~9초). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 575f586 commit 797aaad

8 files changed

Lines changed: 256 additions & 2 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ jobs:
3434
VITE_API_BASE: https://119-helper-api.teemozipsa.workers.dev
3535
VITE_KAKAO_MAP_KEY: ${{ secrets.VITE_KAKAO_MAP_KEY }}
3636

37+
# 오프라인 동작 보증: 이 테스트가 깨지면 현장에서 신호 끊겼을 때 앱이 안 뜬다
38+
- name: Install Playwright browser
39+
run: npx playwright install --with-deps chromium
40+
41+
- name: Offline E2E tests
42+
run: npx playwright test
43+
44+
- name: Upload test results on failure
45+
if: failure()
46+
uses: actions/upload-artifact@v4
47+
with:
48+
name: playwright-results
49+
path: test-results/
50+
retention-days: 7
51+
3752
deploy:
3853
name: 🚀 Deploy to GitHub Pages
3954
needs: build

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,8 @@ lint_errors.txt
4242
lint_output.txt
4343
tmp_lint.txt
4444
.serena/
45+
46+
47+
# Playwright
48+
test-results/
49+
playwright-report/

package-lock.json

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "vite",
88
"build": "tsc -b && vite build",
99
"lint": "eslint .",
10-
"preview": "vite preview"
10+
"preview": "vite preview",
11+
"test:e2e": "playwright test"
1112
},
1213
"dependencies": {
1314
"@react-google-maps/api": "^2.20.8",
@@ -18,6 +19,7 @@
1819
},
1920
"devDependencies": {
2021
"@eslint/js": "^9.39.4",
22+
"@playwright/test": "^1.60.0",
2123
"@tailwindcss/vite": "^4.2.4",
2224
"@types/node": "^24.12.0",
2325
"@types/proj4": "^2.5.6",

playwright.config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
/*
4+
* 오프라인 E2E 테스트 설정
5+
* 프로덕션 빌드(dist)를 vite preview로 서빙해 서비스 워커까지 실제로 검증한다.
6+
* (SW는 PROD 빌드에서만 등록되므로 dev 서버로는 테스트 불가)
7+
*/
8+
export default defineConfig({
9+
testDir: './tests',
10+
timeout: 90_000,
11+
retries: process.env.CI ? 1 : 0,
12+
reporter: process.env.CI ? 'github' : 'list',
13+
use: {
14+
baseURL: 'http://localhost:4173',
15+
serviceWorkers: 'allow',
16+
},
17+
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
18+
webServer: {
19+
command: 'npm run preview -- --port 4173 --strictPort',
20+
url: 'http://localhost:4173',
21+
reuseExistingServer: !process.env.CI,
22+
timeout: 30_000,
23+
},
24+
});

src/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { prefetchCriticalViews } from './utils/prefetchCriticalViews';
1313
import { fetchDisasterMsgs } from './services/disasterMsgApi';
1414
import { loadKakaoMapSDK } from './utils/kakaoLoader';
1515

16+
import { isTabId } from './types/navigation';
1617
import type { TabId, NavigateTarget } from './types/navigation';
1718
type ShelterCategory = 'building' | 'hydrants' | 'waterTowers' | 'civil' | 'tsunami' | 'restrooms';
1819
type GpsStatus = 'loading' | 'granted' | 'denied' | 'idle' | 'unsupported';
@@ -241,7 +242,11 @@ function TabLoading({ label }: { label: string }) {
241242

242243
/* ─────────── Main App ─────────── */
243244
export default function App() {
244-
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
245+
// ?tab= 파라미터로 시작 탭 지정 가능 (manifest 바로가기 '/?tab=wildfire' 등)
246+
const [activeTab, setActiveTab] = useState<TabId>(() => {
247+
const tab = new URLSearchParams(window.location.search).get('tab');
248+
return isTabId(tab) ? tab : 'dashboard';
249+
});
245250
const [activeSubId, setActiveSubId] = useState<string | undefined>(undefined);
246251
const [city, setCity] = useState<string>(() => localStorage.getItem('119helper-city') || 'seoul');
247252
const [fireFacilities, setFireFacilities] = useState<FireFacility[]>([]);

src/types/navigation.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ export type TabId =
2121
| 'checklist'
2222
| 'equipment-cert';
2323

24+
// URL ?tab= 파라미터 검증용 런타임 목록 (manifest 바로가기, E2E 테스트에서 사용)
25+
export const ALL_TAB_IDS: readonly TabId[] = [
26+
'dashboard', 'shelter', 'er', 'weather', 'calculator', 'calendar',
27+
'emergency', 'fire-analysis', 'multiuse', 'hazmat', 'annual-fire',
28+
'fire-damage', 'hazards', 'manual', 'field-timer', 'news', 'policy',
29+
'wildfire', 'law', 'checklist', 'equipment-cert',
30+
];
31+
32+
export function isTabId(value: string | null): value is TabId {
33+
return !!value && (ALL_TAB_IDS as readonly string[]).includes(value);
34+
}
35+
2436
export type LegacyShelterTab = 'hydrants' | 'waterTowers' | 'building';
2537

2638
export type NavigateTarget = TabId | LegacyShelterTab;

tests/offline.spec.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
/*
4+
* 오프라인 동작 보증 테스트
5+
*
6+
* 오프라인은 평소에 안 쓰여서 조용히 망가지기 쉬운 기능이다.
7+
* 이 테스트가 깨지면 = 현장에서 신호 끊겼을 때 앱이 안 뜬다는 뜻.
8+
*
9+
* 시나리오:
10+
* 1. 온라인 첫 방문 → SW 활성화 + 앱 셸/핵심 청크 캐시 적재 확인
11+
* 2. 오프라인 전환 + 새로고침 → 앱이 백지 없이 뜨고 오프라인 배지 표시
12+
* 3. 핵심 현장 도구 5개 탭(A등급)이 오프라인에서 전부 열림
13+
*
14+
* 주의: SW 캐시는 브라우저 컨텍스트별로 격리되므로
15+
* 캐시 워밍업과 오프라인 검증을 한 테스트 안에서 수행한다.
16+
*/
17+
18+
// A등급(오프라인 필수) 탭과 각 화면의 식별 문구
19+
const CRITICAL_TABS = [
20+
{ tab: 'calculator', text: '119 계산기' },
21+
{ tab: 'manual', text: '대응 매뉴얼' },
22+
{ tab: 'field-timer', text: '현장 활동 타이머' },
23+
{ tab: 'checklist', text: '개인안전장비 점검' },
24+
{ tab: 'law', text: '관련 법령' },
25+
];
26+
27+
const CRITICAL_CHUNKS = ['Calculators', 'ManualView', 'FieldTimer', 'EquipmentChecklist', 'LawDashboard'];
28+
29+
async function waitForServiceWorkerControl(page: Page) {
30+
// 1) 등록이 활성화될 때까지 대기 — 최초 등록이 일시 오류로 실패했으면 재등록 (자가 복구)
31+
await page.waitForFunction(
32+
async () => {
33+
let reg = await navigator.serviceWorker.getRegistration();
34+
if (!reg) {
35+
try {
36+
reg = await navigator.serviceWorker.register('/sw.js');
37+
} catch {
38+
return false;
39+
}
40+
}
41+
return !!reg.active;
42+
},
43+
undefined,
44+
{ timeout: 30_000, polling: 1_000 },
45+
);
46+
47+
// 2) 페이지 제어권 확보 — clients.claim이 아직 안 먹었으면 새로고침 한 번
48+
const controlled = await page.evaluate(() => navigator.serviceWorker.controller != null);
49+
if (!controlled) {
50+
await page.reload();
51+
await page.waitForFunction(
52+
() => navigator.serviceWorker?.controller != null,
53+
undefined,
54+
{ timeout: 15_000, polling: 1_000 },
55+
);
56+
}
57+
}
58+
59+
async function waitForCriticalChunksCached(page: Page, chunks: string[]) {
60+
// prefetchCriticalViews가 유휴 시간(requestIdleCallback timeout 10초)에 실행되므로 여유를 둔다
61+
await page.waitForFunction(
62+
async (names: string[]) => {
63+
const cacheNames = await caches.keys();
64+
const assetCacheName = cacheNames.find((k) => k.startsWith('119-assets-'));
65+
if (!assetCacheName) return false;
66+
const cache = await caches.open(assetCacheName);
67+
const urls = (await cache.keys()).map((r) => r.url);
68+
return names.every((name) => urls.some((u) => u.includes(name)));
69+
},
70+
chunks,
71+
{ timeout: 45_000, polling: 1_000 },
72+
);
73+
}
74+
75+
test('오프라인: 앱 셸과 핵심 현장 도구(A등급)가 전부 동작한다', async ({ page, context }) => {
76+
// ── 1. 온라인 워밍업: 첫 방문 → SW 설치 → 자동 새로고침 안정화 대기
77+
await page.goto('/');
78+
await page.waitForTimeout(3_000); // 첫 SW 활성화 시 controllerchange 자동 새로고침 흡수
79+
await page.waitForLoadState('domcontentloaded');
80+
await waitForServiceWorkerControl(page);
81+
82+
// 셸 프리캐시 확인 (index.html + 아이콘 폰트)
83+
const shellOk = await page.evaluate(async () => {
84+
const cacheNames = await caches.keys();
85+
const shellName = cacheNames.find((k) => k.startsWith('119-shell-'));
86+
if (!shellName) return false;
87+
const cache = await caches.open(shellName);
88+
const hasIndex = !!(await cache.match('/index.html'));
89+
const hasIconFont = !!(await cache.match('/fonts/material-symbols-outlined.woff2'));
90+
return hasIndex && hasIconFont;
91+
});
92+
expect(shellOk, '앱 셸(index.html + 아이콘 폰트)이 프리캐시되어야 함').toBe(true);
93+
94+
// 핵심 청크 프리페치 완료 대기
95+
await waitForCriticalChunksCached(page, CRITICAL_CHUNKS);
96+
97+
// ── 2. 오프라인 전환 → 새로고침해도 앱이 뜬다
98+
await context.setOffline(true);
99+
await page.reload();
100+
await expect(
101+
page.getByText('대시보드').first(),
102+
'오프라인 새로고침 후에도 앱 셸이 렌더링되어야 함',
103+
).toBeVisible({ timeout: 15_000 });
104+
105+
// 오프라인 배지 표시
106+
await expect(
107+
page.getByText(/ · /),
108+
'오프라인 배지가 표시되어야 함',
109+
).toBeVisible({ timeout: 10_000 });
110+
111+
// ── 3. A등급 탭 5개가 오프라인에서 전부 열린다
112+
for (const { tab, text } of CRITICAL_TABS) {
113+
await page.goto(`/?tab=${tab}`);
114+
await expect(
115+
page.getByText(text).first(),
116+
`오프라인에서 '${tab}' 탭(${text})이 열려야 함`,
117+
).toBeVisible({ timeout: 15_000 });
118+
}
119+
120+
await context.setOffline(false);
121+
});
122+
123+
test('온라인: 기본 렌더링 스모크', async ({ page }) => {
124+
await page.goto('/');
125+
await expect(page).toHaveTitle(/119 Helper/);
126+
await expect(page.getByText('대시보드').first()).toBeVisible({ timeout: 15_000 });
127+
});

0 commit comments

Comments
 (0)