/* ===================================================================== full-sweep-shell.spec.ts — 2026-07-27 전수 순회 체크리스트 §10(shell) 중 "신규 spec 필요" 항목 검증. 대상: 톱바 브랜드 링크·로그아웃, body[data-role] 역할 테마, 인증 부트스트랩 BootScreen, lazy 라우트 Suspense fallback, PendingApprovalGate, 미정의 경로 리다이렉트, /dev/avatar-preview. 실 API(dev-login)를 기본으로 쓰고, 상태 고정이 필요한 곳만 page.route fixture(/auth/me·/auth/logout·lazy chunk)를 쓴다. ===================================================================== */ import { expect, test, type Page } from "@playwright/test"; import { signInAsAdmin, signInAsLearner, signInAsTeacher, useRealApi } from "./support"; const BOOT_SCREEN_TEXT = "불러오는 중…"; function meResponse(overrides: Record = {}) { return { user_id: "shell-e2e-user", email: "shell.e2e@hs.ac.kr", display_name: "Shell E2E", role: "learner", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1, onboarding_completed_at: 1, nickname: "Shell E2E", self_introduction: "", avatar_url: "", ...overrides, }; } async function mockMe(page: Page, overrides: Record = {}) { await page.route("**/api/auth/me", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(meResponse(overrides)), }); }); } test.describe("full sweep — shared shell, GNB, and routing guards", () => { test.beforeEach(async ({ page }) => { await useRealApi(page); }); // checklist: shell-topbar-brand-link // 로그인 상태에서 워드마크는 루트(/)로 링크되고, 클릭 시 RootRedirect가 // 역할 홈(/learn)으로 재분기한다. (미로그인 상태의 톱바는 정상 UI 흐름상 // 도달 불가 — 모든 셸 페이지가 RequireAuth 뒤에 있음 — 링크 목적지 규칙만 검증.) test("routes the topbar brand link back to the role home through the root redirect", async ({ page, }) => { await signInAsLearner(page); await page.goto("/learn"); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); const brand = page.locator(".vg-topbar__brand"); await expect(brand).toBeVisible(); await expect(brand).toHaveAttribute("href", "/"); await expect(brand).toContainText("Vignette"); await brand.click(); // "/" 진입 → RootRedirect가 학습자 홈으로 replace 이동 await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); }); // checklist: shell-topbar-logout // 로그아웃 버튼이 서버 세션을 실제로 무효화하고 /login으로 이동시킨다. test("logs out from the topbar and invalidates the server session", async ({ page }) => { await signInAsLearner(page); await page.goto("/learn"); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); await page.getByRole("button", { name: "로그아웃" }).click(); await expect(page).toHaveURL(/\/login$/); await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible(); // 서버 세션도 무효화되었는지 실 API로 확인 const me = await page.request.get("/api/auth/me"); expect(me.status()).toBe(401); // 보호 라우트 재진입 시 로그인으로 회수 await page.goto("/learn"); await expect(page).toHaveURL(/\/login$/); }); // checklist: shell-topbar-logout // 로그아웃 API가 실패해도 로컬 사용자 상태를 비워 /login으로 빠져나온다. test("still exits to the login screen when the logout API fails", async ({ page }) => { await signInAsLearner(page); await page.goto("/learn"); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); let logoutCalled = false; await page.route("**/api/auth/logout", async (route) => { logoutCalled = true; await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "logout unavailable" }), }); }); await page.getByRole("button", { name: "로그아웃" }).click(); await expect(page).toHaveURL(/\/login$/); await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible(); expect(logoutCalled).toBeTruthy(); }); // checklist: shell-appshell-role-theming // 역할별 셸 진입 시 body[data-role]이 learner/instructor/admin으로 설정된다. test("applies body[data-role] theming for each role shell", async ({ page }) => { await signInAsLearner(page); await page.goto("/learn"); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "learner"); await signInAsTeacher(page); await page.goto("/teach"); await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "instructor"); await signInAsAdmin(page); await page.goto("/admin"); await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "admin"); }); // checklist: shell-auth-bootstrap-loading // 세션 부트스트랩(/auth/me)이 끝나기 전에는 가드가 중립 BootScreen을 표시하고, // 401 확정 후에야 /login으로 이동한다. test("shows the neutral boot screen while the session bootstrap is pending", async ({ page, }) => { let releaseMe!: () => void; const gate = new Promise((resolve) => { releaseMe = resolve; }); await page.route("**/api/auth/me", async (route) => { await gate; await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ detail: "not authenticated" }), }); }); await page.goto("/learn"); await expect(page.getByText(BOOT_SCREEN_TEXT)).toBeVisible(); // 부트스트랩이 끝나지 않는 동안은 로그인으로 이동하지 않는다 await expect(page).toHaveURL(/\/learn$/); releaseMe(); await expect(page).toHaveURL(/\/login$/); await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible(); }); // checklist: shell-suspense-fallback // 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안 // Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현. test("shows the suspense fallback while a lazy route chunk is loading", async ({ page }) => { let releaseChunk!: () => void; const gate = new Promise((resolve) => { releaseChunk = resolve; }); await page.route("**/src/pages/Login.tsx*", async (route) => { await gate; await route.continue(); }); const meResolved = page.waitForResponse( (res) => res.url().includes("/api/auth/me"), { timeout: 15_000 }, ); await page.goto("/login", { waitUntil: "domcontentloaded" }); // 인증 부트스트랩이 끝난 뒤에도(가드 BootScreen 종료) 청크가 지연되는 동안 // Suspense fallback이 같은 중립 화면을 유지해야 한다. await meResolved; // React 18 Suspense는 재-서스펜드 시 기존 콘텐츠를 display:none으로 숨긴 채 // fallback을 추가하므로 '보이는' BootScreen 하나를 기준으로 검증한다. await expect(page.getByText(BOOT_SCREEN_TEXT).filter({ visible: true })).toHaveCount(1); releaseChunk(); await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible({ timeout: 10_000, }); await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveCount(0); }); // checklist: shell-guard-pending-approval // 미승인(account_status=pending) 사용자는 어떤 보호 경로에서든 /pending으로 회수된다. test("redirects a pending-approval user to /pending from any protected route", async ({ page, }) => { await mockMe(page, { account_status: "pending" }); await page.goto("/learn"); await expect(page).toHaveURL(/\/pending$/); await expect(page.locator(".pa-kicker")).toHaveText("승인 대기"); await expect(page.getByText("shell.e2e@hs.ac.kr")).toBeVisible(); await expect(page.getByRole("button", { name: "승인 상태 새로고침" })).toBeVisible(); await page.goto("/settings"); await expect(page).toHaveURL(/\/pending$/); await expect(page.locator(".pa-kicker")).toHaveText("승인 대기"); }); // checklist: shell-guard-pending-approval // 승인된 사용자가 /pending에 직접 오면 초기 경로(역할 홈)로 되돌린다. test("returns an approved user visiting /pending to their role home", async ({ page }) => { await signInAsLearner(page); await page.goto("/pending"); await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); }); // checklist: shell-unknown-route-redirect // 미정의 경로는 404 화면 없이 /로 replace되어 미인증은 /login, // 인증 학습자는 /learn으로 재분기된다. test("redirects unknown routes to the role-aware root destination", async ({ page }) => { await page.goto("/definitely/not-a-route"); await expect(page).toHaveURL(/\/login$/); await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible(); await signInAsLearner(page); await page.goto("/no-such-page"); await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); }); // checklist: shell-avatar-preview-dev-page // /dev/avatar-preview는 인증 없이 접근 가능한 dev 페이지로 // 4개 상태 애니메이션 히어로와 대표 표정 12종 그리드를 표시한다. test("serves the seoyeon avatar dev preview without authentication", async ({ page }) => { await page.goto("/dev/avatar-preview"); // 인증 가드에 걸리지 않고 그대로 렌더된다 await expect(page).toHaveURL(/\/dev\/avatar-preview$/); await expect( page.getByRole("heading", { name: "서연 아바타 미리보기 (SVG 파라미터 리그)" }), ).toBeVisible(); await expect(page.locator(".ap")).toHaveAttribute("data-avatar-rig", "svg-parameter"); // 4개 상태(speaking/idle/listening/thinking) 히어로 await expect(page.locator(".ap__hero > *")).toHaveCount(4); // 대표 표정 12종 그리드 await expect(page.locator(".ap__grid .ap__cell")).toHaveCount(12); // ClientAvatar 내부 figcaption(vg-avatar__label)과 구분하기 위해 직계 자식만 조회 await expect(page.locator(".ap__cell > figcaption").first()).toHaveText("neutral"); await expect(page.locator(".ap__cell > figcaption").last()).toHaveText("determined"); await expect(page.locator(".ap__count")).toContainText("총 표정 수:"); }); });