전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리 (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가. RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset 루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉 마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤 스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등. - 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규 학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual), 저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence + 결정론 파생 폴백). - 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의 CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect. - 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest 421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과, layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드 142~144차 노트.
This commit is contained in:
parent
0ae94499f2
commit
4511383cd9
55 changed files with 9333 additions and 573 deletions
269
apps/web/e2e/full-sweep-shell.spec.ts
Normal file
269
apps/web/e2e/full-sweep-shell.spec.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/* =====================================================================
|
||||
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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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<void>((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<void>((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("총 표정 수:");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue