/* ===================================================================== uc-error-resilience-shell.spec.ts — 유스케이스: 오류 복원력·셸·프라이버시 표면 목적: 서버가 느리거나(지연), 끊기거나(단절), 5xx/401을 돌려주거나, 라우트 렌더 자체가 실패하는 상황에서 사용자가 · 빈 화면 대신 중립 로딩/자연어 오류/복구 수단을 보고 · 진행 중 입력·상태를 잃지 않고 · 스택트레이스·raw JSON·raw UUID·[NAME] 마스킹 토큰 같은 내부 표현을 화면에서 보지 않는지 를 실제 사용 행동 단위로 검증한다. 근거(실제 소스 계약): - src/App.tsx: AuthRestoreGate(auth-restore-retrying/failed/retry testid), RouteErrorBoundary + RouteErrorFallback("화면을 표시하지 못했습니다", route-error-reload), BootScreen("불러오는 중…"), RequireAuth. - src/lib/auth.tsx: restoreCurrentUser 재시도(0/400/1000ms), AUTH_EXPIRED_EVENT 수신 시 user를 비워 보호 라우트가 /login으로 회수. - src/lib/api.ts: ApiError 표준화, 401 시 notifyAuthExpired 이벤트. - src/lib/piiDisplay.ts: displayPiiSafeText — [NAME] 등 토큰의 조사 자연화. - src/pages/LearnerHome.tsx + learner-home/model.ts: 로딩 스켈레톤, "…불러오지 못했습니다" 자연어 오류, 빈 상태(role=status) 구분, toggleArchiveSession(archiveBusyId 이중 클릭 방지, 실패 notice), 대시보드 리캡의 displayPiiSafeText 적용. - src/pages/Session.tsx: personaLoadState missing/error 구분 문구 (sx-prestart__note), 회기 시작 게이트. - src/components/shell/Topbar.tsx: 오류 상태에서도 유지되는 공통 셸. 중복 회피: - full-sweep-shell.spec.ts: suspense fallback·stale chunk 1회 reload· unknown route·로그아웃 실패 시 /login 복귀·pending 가드 → 다루지 않음. - uc-session-conversation.spec.ts: 턴 전송 500 입력 보존·스트림 오류 재시도· 세션 자막의 [NAME] → 다루지 않음(여기는 대시보드 리캡 표면). - uc-settings-consent.spec.ts: 설정 저장 실패·철회 실패 → 다루지 않음. - uc-session-review-core.spec.ts: 리뷰 화면 raw UUID·워크시트 저장 실패 → 다루지 않음(여기는 학습자 홈 기록 표면). 전부 route fixture 기반(mocked-auth 패턴, catch-all 먼저)이며 실제 AI 엔진 턴 생성은 일절 없다. ===================================================================== */ import { expect, test, type Page } from "@playwright/test"; const NOW = Date.now(); const HOUR = 60 * 60 * 1000; const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString(); // raw UUID 비노출 검증용 — 화면 어디에도 그대로 보여선 안 되는 식별자. const ACTIVE_UUID = "9c8d7e6f-5a4b-4c3d-8e2f-1a0b9c8d7e6f"; const ENDED_UUID = "3f2b8a9e-6c1d-4e5f-9a7b-2c8d4e6f1a3b"; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } /** catch-all: fixture로 지정하지 않은 API는 404. 반드시 가장 먼저 등록. */ async function routeUnmockedApi(page: Page) { await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), ); } /** userFromMe(auth.tsx)가 읽는 필드를 빠짐없이 채운 승인 완료 학습자. */ const ME_LEARNER = { user_id: "e2e-uc-resilience-1", email: "learner@hs.ac.kr", role: "learner", display_name: "학습자", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "학습자", self_introduction: "", avatar_url: "", }; async function routeAuthedLearner(page: Page) { await page.route("**/api/auth/me", (route) => route.fulfill(jsonRoute(ME_LEARNER)), ); } const PERSONAS_BODY = [ { code: "P1", display_name: "성하늘", difficulty: "easy", theory_target: ["humanistic"], demographics: { age_band: "20대" }, presenting_summary: "학업 스트레스와 수면 문제를 호소", voice_preset: null, source: "database", degraded: false, }, { code: "P2", display_name: "김도윤", difficulty: "moderate", theory_target: ["cbt"], demographics: { age_band: "30대" }, presenting_summary: "직장 내 대인관계 갈등을 호소", voice_preset: null, source: "database", degraded: false, }, ]; function activeSession(sessionId: string) { return { session_id: sessionId, session_no: 2, persona_code: "P1", persona_name: "성하늘", status: "active", stage: "탐색", started_at: iso(1 * HOUR), ended_at: null, review_ready: false, archived: false, archived_at: null, turn_count: 6, learner_turn_count: 3, client_turn_count: 3, }; } function endedSession(sessionId: string, archived = false) { return { session_id: sessionId, session_no: 1, persona_code: "P2", persona_name: "김도윤", status: "ended", stage: "라포", started_at: iso(5 * HOUR), ended_at: iso(4.5 * HOUR), review_ready: false, archived, archived_at: archived ? iso(0.1 * HOUR) : null, turn_count: 4, learner_turn_count: 2, client_turn_count: 2, }; } const DASHBOARD_BODY = { message: "학습 현황 요약", source: "runtime", overview: { total_sessions: 2, active_sessions: 1, review_ready_sessions: 0, completed_sessions: 1, archived_sessions: 0, learner_turns: 5, client_turns: 5, last_practiced_at: iso(1 * HOUR), }, growth: { trend: "insufficient", avg_rapport: null, avg_score: null, evaluated_sessions: 0, latest_score: null, first_score: null, score_delta: null, top_techniques: [], points: [], }, recent_feedback: [], persona_progress: [], achievements: [], }; const EMPTY_DASHBOARD = { ...DASHBOARD_BODY, overview: { ...DASHBOARD_BODY.overview, total_sessions: 0, active_sessions: 0, completed_sessions: 0, learner_turns: 0, client_turns: 0, last_practiced_at: null, }, }; /** 학습자 홈 정상 데이터 fixture 일괄 등록 (catch-all·auth 포함). */ async function installLearnerFixtures( page: Page, options: { sessions?: () => unknown[]; dashboard?: unknown; } = {}, ) { const { sessions, dashboard = DASHBOARD_BODY } = options; await routeUnmockedApi(page); await routeAuthedLearner(page); await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(PERSONAS_BODY)), ); await page.route(/\/api\/sessions$/, (route) => route.fulfill( jsonRoute({ sessions: sessions ? sessions() : [activeSession("ucres-act1"), endedSession("ucres-end1")], source: "runtime", }), ), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(dashboard)), ); } /** Login.tsx가 GET /auth/config로 읽는 OAuth 준비 fixture. */ const READY_AUTH_CONFIG = { google_oauth_configured: true, saml_configured: false, providers: [ { provider: "google", configured: true, enabled: true, login_path: "/auth/login?provider=google", }, ], allowed_email_domains: [], redirect_uri: "http://127.0.0.1:8000/auth/callback", dev_login_enabled: false, }; test.describe("uc error-resilience-shell", () => { // usecase: 서버가 느릴 때 학습자는 빈 화면·오류 대신 중립 로딩 화면을 보다가, 응답이 오면 그대로 홈에 진입한다 test("인증 부트스트랩이 지연되는 동안 중립 로딩을 유지하고 응답 후 정상 진입한다", async ({ page, }) => { let releaseMe!: () => void; const meGate = new Promise((resolve) => { releaseMe = resolve; }); await routeUnmockedApi(page); await page.route("**/api/auth/me", async (route) => { await meGate; await route.fulfill(jsonRoute(ME_LEARNER)); }); await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(PERSONAS_BODY)), ); await page.route(/\/api\/sessions$/, (route) => route.fulfill( jsonRoute({ sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")], source: "runtime", }), ), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(DASHBOARD_BODY)), ); await page.goto("/learn"); // 응답 전: 중립 BootScreen만 보이고 오류·로그인 회수가 없다. await expect(page.getByText("불러오는 중…")).toBeVisible(); await expect(page).toHaveURL(/\/learn$/); await expect(page.getByTestId("auth-restore-failed")).toHaveCount(0); releaseMe(); // 응답 후: 같은 주소에서 학습자 홈이 그대로 열린다. await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); await expect(page).toHaveURL(/\/learn$/); }); // usecase: 서버 재구동 직후처럼 연결이 끊겼을 때 학습자는 세션을 잃었다는 오해 대신 재연결 안내와 복구 실패 진단을 본다 test("부트스트랩 연결 단절 시 재연결 안내를 거쳐 진단 화면과 재연결 버튼을 보여준다", async ({ page, }) => { // 1·2번째 시도는 즉시 실패, 3번째 시도는 게이트로 붙잡아 // '재연결 중' 화면을 결정론적으로 관찰한 뒤 실패를 확정한다. let attempts = 0; let releaseFinal!: () => void; const finalGate = new Promise((resolve) => { releaseFinal = resolve; }); await routeUnmockedApi(page); await page.route("**/api/auth/me", async (route) => { attempts += 1; if (attempts >= 3) await finalGate; await route.abort().catch(() => {}); }); await page.goto("/learn"); // 재시도 동안: 세션을 버리지 않는다는 재연결 안내(role=status). const retrying = page.getByTestId("auth-restore-retrying"); await expect(retrying).toBeVisible(); await expect(retrying).toContainText("서버 재연결"); await expect(retrying).toContainText("로그인 권한을 다시 확인하고 있습니다."); releaseFinal(); // 재시도 소진 후: 권한 상실이 아님을 못박는 진단 화면 + 재연결 버튼. const failed = page.getByTestId("auth-restore-failed"); await expect(failed).toBeVisible(); await expect(failed).toContainText("서버 연결 진단"); await expect(failed).toContainText("관리자 권한이 사라진 것이 아닙니다."); await expect(page.getByTestId("auth-restore-retry")).toBeVisible(); // 이 화면은 로그인으로 튕기지 않고 같은 주소에서 복구를 기다린다. await expect(page).toHaveURL(/\/learn$/); expect(attempts).toBe(3); }); // usecase: 서버가 정상화된 뒤 학습자가 '서버에 다시 연결'을 눌러 재로그인 없이 같은 세션으로 복귀한다 test("복구 실패 화면에서 서버 정상화 후 재연결 버튼으로 같은 세션에 복귀한다", async ({ page, }) => { let apiUp = false; await routeUnmockedApi(page); await page.route("**/api/auth/me", async (route) => { if (!apiUp) { await route.abort().catch(() => {}); return; } await route.fulfill(jsonRoute(ME_LEARNER)); }); await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(PERSONAS_BODY)), ); await page.route(/\/api\/sessions$/, (route) => route.fulfill( jsonRoute({ sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")], source: "runtime", }), ), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(DASHBOARD_BODY)), ); await page.goto("/learn"); await expect(page.getByTestId("auth-restore-failed")).toBeVisible({ timeout: 15_000, }); apiUp = true; await page.getByTestId("auth-restore-retry").click(); // 재로그인(/login) 없이 같은 주소에서 학습자 홈이 복원된다. await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".vg-topbar__uname")).toHaveText("학습자"); }); // usecase: 데이터 API가 느릴 때 학습자는 연습 화면에서 스켈레톤·확인 문구를 보다가 응답이 오면 목록으로 전환된다 test("연습 화면 데이터 지연 동안 스켈레톤을 보이다가 응답 후 목록으로 전환된다", async ({ page, }) => { let releaseData!: () => void; const dataGate = new Promise((resolve) => { releaseData = resolve; }); await routeUnmockedApi(page); await routeAuthedLearner(page); await page.route(/\/api\/personas$/, async (route) => { await dataGate; await route.fulfill(jsonRoute(PERSONAS_BODY)); }); await page.route(/\/api\/sessions$/, async (route) => { await dataGate; await route.fulfill( jsonRoute({ sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")], source: "runtime", }), ); }); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(DASHBOARD_BODY)), ); await page.goto("/learn/practice"); // 지연 동안: 페르소나 스켈레톤 3개 + 기록 확인 문구, 오류 문구는 없다. await expect(page.locator(".lh-persona.is-skel")).toHaveCount(3); await expect( page.getByText("저장된 연습 기록을 확인하고 있습니다."), ).toBeVisible(); await expect(page.locator("body")).not.toContainText("불러오지 못했습니다"); releaseData(); // 응답 후: 실제 페르소나 목록과 기존 회기 액션으로 전환된다. const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); await expect(listbox.getByRole("option", { name: /P1/ })).toBeVisible(); await expect(page.locator(".lh-persona.is-skel")).toHaveCount(0); await expect( page.locator(".lh-activity").getByRole("button", { name: "이어하기" }), ).toBeVisible(); }); // usecase: 네트워크가 끊겨도 학습자는 셸(톱바) 안에서 자연어 오류를 보고, 오류가 빈 기록 상태로 위장되지 않는다 test("네트워크 단절 시 자연어 오류를 표시하고 셸을 유지하며 빈 상태와 구분한다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await page.route(/\/api\/personas$/, (route) => route.abort()); await page.route(/\/api\/sessions$/, (route) => route.abort()); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.abort()); await page.goto("/learn/practice"); // 페르소나 목록 실패: role=alert 자연어 안내. const personaError = page.locator(".lh-error"); await expect(personaError).toBeVisible(); await expect(personaError).toContainText("내담자 목록을 불러오지 못했습니다."); await expect(personaError).toContainText("잠시 뒤 다시 시도해 주세요."); // 기록 실패: 인라인 자연어 안내. await expect( page.getByText("연습 기록을 불러오지 못했습니다."), ).toBeVisible(); // 실패를 '기록 없음' 빈 상태로 위장하지 않는다. await expect(page.locator("body")).not.toContainText( "저장된 기존 회기가 없습니다.", ); // 공통 셸은 살아 있어 로그아웃 등 탈출 경로가 유지된다. await expect(page.locator(".vg-topbar")).toBeVisible(); await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible(); }); // usecase: 서버가 스택트레이스가 담긴 5xx를 돌려줘도 학습자는 자연어 안내만 보고 내부 오류 본문은 보지 않는다 test("5xx 응답의 스택트레이스·raw JSON이 화면에 노출되지 않는다", async ({ page, }) => { const scaryBody = { detail: 'Traceback (most recent call last):\n File "app/routes/sessions.py", line 42, in list_sessions\nKeyError: \'session_rows\'', }; await routeUnmockedApi(page); await routeAuthedLearner(page); await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(scaryBody, 500)), ); await page.route(/\/api\/sessions$/, (route) => route.fulfill(jsonRoute(scaryBody, 500)), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => route.fulfill(jsonRoute(scaryBody, 500)), ); await page.goto("/learn"); // 자연어 오류 표면만 보인다. await expect( page.getByRole("heading", { name: "코칭을 만들 기록을 불러오지 못했습니다.", }), ).toBeVisible(); await expect( page.getByText("최근 평가 피드백을 불러오지 못했습니다."), ).toBeVisible(); await expect(page.locator(".lh-dashboard-feedback")).toContainText( "불러오기 실패", ); // 내부 오류 본문·상태 표현은 어디에도 없다. const body = page.locator("body"); await expect(body).not.toContainText("Traceback"); await expect(body).not.toContainText("KeyError"); await expect(body).not.toContainText('"detail"'); await expect(body).not.toContainText("API 500"); }); // usecase: 사용 중 세션이 만료(401)되면 진행하던 화면 대신 로그인 화면으로 안전하게 회수된다 test("사용 중 401이 발생하면 로그인 화면으로 복귀한다", async ({ page }) => { await installLearnerFixtures(page); await page.route("**/api/auth/config", (route) => route.fulfill(jsonRoute(READY_AUTH_CONFIG)), ); await page.route("**/api/sessions/ucres-end1/archive", (route) => route.fulfill(jsonRoute({ detail: "session expired" }, 401)), ); await page.goto("/learn/history"); const cards = page.locator(".lh-session-list"); await expect(cards).toBeVisible(); // 보관 액션이 401을 받으면 전역 인증 만료로 처리되어 /login으로 회수된다. await cards.getByRole("button", { name: "보관", exact: true }).click(); await expect(page).toHaveURL(/\/login$/); await expect( page.getByRole("button", { name: /Google 계정으로 계속/ }), ).toBeVisible(); }); // usecase: 보관이 서버 오류로 실패해도 학습자의 검색어와 기록 상태는 사라지지 않고 실패 안내를 받는다 test("보관 실패(500) 시 실패 안내가 뜨고 검색어·기록 상태가 보존된다", async ({ page, }) => { await installLearnerFixtures(page); await page.route("**/api/sessions/ucres-end1/archive", (route) => route.fulfill(jsonRoute({ detail: "internal error" }, 500)), ); await page.goto("/learn/history"); const search = page.getByRole("textbox", { name: "기록 검색" }); await search.fill("김도윤"); const list = page.locator(".lh-session-list"); await expect(list.locator(".lh-session-card")).toHaveCount(1); const archiveButton = list.getByRole("button", { name: "보관", exact: true }); await archiveButton.click(); // 실패 안내(role=status)와 함께 상태가 그대로 남는다. await expect( page.getByText("보관 상태를 바꾸지 못했습니다."), ).toBeVisible(); await expect(search).toHaveValue("김도윤"); await expect(archiveButton).toBeVisible(); await expect(archiveButton).toBeEnabled(); await expect(list.locator(".lh-session-card")).toHaveCount(1); // 실패인데 성공 문구로 위장하지 않는다. await expect(page.locator("body")).not.toContainText("회기를 보관했습니다."); }); // usecase: 학습자가 보관을 조급하게 연타해도 요청은 한 번만 나가고, 처리 중에는 버튼이 잠긴다 test("보관 더블클릭 시 요청이 1회만 전송되고 처리 중 버튼이 잠긴다", async ({ page, }) => { let archiveCalls = 0; let archivedNow = false; let releaseArchive!: () => void; const archiveGate = new Promise((resolve) => { releaseArchive = resolve; }); await installLearnerFixtures(page, { sessions: () => [ activeSession("ucres-act1"), endedSession("ucres-end1", archivedNow), ], }); await page.route("**/api/sessions/ucres-end1/archive", async (route) => { archiveCalls += 1; await archiveGate; archivedNow = true; await route.fulfill( jsonRoute({ session_id: "ucres-end1", archived: true, archived_at: iso(0), }), ); }); await page.goto("/learn/history"); const list = page.locator(".lh-session-list"); const archiveButton = list.getByRole("button", { name: "보관", exact: true }); await expect(archiveButton).toBeVisible(); await archiveButton.dblclick(); // 처리 중: 같은 자리 버튼이 '처리 중'으로 잠긴다. const busyButton = list.getByRole("button", { name: "처리 중" }); await expect(busyButton).toBeVisible(); await expect(busyButton).toBeDisabled(); releaseArchive(); // 성공 안내 후 재조회된 목록에서 카드가 '복원' 액션으로 바뀐다. await expect(page.getByText("회기를 보관했습니다.")).toBeVisible(); await expect( list.getByRole("button", { name: "복원", exact: true }), ).toBeVisible(); expect(archiveCalls).toBe(1); }); // usecase: 저장 마스킹된 [NAME] 토큰이 대시보드 리캡에서 자연스러운 한국어 문장으로 보인다 test("대시보드 리캡의 [NAME] 마스킹이 조사까지 자연어로 표시된다", async ({ page, }) => { await installLearnerFixtures(page); await page.route(`**/api/sessions/ucres-act1`, (route) => route.fulfill( jsonRoute({ case_id: "case-ucres", session_id: "ucres-act1", persona_code: "P1", persona_name: "성하늘", status: "active", stage: "탐색", started_at: iso(1 * HOUR), ended_at: null, review_ready: false, theory_mode: "humanistic", goal_stages: ["라포", "탐색"], effective_openness: 0.4, duration_limit_seconds: 1200, warning_before_end_seconds: 600, progress: null, turns: [ { turn_seq: 1, speaker: "learner", stage: "라포", text: "[NAME]은 지난주에 어떻게 지내셨어요?", created_at: iso(0.9 * HOUR), }, { turn_seq: 2, speaker: "client", stage: "라포", text: "요즘 [NAME]과 이야기할 때마다 마음이 무거워요.", created_at: iso(0.8 * HOUR), }, ], }), ), ); await page.goto("/learn"); await expect(page.locator(".lh-recap")).toBeVisible(); // 내담자 인용: [NAME]과 → 익명 내담자와 (받침 없는 라벨의 조사 선택). await expect(page.locator(".lh-recap__quote p")).toContainText( "익명 내담자와 이야기할 때마다 마음이 무거워요.", ); // 학습자 발화: [NAME]은 → 익명 내담자는. await expect(page.locator(".lh-recap__learner p")).toContainText( "익명 내담자는 지난주에 어떻게 지내셨어요?", ); // 마스킹 토큰 원문은 화면 어디에도 남지 않는다. await expect(page.locator("body")).not.toContainText("[NAME]"); }); // usecase: 기록 카드에는 내담자 이름·상태만 보이고 내부 세션 UUID는 노출되지 않는다 test("기록 화면에 raw 세션 UUID가 노출되지 않는다", async ({ page }) => { await installLearnerFixtures(page, { sessions: () => [activeSession(ACTIVE_UUID), endedSession(ENDED_UUID)], }); await page.goto("/learn/history"); const cards = page.locator(".lh-session-list .lh-session-card"); await expect(cards).toHaveCount(2); await expect(cards.first()).toContainText("성하늘"); await expect(cards.last()).toContainText("김도윤"); // 사람이 읽는 표면에 내부 UUID가 그대로 보이지 않는다. const body = page.locator("body"); await expect(body).not.toContainText(ACTIVE_UUID); await expect(body).not.toContainText(ENDED_UUID); }); // usecase: 기록이 정말 0건일 때는 오류가 아니라 빈 상태 안내로 첫 회기를 이끈다 test("기록 0건은 오류가 아닌 빈 상태(role=status)로 안내한다", async ({ page, }) => { await installLearnerFixtures(page, { sessions: () => [], dashboard: EMPTY_DASHBOARD, }); await page.goto("/learn/history"); const empty = page.locator(".lh-activity__empty"); await expect(empty).toBeVisible(); await expect(empty).toContainText("저장된 기존 회기가 없습니다."); await expect(empty).toContainText( "첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.", ); await expect(page.locator(".lh-history-main__head")).toContainText("0개"); // 빈 상태를 오류 문구로 표현하지 않는다. await expect(page.locator("body")).not.toContainText("불러오지 못했습니다"); }); // usecase: 존재하지 않는 내담자 딥링크로 들어온 학습자는 '목록에 없음' 안내를 받고 회기 시작이 잠긴다 test("없는 페르소나 딥링크는 '목록에 없음' 안내와 함께 시작을 차단한다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(PERSONAS_BODY)), ); await page.route("**/api/voice/health", (route) => route.fulfill(jsonRoute({ available: false, reason: null })), ); await page.route("**/api/users/me/prepost-measures**", (route) => route.fulfill( jsonRoute({ pilot_id: "phase3-pilot-draft", instrument_version: "pilot-prepost-scaffold-2026-06-28", measures: [], complete_pre_count: 0, complete_post_count: 0, updated_at: null, }), ), ); await page.goto("/learn/session/ZZ99"); await expect(page.locator(".sx-page--prestart")).toBeVisible(); // 존재하지 않음(missing)은 서버 오류와 구분되는 전용 문구로 안내된다. await expect(page.locator(".sx-prestart__note")).toHaveText( "ZZ99 페르소나는 현재 연습 목록에 없습니다. 학습자 홈에서 사용 가능한 페르소나를 선택해 주세요.", ); await expect( page.getByRole("button", { name: "회기 시작" }), ).toBeDisabled(); }); // usecase: 연습 목록 조회가 실패하면 '없음'이 아니라 '불러오지 못함'으로 구분해 안내하고 시작을 잠근다 test("페르소나 목록 로드 실패는 missing과 구분되는 오류 문구로 안내한다", async ({ page, }) => { await routeUnmockedApi(page); await routeAuthedLearner(page); await page.route(/\/api\/personas$/, (route) => route.abort()); await page.route("**/api/voice/health", (route) => route.fulfill(jsonRoute({ available: false, reason: null })), ); await page.route("**/api/users/me/prepost-measures**", (route) => route.fulfill( jsonRoute({ pilot_id: "phase3-pilot-draft", instrument_version: "pilot-prepost-scaffold-2026-06-28", measures: [], complete_pre_count: 0, complete_post_count: 0, updated_at: null, }), ), ); await page.goto("/learn/session/P1"); await expect(page.locator(".sx-page--prestart")).toBeVisible(); await expect(page.locator(".sx-prestart__note")).toHaveText( "연습 목록을 불러오지 못했습니다. 잠시 뒤 다시 시도해 주세요.", ); await expect( page.getByRole("button", { name: "회기 시작" }), ).toBeDisabled(); // 로드 실패를 '목록에 없음'으로 단정하지 않는다. await expect(page.locator("body")).not.toContainText( "페르소나는 현재 연습 목록에 없습니다", ); }); // usecase: 라우트 렌더가 예외로 죽으면 빈 화면 대신 진단 화면을 보고, 복구 버튼으로 정상 화면에 돌아온다 test("라우트 렌더 실패 시 오류 경계 진단 화면이 뜨고 다시 불러오기로 복구된다", async ({ page, }) => { let chunkDown = true; await installLearnerFixtures(page); // dev 모듈 URL 차단으로 lazy 라우트 렌더 실패를 재현한다. await page.route("**/src/pages/LearnerHome.tsx*", async (route) => { if (chunkDown) { await route.abort().catch(() => {}); return; } await route.continue(); }); await page.goto("/learn"); // RouteErrorBoundary 진단 화면: 제목·경로·복구 액션. // (index.html 부트 워치독 패널도 role=alert를 잠깐 띄울 수 있어 문구로 한정) const fallback = page .getByRole("alert") .filter({ hasText: "화면을 표시하지 못했습니다" }); await expect(fallback).toBeVisible({ timeout: 15_000 }); await expect( fallback.getByRole("heading", { name: "화면을 표시하지 못했습니다" }), ).toBeVisible(); await expect(fallback).toContainText("화면 진단"); await expect(fallback).toContainText("/learn"); await expect( fallback.getByRole("link", { name: "홈으로 이동" }), ).toBeVisible(); // 모듈이 다시 제공되면 '최신 버전 다시 불러오기'로 정상 화면에 복귀한다. chunkDown = false; await page.getByTestId("route-error-reload").click(); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); await expect(page).toHaveURL(/\/learn$/); }); });