|
| 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