- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나 웹 진입점이 없던 2결함 - a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩 그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정 - uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정 - breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정 — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로 - 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN), 직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소, 교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기 - 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json, SSOT 체커 PASS(59)
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
/* =====================================================================
|
|
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 }) => {
|
|
// /auth/me만 fixture로 고정하면 나머지 실 API 호출(/api/sessions 등)이 쿠키 없이
|
|
// 401을 돌려주고, api 클라이언트의 AUTH_EXPIRED 이벤트가 AuthProvider의 user를
|
|
// 비워 body[data-role]을 제거한다(실제 세션 만료 계약). 역할 테마는 역할별 실
|
|
// dev-login 세션으로 검증한다.
|
|
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");
|
|
|
|
// 마우스 중심 desktop에서도 브랜드·공간 전환·테마·로그아웃은 같은 44px
|
|
// 조작면을 유지한다. 공개 배포가 28~34px으로 축소됐던 회귀를 desktop과
|
|
// mobile 양쪽에서 고정한다.
|
|
for (const viewport of [
|
|
{ width: 1280, height: 720 },
|
|
{ width: 1024, height: 768 },
|
|
{ width: 390, height: 844 },
|
|
{ width: 320, height: 568 },
|
|
]) {
|
|
await page.setViewportSize(viewport);
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
const topbar = await page.locator(".vg-topbar").evaluate((element) => {
|
|
const topbarRect = element.getBoundingClientRect();
|
|
const controls = Array.from(
|
|
element.querySelectorAll<HTMLElement>(
|
|
".vg-topbar__brand, .vg-topbar__switch-link, .vg-iconbtn",
|
|
),
|
|
).map((control) => {
|
|
const rect = control.getBoundingClientRect();
|
|
return {
|
|
label:
|
|
control.getAttribute("aria-label") ??
|
|
control.getAttribute("title") ??
|
|
control.textContent?.trim() ??
|
|
control.className,
|
|
left: rect.left,
|
|
right: rect.right,
|
|
top: rect.top,
|
|
bottom: rect.bottom,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
});
|
|
return {
|
|
controls,
|
|
topbarLeft: topbarRect.left,
|
|
topbarRight: topbarRect.right,
|
|
topbarTop: topbarRect.top,
|
|
topbarBottom: topbarRect.bottom,
|
|
viewportWidth: window.innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
};
|
|
});
|
|
|
|
const labels = topbar.controls.map((control) => control.label);
|
|
expect(labels.slice(0, 4)).toEqual([
|
|
"Vignette",
|
|
"학습자 공간",
|
|
"교수 콘솔",
|
|
"운영 콘솔",
|
|
]);
|
|
expect(labels[4]).toMatch(/^(라이트|다크) 모드로$/);
|
|
expect(labels[5]).toBe("로그아웃");
|
|
for (const control of topbar.controls) {
|
|
expect(control.width, `${control.label} ${viewport.width}px width`).toBeGreaterThanOrEqual(44);
|
|
expect(control.height, `${control.label} ${viewport.width}px height`).toBeGreaterThanOrEqual(44);
|
|
expect(control.left, `${control.label} ${viewport.width}px left`).toBeGreaterThanOrEqual(
|
|
topbar.topbarLeft,
|
|
);
|
|
expect(control.right, `${control.label} ${viewport.width}px right`).toBeLessThanOrEqual(
|
|
topbar.topbarRight,
|
|
);
|
|
expect(control.top, `${control.label} ${viewport.width}px top`).toBeGreaterThanOrEqual(
|
|
topbar.topbarTop,
|
|
);
|
|
expect(control.bottom, `${control.label} ${viewport.width}px bottom`).toBeLessThanOrEqual(
|
|
topbar.topbarBottom,
|
|
);
|
|
}
|
|
expect(topbar.scrollWidth).toBe(topbar.viewportWidth);
|
|
}
|
|
});
|
|
|
|
// 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-stale-chunk-recovery
|
|
// Pages 배포 전환 뒤 열린 탭의 Vite lazy 청크가 사라진 경우 한 번만 새 문서를
|
|
// 다시 받고, 정상 렌더 뒤 잠금을 해제한다. 같은 실패가 연속되면 무한 reload하지 않는다.
|
|
test("reloads once for a stale Vite route chunk without entering a reload loop", async ({
|
|
page,
|
|
}) => {
|
|
let documentRequests = 0;
|
|
page.on("request", (request) => {
|
|
if (request.isNavigationRequest()) documentRequests += 1;
|
|
});
|
|
|
|
await page.goto("/login");
|
|
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
|
|
expect(documentRequests).toBe(1);
|
|
|
|
const firstPrevented = await page.evaluate(() => {
|
|
const event = new Event("vite:preloadError", { cancelable: true });
|
|
Object.assign(event, {
|
|
payload: new TypeError("Failed to fetch dynamically imported module"),
|
|
});
|
|
return !window.dispatchEvent(event);
|
|
});
|
|
expect(firstPrevented).toBeTruthy();
|
|
|
|
await expect.poll(() => documentRequests).toBe(2);
|
|
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => sessionStorage.getItem("vignette:chunk-recovery:/login")),
|
|
)
|
|
.toBeNull();
|
|
|
|
const secondAttempt = await page.evaluate(() => {
|
|
sessionStorage.setItem("vignette:chunk-recovery:/login", "reload-attempted");
|
|
const event = new Event("vite:preloadError", { cancelable: true });
|
|
Object.assign(event, {
|
|
payload: new TypeError("Failed to fetch dynamically imported module"),
|
|
});
|
|
return {
|
|
prevented: !window.dispatchEvent(event),
|
|
marker: sessionStorage.getItem("vignette:chunk-recovery:/login"),
|
|
};
|
|
});
|
|
|
|
expect(secondAttempt).toEqual({ prevented: false, marker: "reload-attempted" });
|
|
await expect.poll(() => documentRequests).toBe(2);
|
|
});
|
|
|
|
// 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("총 표정 수:");
|
|
});
|
|
});
|