feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -26,7 +26,7 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e
Feature evidence map:
- `session-persistence.spec.ts` is the primary DB-backed evidence for session runtime flows. It covers browser `openSessionStream()` persistence, Korean PII masking through the browser stream into DB-backed detail/review payloads, crisis learner-only safety persistence without a client AI reply, AI tutor live coach history, source-pack metadata round-trip, voice metadata persistence, learner worksheet/review persistence, session-end evaluation storage, explicit teacher session/turn reevaluation, and manual teacher UI evaluation retry from a real failed row into durable DB state. The AI tutor history test must see `status=ready` and `latency_ms>0`; degraded fallback must not pass as normal engine-backed coaching.
- `session-persistence.spec.ts` is the primary DB-backed evidence for session runtime flows. It covers browser `openSessionStream()` persistence, Korean PII masking through the browser stream into DB-backed detail/review payloads, crisis learner-only safety persistence without a client AI reply, AI tutor live coach history, source-pack metadata round-trip, voice metadata persistence, learner worksheet/review persistence, session-end evaluation storage, explicit teacher session/turn reevaluation, and fail-closed admin engine configuration that rejects an unreachable gateway without mutating the durable setting. The AI tutor history test must see `status=ready` and `latency_ms>0`; degraded fallback must not pass as normal engine-backed coaching.
- `kb-source-packs.spec.ts` is DB-backed source-pack sync evidence. It checks admin-only sync and source-scoped evaluator RAG lookup for the licensed source packs; evaluator retrieval 503 is a failure, not a skipped proof.
- `session-review.spec.ts` is route-fixture UI regression evidence for review states, including delayed `평가 대기` to `평가 완료` polling, `평가 실패`, `AI 평가 재시도`, and pre/post input validation states. It does not prove that the evaluator wrote a DB row unless paired with `session-persistence.spec.ts`.
- `teacher.spec.ts` mixes DB-backed teacher console paths with route-fixture queue/readability checks. Use the individual test body before citing it as persisted evidence.
@ -60,6 +60,19 @@ $env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-admin-auth.json"
npx playwright test e2e/public-admin-visual.spec.ts --project=chromium-public-auth
```
이 스모크에는 공식 EasyList의 과거 충돌 규칙(`.ad-root`, `.ad-section`)을 첫 paint부터
주입한 상태로 운영 홈·AI 운영·사용자·권한·티켓 5경로의 실제 가시성을 확인하는 게이트가 포함된다.
API 200, DOM 존재, selector count만으로는 통과하지 않는다.
Production lazy-chunk recovery smoke (no sign-in state required):
```powershell
$env:E2E_PREVIEW_BUILD="1"
$env:PLAYWRIGHT_SKIP_WEB_SERVER="1"
$env:PLAYWRIGHT_BASE_URL="https://vignette.chanpaca.net"
npx playwright test e2e/chunk-recovery-preview.spec.ts --project=chromium-single-run --workers=1
```
Notes:
- `E2E_PUBLIC_AUTH=1` targets the public site and does not start the local Vite web server.

View file

@ -1,4 +1,4 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import {
completeOnboarding,
expectNoHorizontalOverflow,
@ -99,6 +99,7 @@ interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort: string | null;
source: "database" | "runtime_cache" | "runtime_default";
durable: boolean;
updated_by: string | null;
@ -174,9 +175,11 @@ async function mockAdminSession(
adminTickets?: unknown;
adminUsage?: AdminUsageResponse;
engineConfig?: AdminEngineConfigResponse;
authFailuresBeforeSuccess?: number;
} = {},
) {
const seenAdminEndpoints = new Set<string>();
let authAttempts = 0;
const json = (body: unknown) => JSON.stringify(body);
await page.route("**/api/**", async (route) => {
@ -192,6 +195,11 @@ async function mockAdminSession(
});
if (method === "GET" && path.endsWith("/auth/me")) {
authAttempts += 1;
if (authAttempts <= (options.authFailuresBeforeSuccess ?? 0)) {
await fulfillJson({ detail: "public runtime is starting" }, 503);
return;
}
await fulfillJson({
user_id: authUser.user_id ?? "stale-admin",
email: authUser.email ?? "stale-admin@twentyoz.kr",
@ -268,6 +276,64 @@ async function mockAdminSession(
return;
}
if (method === "GET" && path.endsWith("/admin/engine-capabilities")) {
seenAdminEndpoints.add("engine-capabilities");
const provider = url.searchParams.get("engine_mode") ?? "openai";
const models =
provider === "codex_cli"
? [
{
id: "gpt-5.6-terra",
label: "GPT-5.6 Terra",
description: "Codex 기본 모델",
reasoning_efforts: ["low", "medium", "high"],
default_reasoning_effort: "medium",
is_default: true,
},
]
: provider === "agy_cli"
? [
{
id: "gemini-3.6-flash-high",
label: "Gemini 3.6 Flash (High)",
description: "Agy 기본 모델",
reasoning_efforts: ["high"],
default_reasoning_effort: "high",
is_default: true,
},
]
: [
{
id: "gateway-default",
label: "게이트웨이 기본 모델",
description: "테스트 기본 모델",
reasoning_efforts: ["medium"],
default_reasoning_effort: "medium",
is_default: true,
},
{
id: "gpt-5.1-mini",
label: "GPT-5.1 Mini",
description: "테스트 선택 모델",
reasoning_efforts: ["low", "medium", "high"],
default_reasoning_effort: "medium",
is_default: false,
},
];
const defaultModel = models[0];
await fulfillJson({
provider,
available: true,
source: "live_cli",
models,
default_model: defaultModel.id,
default_reasoning_effort: defaultModel.default_reasoning_effort,
detail: "테스트 모델 목록",
fetched_at: 1_783_990_800,
});
return;
}
if (method === "GET" && path.endsWith("/admin/engine-config")) {
seenAdminEndpoints.add("engine-config");
await fulfillJson(
@ -275,6 +341,7 @@ async function mockAdminSession(
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
reasoning_effort: "medium",
source: "database",
durable: true,
updated_by: "admin@twentyoz.kr",
@ -292,6 +359,7 @@ async function mockAdminSession(
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
reasoning_effort: "medium",
}),
...body,
source: "database",
@ -565,7 +633,7 @@ async function openAdminTickets(page: Page) {
}
async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
const form = page.locator(".ad-user-create");
const form = page.locator(".vgops-user-create");
await expect(form).toBeVisible();
const clippedControls = await form.evaluate((element) => {
@ -619,7 +687,7 @@ async function expectVisibleButtonsFit(page: Page, selector: string, context: st
.map((button) => {
const rect = button.getBoundingClientRect();
const owner =
button.closest<HTMLElement>(".ad-user,.ad-user-create,.ad-user-table tbody tr") ??
button.closest<HTMLElement>(".vgops-user,.vgops-user-create,.vgops-user-table tbody tr") ??
button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
@ -661,7 +729,7 @@ test.describe("admin route guards", () => {
await expect(page).toHaveURL(/\/admin$/);
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.locator(".ad-status")).toContainText("개발");
await expect(page.locator(".vgops-status")).toContainText("개발");
await expect
.poll(() => Array.from(seenAdminEndpoints).sort())
.toEqual(["health", "tickets", "uptime", "usage", "users"]);
@ -688,6 +756,118 @@ test.describe("admin route guards", () => {
.toEqual(["health", "tickets", "uptime", "usage", "users"]);
});
test("restores every admin page for admin, super-admin, and delegated admin sessions", async ({
page,
}) => {
const adminRoutes = [
{ path: "/admin", heading: "현재 서비스 상태" },
{ path: "/admin/ai", heading: "AI 운영과 DB 계량" },
{ path: "/admin/users", heading: "가입 승인과 권한 관리" },
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위" },
{ path: "/admin/tickets", heading: "사용자 문제 큐" },
] as const;
const sessions = [
{
user_id: "role-admin",
email: "role-admin@twentyoz.kr",
display_name: "Role Admin",
role: "admin" as const,
admin_access: false,
super_admin: false,
},
{
user_id: "super-admin",
email: "super-admin@twentyoz.kr",
display_name: "Super Admin",
role: "learner" as const,
admin_access: false,
super_admin: true,
},
{
user_id: "delegated-admin",
email: "delegated-admin@hs.ac.kr",
display_name: "Delegated Admin",
role: "teacher" as const,
admin_access: true,
super_admin: false,
},
] as const;
for (const session of sessions) {
await page.unrouteAll({ behavior: "wait" });
await mockAdminSession(page, {
...session,
onboarding_completed_at: 1_782_900_000,
});
for (const route of adminRoutes) {
await page.goto(route.path);
await expect(page).toHaveURL(new RegExp(`${route.path.replace("/", "\\/")}$`));
await expect(page.getByRole("heading", { name: route.heading })).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "AI 운영" })).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "사용자" })).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "권한" })).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "티켓" })).toBeVisible();
}
}
});
test("keeps the admin entry visible after a primary-role super-admin leaves the admin workspace", async ({
page,
}) => {
await mockAdminSession(page, {
user_id: "primary-role-super-admin",
email: "primary-role-super-admin@twentyoz.kr",
display_name: "Primary Role Super Admin",
role: "learner",
admin_access: false,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
});
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await page.locator(".vg-nav").getByRole("link", { name: "학습자 홈" }).click();
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" })).toBeVisible();
await page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" }).click();
await expect(page).toHaveURL(/\/admin$/);
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
});
test("keeps the session recoverable when auth restore starts during a server restart", async ({
page,
}) => {
await mockAdminSession(
page,
{
user_id: "restart-admin",
email: "restart-admin@twentyoz.kr",
display_name: "Restart Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
},
{ authFailuresBeforeSuccess: 3 },
);
await page.goto("/admin");
await expect(page.getByTestId("auth-restore-failed")).toBeVisible();
await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible();
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
await page.getByTestId("auth-restore-retry").click();
await expect(page).toHaveURL(/\/admin$/);
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.getByTestId("auth-restore-failed")).toHaveCount(0);
});
test("shows detailed AI metering and saves the real engine configuration", async ({ page }) => {
const seenAdminEndpoints = await mockAdminSession(
page,
@ -755,11 +935,14 @@ test.describe("admin route guards", () => {
"page",
);
await expect(page.getByText("운영 DB 원장").first()).toBeVisible();
await expect(page.locator(".aic-ledger")).toContainText("$6.6212");
// 2026-07-27 D7: 합계 금액은 화면에 소수 2자리로 표시하고 원본 정밀도는 title 로 옮겼다.
await expect(page.locator(".aic-ledger")).toContainText("$6.62");
await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /6\.6212/);
await expect(page.locator(".aic-budget")).toContainText("93.8%");
await expect(page.locator(".aic-table")).toContainText("gpt-5-mini");
await expect(page.locator(".aic-cache-score")).toContainText("80%");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6);
const usageRequest = page.waitForRequest((request) =>
request.url().includes("/api/admin/usage?window_days=7"),
@ -767,17 +950,40 @@ test.describe("admin route guards", () => {
await page.getByRole("button", { name: "7일" }).click();
await usageRequest;
await page.getByLabel("AI 기본 모델").fill("gpt-5.1-mini");
await page.getByLabel("AI 엔진 공급자").selectOption("codex_cli");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gpt-5.6-terra");
await expect(page.getByLabel("AI 추론 강도")).toHaveValue("medium");
await page.getByLabel("AI 엔진 공급자").selectOption("agy_cli");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gemini-3.6-flash-high");
await expect(page.getByLabel("AI 추론 강도")).toHaveValue("high");
await page.getByLabel("AI 연결 주소").fill("http://127.0.0.1:9199");
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeDisabled();
const catalogRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
url.pathname.endsWith("/api/admin/engine-capabilities") &&
url.searchParams.get("engine_url") === "http://127.0.0.1:9199"
);
});
await page.getByRole("button", { name: "목록 새로고침" }).click();
await catalogRequest;
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gemini-3.6-flash-high");
const patchRequest = page.waitForRequest((request) =>
request.method() === "PATCH" && request.url().endsWith("/api/admin/engine-config"),
);
await page.getByRole("button", { name: "운영 설정 저장" }).click();
const request = await patchRequest;
expect(request.postDataJSON()).toMatchObject({ model: "gpt-5.1-mini" });
expect(request.postDataJSON()).toMatchObject({
engine_mode: "agy_cli",
engine_url: "http://127.0.0.1:9199",
model: "gemini-3.6-flash-high",
reasoning_effort: "high",
});
await expect(page.getByText("저장됨")).toBeVisible();
await expect
.poll(() => Array.from(seenAdminEndpoints).sort())
.toEqual(["engine-config", "engine-config-patch", "health", "usage"]);
.toEqual(["engine-capabilities", "engine-config", "engine-config-patch", "health", "usage"]);
await expectNoHorizontalOverflow(page);
});
@ -817,10 +1023,12 @@ test.describe("admin route guards", () => {
await page.goto("/admin/users");
await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible();
const scrolled = await page.evaluate(() => {
document.documentElement.style.minHeight = "2200px";
document.body.style.minHeight = "2200px";
window.scrollTo(0, 900);
return window.scrollY;
const main = document.querySelector<HTMLElement>(".vg-main");
const root = document.querySelector<HTMLElement>(".vgops-root");
if (!main || !root) throw new Error("admin scroll container missing");
root.style.minHeight = "2200px";
main.scrollTop = 900;
return main.scrollTop;
});
expect(scrolled).toBeGreaterThan(0);
@ -828,9 +1036,13 @@ test.describe("admin route guards", () => {
await expect(page).toHaveURL(/\/admin\/access$/);
await expect
.poll(() => page.evaluate(() => window.scrollY))
.poll(() =>
page.evaluate(
() => document.querySelector<HTMLElement>(".vg-main")?.scrollTop ?? -1,
),
)
.toBe(0);
await expect(page.locator(".ad-root")).toBeInViewport();
await expect(page.locator(".vgops-root")).toBeInViewport();
await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeVisible();
});
@ -848,10 +1060,12 @@ test.describe("admin route guards", () => {
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
const scrolled = await page.evaluate(() => {
document.documentElement.style.minHeight = "2200px";
document.body.style.minHeight = "2200px";
window.scrollTo(0, 900);
return window.scrollY;
const main = document.querySelector<HTMLElement>(".vg-main");
const root = document.querySelector<HTMLElement>(".vgops-root");
if (!main || !root) throw new Error("admin scroll container missing");
root.style.minHeight = "2200px";
main.scrollTop = 900;
return main.scrollTop;
});
expect(scrolled).toBeGreaterThan(0);
@ -860,12 +1074,44 @@ test.describe("admin route guards", () => {
});
await expect
.poll(() => page.evaluate(() => window.scrollY))
.poll(() =>
page.evaluate(
() => document.querySelector<HTMLElement>(".vg-main")?.scrollTop ?? -1,
),
)
.toBe(0);
await expect(page.locator(".ad-root")).toBeInViewport();
await expect(page.locator(".vgops-root")).toBeInViewport();
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
});
test("keeps the admin console visible under EasyList cosmetic filters", async ({ page }) => {
await mockAdminSession(page, {
user_id: "cosmetic-filter-admin",
email: "cosmetic-filter-admin@twentyoz.kr",
display_name: "Cosmetic Filter Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
});
await page.goto("/admin");
await page.addStyleTag({
// EasyList general cosmetic rules contain both selectors. They used to
// hide the whole operations console while every admin API still returned 200.
content: ".ad-root,.ad-section{display:none!important}",
});
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.locator("[data-vignette-admin-root]")).toBeVisible();
await expect(page.locator('[class^="ad-"],[class*=" ad-"]')).toHaveCount(0);
// 첫 watchdog 판정(3.5초)이 실제 픽셀 가시성을 확인한 뒤에도 진단 화면이
// 뜨지 않아야 한다. DOM 존재 여부만 확인하면 이번 장애를 재현하지 못한다.
await page.waitForTimeout(4_000);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
});
test("shows admin data diagnostics instead of a blank main pane", async ({ page }) => {
await mockAdminSession(
page,
@ -902,9 +1148,9 @@ test.describe("admin route guards", () => {
await page.goto("/admin/users");
await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible();
await expect(page.locator(".ad-diagnostic")).toContainText("관리자 데이터 진단");
await expect(page.locator(".ad-diagnostic")).toContainText("cohort_ids");
await expect(page.locator(".ad-diagnostic")).toContainText("active_sessions");
await expect(page.locator(".vgops-diagnostic")).toContainText("관리자 데이터 진단");
await expect(page.locator(".vgops-diagnostic")).toContainText("cohort_ids");
await expect(page.locator(".vgops-diagnostic")).toContainText("active_sessions");
await expect(page.getByText("Bad User")).toBeVisible();
});
@ -933,8 +1179,8 @@ test.describe("admin route guards", () => {
await page.goto("/admin/users");
await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible();
await expect(page.locator(".ad-diagnostic")).toContainText("관리자 데이터 진단");
await expect(page.locator(".ad-diagnostic")).toContainText("admin.tickets.summary");
await expect(page.locator(".vgops-diagnostic")).toContainText("관리자 데이터 진단");
await expect(page.locator(".vgops-diagnostic")).toContainText("admin.tickets.summary");
await expect(page.locator("body")).not.toHaveText(/^$/);
});
});
@ -950,7 +1196,7 @@ test.describe("admin route", () => {
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
const { health, usage, users, uptime, tickets } = await openAdminAndReadHealth(page);
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
const serviceCards = page.locator(".vgops-service:not(.vgops-service--skeleton)");
const counts = {
ok: health.services.filter((service) => service.status === "ok").length,
degraded: health.services.filter((service) => service.status === "degraded").length,
@ -964,17 +1210,17 @@ test.describe("admin route", () => {
const onlineUsers = users.users.filter((user) => isOnline(user.last_seen_at)).length;
await expect(page).toHaveURL(/\/admin$/);
await expect(page.locator(".ad-status")).toContainText(environmentLabel(health.environment));
await expect(page.locator(".ad-status")).toContainText(engineModeLabel(health.engine_mode));
await expect(page.locator(".ad-kpi b")).toHaveText([
await expect(page.locator(".vgops-status")).toContainText(environmentLabel(health.environment));
await expect(page.locator(".vgops-status")).toContainText(engineModeLabel(health.engine_mode));
await expect(page.locator(".vgops-kpi b")).toHaveText([
countLabel(activeSessions),
countLabel(onlineUsers),
countLabel(users.users.length),
counts.total ? `${counts.ok}/${counts.total}` : "-",
]);
await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible();
await expect(page.locator(".ad-cost")).toContainText(costLabel(usage.cost_usd));
await expect(page.locator(".ad-cost")).toContainText(
await expect(page.locator(".vgops-cost")).toContainText(costLabel(usage.cost_usd));
await expect(page.locator(".vgops-cost")).toContainText(
usage.budget.status === "disabled"
? "예산 경고 비활성"
: usage.budget.status === "exceeded"
@ -983,17 +1229,17 @@ test.describe("admin route", () => {
? "예산 주의"
: "예산 정상",
);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText(
"평가 캐시 hit-rate",
);
const cache = evaluatorCache(usage);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText(
cache.enabled
? `${rateLabel(cache.hit_rate)} hit`
: "캐시 비활성",
);
if (usageDailyCost(usage).length > 0) {
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText(
"일별 비용 추이",
);
}
@ -1003,7 +1249,7 @@ test.describe("admin route", () => {
expect(uptime.ok_ratio).toBeLessThanOrEqual(1);
await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible();
expect(tickets.summary.open_count).toBeGreaterThanOrEqual(0);
await expect(page.locator(".ad-panel").filter({ hasText: "운영 티켓" })).toContainText(
await expect(page.locator(".vgops-panel").filter({ hasText: "운영 티켓" })).toContainText(
/(\d+건 미해결|미해결 티켓이 없습니다)/,
);
await expect(serviceCards).toHaveCount(health.services.length);
@ -1128,10 +1374,10 @@ test.describe("admin route", () => {
const approvalTab = page.getByRole("tab", { name: /가입 승인/ });
await approvalTab.click();
await expect(approvalTab).toHaveAttribute("aria-selected", "true");
const approvalCard = page.locator(".ad-approval").filter({ hasText: email });
const approvalCard = page.locator(".vgops-approval").filter({ hasText: email });
await expect(approvalCard).toBeVisible();
await expect(approvalCard).toContainText("승인 대기");
await expectVisibleButtonsFit(page, ".ad-approval__actions .vg-btn", "admin approval buttons");
await expectVisibleButtonsFit(page, ".vgops-approval__actions .vg-btn", "admin approval buttons");
const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id));
await approvalCard.getByRole("button", { name: "승인" }).click();
@ -1147,11 +1393,11 @@ test.describe("admin route", () => {
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
const card = page.locator(".ad-user-table tbody tr").filter({ hasText: email });
const card = page.locator(".vgops-user-table tbody tr").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card.getByLabel(`${email} 표시 이름`)).toHaveValue(displayName);
await expect(card).toContainText("승인됨");
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
await expectVisibleButtonsFit(page, ".vgops-user__actions .vg-btn", "admin user action buttons");
const nextName = `교수자 ${testInfo.project.name}`;
const nameInput = card.getByLabel(`${email} 표시 이름`);
@ -1220,7 +1466,7 @@ test.describe("admin route", () => {
expect(tickets.durable).toBe(true);
expect(tickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBeTruthy();
const card = page.locator(".ad-ticket").filter({ hasText: subject });
const card = page.locator(".vgops-ticket").filter({ hasText: subject });
await expect(card).toBeVisible();
await expect(card).toContainText("높음");
await expect(card).toContainText("미해결");
@ -1277,7 +1523,7 @@ test.describe("admin route", () => {
expect(createdTickets.some((ticket) => (ticket.duplicate_count ?? 0) > 0)).toBeTruthy();
const linkableCard = page
.locator(".ad-ticket")
.locator(".vgops-ticket")
.filter({ hasText: subject })
.filter({ has: page.getByRole("button", { name: "연결" }) });
await expect(linkableCard).toBeVisible();
@ -1293,7 +1539,7 @@ test.describe("admin route", () => {
const updated = (await patchResponse.json()) as AdminSupportTicket;
expect(updated.parent_ticket_id).toBeTruthy();
await expect(page.locator(".ad-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible();
await expect(page.locator(".vgops-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible();
await expectNoHorizontalOverflow(page);
} finally {
for (const ticketId of createdTicketIds) {
@ -1326,7 +1572,7 @@ test.describe("admin route", () => {
await page.goto("/admin");
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".ad-root")).toHaveCount(0);
await expect(page.locator(".vgops-root")).toHaveCount(0);
});
test("keeps admin controls usable at a mobile viewport", async ({ page }) => {
@ -1337,7 +1583,7 @@ test.describe("admin route", () => {
const users = await openAdminAndReadUsers(page);
await page.getByRole("tab", { name: "사용자 등록" }).click();
const layout = await page.evaluate(() => {
const form = document.querySelector<HTMLElement>(".ad-user-create");
const form = document.querySelector<HTMLElement>(".vgops-user-create");
if (!form) throw new Error("admin user create form was not rendered");
return {
formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length,
@ -1349,7 +1595,7 @@ test.describe("admin route", () => {
expect(layout.formColumns).toBe(1);
if (users.users.length > 0) {
await page.getByRole("tab", { name: "사용자 목록" }).click();
const scrollRegion = page.locator(".ad-user-table-scroll");
const scrollRegion = page.locator(".vgops-user-table-scroll");
const scrollContract = await scrollRegion.evaluate((element) => {
const before = element.scrollLeft;
element.scrollLeft = element.scrollWidth;
@ -1363,7 +1609,7 @@ test.describe("admin route", () => {
expect(scrollContract.scrollWidth).toBeGreaterThan(scrollContract.clientWidth);
expect(scrollContract.after).toBeGreaterThan(scrollContract.before);
await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible();
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
await expectVisibleButtonsFit(page, ".vgops-user__actions .vg-btn", "mobile admin user actions");
}
});

View file

@ -0,0 +1,886 @@
/* =====================================================================
breakpoint-sweep.spec.ts .
:
layout-visual-gate.spec.ts 7(390/720/861/900/1024/1280/1440)
"문서 가로 오버플로 0" . 3 .
(1) 7 721~1080px 0
(2) 1041~1240px 195px
(3) "초기화" 1180~1257px
0 , 7 "경계 안쪽" .
:
1. src .css @media min-width/max-width px .
( CSS )
2. B B-1 / B / B+1
.
3. / / · / / 5
DOM .
4. · · · · .
실행: npx playwright test e2e/breakpoint-sweep.spec.ts --reporter=list
( npm run e2e:single-run @single-run )
===================================================================== */
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import {
fetchAvailablePersona,
signInAsAdmin,
signInAsLearner,
signInAsTeacher,
} from "./support";
/*
1) CSS
*/
const SRC_DIR = path.join(process.cwd(), "src");
/** 뷰포트 클램프 범위 — 320px 미만/1600px 초과 폭은 지원 대상이 아니다. */
const MIN_WIDTH = 320;
const MAX_WIDTH = 1600;
/** 실기기 대표 폭. 브레이크포인트 경계와 무관하게 항상 확인한다. */
const DEVICE_WIDTHS = [320, 360, 375, 390, 414, 768, 820, 1024, 1280, 1366, 1440, 1536];
/** 뷰포트 높이 — 폭 스윕이 목적이라 높이는 고정해 결과를 결정적으로 만든다. */
const SWEEP_HEIGHT = 900;
/** 한 라우트에서 확인할 최대 폭 수 — 런타임 상한. */
const MAX_WIDTHS_PER_ROUTE = 44;
/** src 아래 모든 .css 파일을 posix 상대경로로 수집. */
async function collectCssFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const out: string[] = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await collectCssFiles(full)));
else if (entry.name.endsWith(".css")) out.push(full);
}
return out;
}
function toKey(fullPath: string) {
return path.relative(SRC_DIR, fullPath).split(path.sep).join("/");
}
/**
* @media min-width/max-width px .
* CSS (settings.css )
* .
*/
function extractBreakpoints(css: string): number[] {
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, "");
const found = new Set<number>();
const mediaRe = /@media([^{]+)\{/g;
let media: RegExpExecArray | null;
while ((media = mediaRe.exec(stripped)) !== null) {
const featureRe = /\((?:min|max)-width:\s*(\d+(?:\.\d+)?)px\)/g;
let feature: RegExpExecArray | null;
while ((feature = featureRe.exec(media[1])) !== null) {
found.add(Math.round(Number(feature[1])));
}
}
return [...found].sort((a, b) => a - b);
}
/*
2) CSS
*/
/** 전 페이지 공통으로 취급하는 CSS. 여기 브레이크포인트는 모든 라우트에 적용된다. */
const COMMON_CSS = new Set([
"styles/global.css",
"styles/tokens.css",
"components/shell/shell.css",
"components/auth/auth-shell.css",
"components/ui/ui.css",
]);
/** 페이지 키 → 그 페이지가 소유한 CSS 파일. 여기 없는 CSS 는 공통으로 fallback 한다. */
const PAGE_CSS: Record<string, string[]> = {
login: ["pages/login/login.css"],
onboarding: ["pages/onboarding.css"],
pending: ["pages/pending-approval.css"],
"avatar-preview": ["pages/avatar-preview.css", "components/avatar/client-avatar.css"],
"learner-home": ["pages/learner-home.css"],
"avatar-lab": ["pages/avatar-expression-lab.css", "components/avatar/client-avatar.css"],
session: ["pages/session/session.css", "components/avatar/client-avatar.css"],
"session-review": ["pages/session-review/session-review.css"],
professor: ["pages/professor.css"],
"persona-studio": ["pages/persona-studio.css"],
"admin-console": ["pages/admin/admin-console.css"],
"admin-ai": ["pages/admin/admin-ai.css"],
settings: ["pages/settings/settings.css"],
};
interface BreakpointIndex {
/** 공통 CSS + 매핑되지 않은 CSS 에서 나온 브레이크포인트. */
common: number[];
/** 페이지 키별 고유 브레이크포인트. */
byPage: Record<string, number[]>;
/** 어떤 페이지에도 매핑되지 않아 공통으로 승격된 CSS(디버깅용). */
unmapped: string[];
}
async function buildBreakpointIndex(): Promise<BreakpointIndex> {
const files = await collectCssFiles(SRC_DIR);
const perFile = new Map<string, number[]>();
for (const file of files) {
perFile.set(toKey(file), extractBreakpoints(await fs.readFile(file, "utf8")));
}
const owned = new Set<string>();
for (const list of Object.values(PAGE_CSS)) for (const key of list) owned.add(key);
const common = new Set<number>();
const unmapped: string[] = [];
for (const [key, values] of perFile) {
if (owned.has(key)) continue;
// 공통 CSS 이거나, 아직 어떤 페이지에도 매핑되지 않은 새 CSS → 전 페이지 공통 취급.
// (매핑 누락 때문에 커버리지가 조용히 사라지는 것보다 과잉 커버가 안전하다)
if (!COMMON_CSS.has(key)) unmapped.push(key);
for (const value of values) common.add(value);
}
const byPage: Record<string, number[]> = {};
for (const [page, list] of Object.entries(PAGE_CSS)) {
const set = new Set<number>();
for (const key of list) {
const values = perFile.get(key);
expect(values, `PAGE_CSS 매핑이 가리키는 ${key} 가 src 에 없다`).toBeDefined();
for (const value of values ?? []) set.add(value);
}
byPage[page] = [...set].sort((a, b) => a - b);
}
return { common: [...common].sort((a, b) => a - b), byPage, unmapped };
}
const clampWidth = (value: number) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value));
/** 브레이크포인트 목록 → B-1 / B / B+1 + 실기기 폭 (중복 제거, 클램프, 상한 적용). */
function widthsFor(breakpoints: number[]): number[] {
const boundary = new Set<number>();
for (const bp of breakpoints) {
for (const delta of [-1, 0, 1]) boundary.add(clampWidth(bp + delta));
}
const devices = DEVICE_WIDTHS.map(clampWidth).filter((w) => !boundary.has(w));
const ordered = [...boundary].sort((a, b) => a - b);
let merged = [...ordered, ...devices];
if (merged.length > MAX_WIDTHS_PER_ROUTE) {
// 상한을 넘으면 실기기 폭을 먼저 유지하고(사용자가 실제로 보는 폭),
// 경계 폭은 균등 간격으로 솎아 낸다 — 경계 3연폭 세트는 최대한 함께 남긴다.
const keep = new Set<number>(devices);
const budget = MAX_WIDTHS_PER_ROUTE - keep.size;
const step = Math.max(1, Math.ceil(ordered.length / Math.max(1, budget)));
for (let i = 0; i < ordered.length; i += step) keep.add(ordered[i]);
merged = [...keep];
}
return [...new Set(merged)].sort((a, b) => a - b);
}
/*
3)
*/
interface Finding {
kind: "overlap" | "clip-x" | "clip-y" | "collapse" | "offscreen" | "doc-overflow";
element: string;
detail: string;
}
/**
* .
* 5 , 4 .
* - -webkit-line-clamp ( )
* - visually-hidden (width/height 1px, clip: rect(...))
* - border-radius >= 40px
* - line-height content-area 1~2px
*/
function scanLayoutDefects(): Finding[] {
const doc = document.documentElement;
const viewportWidth = doc.clientWidth;
const findings: Finding[] = [];
const all = Array.from(document.querySelectorAll<HTMLElement>("body *"));
const styles = new Map<Element, CSSStyleDeclaration>();
const rects = new Map<Element, DOMRect>();
// body 도 overflow 경계가 될 수 있으므로 캐시에 포함한다(순회 대상은 아니다).
for (const el of [document.body, ...all]) {
styles.set(el, window.getComputedStyle(el));
rects.set(el, el.getBoundingClientRect());
}
const cs = (el: Element) => styles.get(el) ?? window.getComputedStyle(el);
const rc = (el: Element) => rects.get(el) ?? el.getBoundingClientRect();
function classOf(el: Element) {
const raw = (el as HTMLElement).className as unknown;
const source =
typeof raw === "string" ? raw : raw && typeof raw === "object" && "baseVal" in raw ? String((raw as SVGAnimatedString).baseVal) : "";
return source.trim().split(/\s+/).filter(Boolean).slice(0, 3).join(".");
}
/** 요소 식별 문자열. 클래스가 없으면 부모 클래스를 붙여 디버깅 가능한 좌표를 만든다. */
function describe(el: Element) {
const cls = classOf(el);
const tag = el.tagName.toLowerCase();
const anchor = cls
? `${tag}.${cls}`
: `${tag}${el.parentElement && classOf(el.parentElement) ? `@${classOf(el.parentElement)}` : ""}`;
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 40);
return `${anchor}${text ? ` "${text}"` : ""}`;
}
const visibleCache = new Map<Element, boolean>();
function isVisible(el: Element): boolean {
const cached = visibleCache.get(el);
if (cached !== undefined) return cached;
const style = cs(el);
const rect = rc(el);
let result = true;
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) result = false;
else if (rect.width <= 0 && rect.height <= 0) result = false;
else if (el.getAttribute("aria-hidden") === "true") result = false;
else if (el.hasAttribute("hidden") || el.hasAttribute("inert")) result = false;
else if (el.parentElement && el.parentElement !== document.body && !isVisible(el.parentElement)) result = false;
visibleCache.set(el, result);
return result;
}
/** 장식 레이어: 히트테스트 대상이 아니거나 접근성 트리에서 숨겨진 요소. */
function isDecorative(el: Element) {
return cs(el).pointerEvents === "none" || el.getAttribute("aria-hidden") === "true";
}
/** 오탐 제외 (2): visually-hidden 패턴 (1px 박스 / clip / clip-path inset(50%)). */
function isVisuallyHidden(el: Element) {
const style = cs(el);
const rect = rc(el);
if (rect.width <= 2 && rect.height <= 2) return true;
if (style.clip && style.clip !== "auto") return true;
if (style.clipPath && style.clipPath.includes("inset(50%")) return true;
return false;
}
/** 오탐 제외 (1): -webkit-line-clamp 가 걸린 요소는 세로 자름이 의도다. */
function hasLineClamp(el: Element) {
const style = cs(el);
const value =
style.getPropertyValue("-webkit-line-clamp") ||
(style as unknown as { webkitLineClamp?: string }).webkitLineClamp ||
"none";
return value !== "none" && value !== "" && value !== "0";
}
/** 오탐 제외 (3): border-radius 40px 이상 원형 마스크(아바타 스테이지 등). */
function hasCircularMask(el: Element) {
const style = cs(el);
return (
["borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius"] as const
).some((key) => Number.parseFloat(style[key]) >= 40);
}
/** 오탐 제외 (4): line-height < fontSize * 1.15 → 1~2px 세로 오버슛은 글리프가 안 잘린다. */
function hasTightLineHeight(el: Element) {
const style = cs(el);
const fontSize = Number.parseFloat(style.fontSize) || 16;
const lineHeight = style.lineHeight === "normal" ? fontSize * 1.2 : Number.parseFloat(style.lineHeight) || fontSize * 1.2;
return lineHeight < fontSize * 1.15;
}
function hasDirectText(el: Element) {
for (const node of Array.from(el.childNodes)) {
if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim()) return true;
}
return false;
}
const clipsX = (s: CSSStyleDeclaration) => s.overflowX === "hidden" || s.overflowX === "clip";
const clipsY = (s: CSSStyleDeclaration) => s.overflowY === "hidden" || s.overflowY === "clip";
const scrollsX = (s: CSSStyleDeclaration) => s.overflowX === "auto" || s.overflowX === "scroll";
const scrollsY = (s: CSSStyleDeclaration) => s.overflowY === "auto" || s.overflowY === "scroll";
const isOverflowBoundary = (s: CSSStyleDeclaration) => clipsX(s) || clipsY(s) || scrollsX(s) || scrollsY(s);
/* (A)
overflow hidden/clip .
scrollWidth/scrollHeight ::after ( offset
, : .lh-session-focus::after) "잘림" .
"in-flow 자손 rect vs client box"
. absolute/fixed pointer-events:none .
성능: 컨테이너마다 O(n^2) .
"최근접 overflow 경계 조상" (O(n)),
. . */
const clipCandidate = (el: Element) => {
const style = cs(el);
if (!clipsX(style) && !clipsY(style)) return false;
if (!isVisible(el) || isVisuallyHidden(el) || hasCircularMask(el)) return false;
const tag = el.tagName.toLowerCase();
// 폼 컨트롤은 설계상 자기 값을 스크롤한다(키보드로 전부 도달 가능).
if (tag === "input" || tag === "textarea" || tag === "select") return false;
return el.clientWidth > 0 || el.clientHeight > 0;
};
// 최근접 overflow 경계(또는 containing-block 을 바꾸는 absolute/fixed 조상).
const nearestBoundary = new Map<Element, Element | null>();
for (const el of all) {
const parent = el.parentElement;
if (!parent || !styles.has(parent)) {
nearestBoundary.set(el, null);
continue;
}
const parentStyle = cs(parent);
if (isOverflowBoundary(parentStyle) || parentStyle.position === "absolute" || parentStyle.position === "fixed") {
nearestBoundary.set(el, parent);
} else {
nearestBoundary.set(el, nearestBoundary.get(parent) ?? null);
}
}
// 컨테이너별 최악 오버슛만 남긴다(요소 하나당 한 줄 보고).
const worstClipX = new Map<Element, { overshoot: number; node: Element }>();
const worstClipY = new Map<Element, { overshoot: number; node: Element }>();
const clientBox = (el: Element) => {
const style = cs(el);
const rect = rc(el);
const left = rect.left + (Number.parseFloat(style.borderLeftWidth) || 0);
const top = rect.top + (Number.parseFloat(style.borderTopWidth) || 0);
return { left, top, right: left + el.clientWidth, bottom: top + el.clientHeight };
};
for (const el of all) {
if (!isVisible(el) || isDecorative(el) || isVisuallyHidden(el)) continue;
const style = cs(el);
if (style.position === "absolute" || style.position === "fixed") continue;
const boundary = nearestBoundary.get(el);
if (!boundary || !clipCandidate(boundary)) continue;
const rect = rc(el);
if (rect.width <= 0 || rect.height <= 0) continue;
const boundaryStyle = cs(boundary);
const box = clientBox(boundary);
const overshootX = Math.max(rect.right - box.right, box.left - rect.left);
const overshootY = Math.max(rect.bottom - box.bottom, box.top - rect.top);
if (clipsX(boundaryStyle) && boundaryStyle.textOverflow !== "ellipsis" && overshootX > 1) {
const prev = worstClipX.get(boundary);
if (!prev || overshootX > prev.overshoot) worstClipX.set(boundary, { overshoot: overshootX, node: el });
}
if (
clipsY(boundaryStyle) &&
!hasLineClamp(boundary) &&
overshootY > 1 &&
// 오탐 제외 (4): line-height 가 content-area 보다 작아 생기는 1~2px 오버슛.
!(overshootY <= 2 && hasTightLineHeight(el))
) {
const prev = worstClipY.get(boundary);
if (!prev || overshootY > prev.overshoot) worstClipY.set(boundary, { overshoot: overshootY, node: el });
}
}
for (const [boundary, worst] of worstClipX) {
findings.push({
kind: "clip-x",
element: describe(boundary),
detail: `자식 ${describe(worst.node)} 이(가) 가로로 ${worst.overshoot.toFixed(1)}px 잘림 (clientWidth ${boundary.clientWidth}px)`,
});
}
for (const [boundary, worst] of worstClipY) {
findings.push({
kind: "clip-y",
element: describe(boundary),
detail: `자식 ${describe(worst.node)} 이(가) 세로로 ${worst.overshoot.toFixed(1)}px 잘림 (clientHeight ${boundary.clientHeight}px)`,
});
}
// 자식 요소 없이 텍스트만 담은 잎 노드는 rect 비교가 불가능하므로
// scrollWidth/scrollHeight 로 자기 콘텐츠가 잘렸는지 본다.
for (const el of all) {
if (el.children.length > 0 || !clipCandidate(el)) continue;
const style = cs(el);
const dx = Math.ceil(el.scrollWidth - el.clientWidth);
const dy = Math.ceil(el.scrollHeight - el.clientHeight);
if (clipsX(style) && style.textOverflow !== "ellipsis" && dx > 1) {
findings.push({
kind: "clip-x",
element: describe(el),
detail: `자기 텍스트가 가로로 ${dx}px 잘림 (scrollWidth ${el.scrollWidth} > clientWidth ${el.clientWidth})`,
});
}
if (clipsY(style) && !hasLineClamp(el) && dy > 1 && !(dy <= 2 && hasTightLineHeight(el))) {
findings.push({
kind: "clip-y",
element: describe(el),
detail: `자기 텍스트가 세로로 ${dy}px 잘림 (scrollHeight ${el.scrollHeight} > clientHeight ${el.clientHeight})`,
});
}
}
/* (B) /
rect 1px .
( 0 ) */
for (const el of all) {
if (!isVisible(el) || !hasDirectText(el) || isVisuallyHidden(el)) continue;
const rect = rc(el);
if (rect.width < 1 || rect.height < 1) {
findings.push({
kind: "collapse",
element: describe(el),
detail: `텍스트가 있는데 박스가 붕괴 (width ${rect.width.toFixed(2)}px, height ${rect.height.toFixed(2)}px)`,
});
}
}
/* (C)
.
. */
const interactiveSelector = "button,a[href],input,select,textarea,[role='button'],[role='tab']";
function hasHorizontalScrollAncestor(el: Element) {
let node: Element | null = el.parentElement;
while (node && node !== document.body) {
const style = cs(node);
if (scrollsX(style) && node.scrollWidth - node.clientWidth > 1) return true;
node = node.parentElement;
}
return false;
}
for (const el of Array.from(document.querySelectorAll<HTMLElement>(interactiveSelector))) {
if (!styles.has(el) || !isVisible(el) || isVisuallyHidden(el)) continue;
const rect = rc(el);
if (rect.width <= 0 || rect.height <= 0) continue;
if (rect.left >= -1 && rect.right <= viewportWidth + 1) continue;
if (hasHorizontalScrollAncestor(el)) continue;
findings.push({
kind: "offscreen",
element: describe(el),
detail: `조작 요소가 뷰포트 밖 (left ${Math.round(rect.left)}px, right ${Math.round(rect.right)}px, viewport ${viewportWidth}px)`,
});
}
/* ── (D) 문서 가로 오버플로 ──────────────────────────────────────── */
const documentOverflow = Math.ceil(doc.scrollWidth - viewportWidth);
if (documentOverflow > 1) {
findings.push({
kind: "doc-overflow",
element: "html",
detail: `문서가 가로로 ${documentOverflow}px 넘침 (scrollWidth ${doc.scrollWidth} > clientWidth ${viewportWidth})`,
});
}
/* (E)
in-flow rect . absolute/fixed/sticky,
pointer-events:none, aria-hidden, float/transform,
grid-template-areas ( ) . */
const parents = new Set<Element>();
for (const el of all) if (el.parentElement) parents.add(el.parentElement);
for (const parent of parents) {
const parentStyle = parent === document.body ? window.getComputedStyle(parent) : cs(parent);
if (parentStyle.gridTemplateAreas && parentStyle.gridTemplateAreas !== "none") continue;
const siblings = Array.from(parent.children).filter((el) => {
if (!styles.has(el) || !isVisible(el)) return false;
const style = cs(el);
if (style.position !== "static" && style.position !== "relative") return false;
if (style.pointerEvents === "none" || style.float !== "none" || style.transform !== "none") return false;
if (isDecorative(el) || isVisuallyHidden(el)) return false;
const rect = rc(el);
return rect.width > 2 && rect.height > 2;
});
for (let i = 0; i < siblings.length; i += 1) {
for (let j = i + 1; j < siblings.length; j += 1) {
const a = rc(siblings[i]);
const b = rc(siblings[j]);
const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (overlapX > 2 && overlapY > 2) {
findings.push({
kind: "overlap",
element: `${describe(siblings[i])}${describe(siblings[j])}`,
detail: `형제 요소가 ${overlapX.toFixed(1)}x${overlapY.toFixed(1)}px 겹침 (부모 ${describe(parent)})`,
});
}
}
}
}
return findings;
}
/*
4) &
*/
interface RouteCase {
/** 리포트에 찍히는 이름. */
label: string;
/** PAGE_CSS 키 — 이 라우트가 상속할 브레이크포인트 집합. */
page: keyof typeof PAGE_CSS;
url: string;
/** 렌더 완료 판정 셀렉터. */
ready: string;
}
/** 폭 변경 후 레이아웃이 안정될 때까지 대기 (rAF 2회 + 폰트/차트 여유). */
async function settleLayout(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await page.waitForTimeout(90);
}
interface WidthFailure {
width: number;
finding: Finding;
}
/*
4-1) (quarantine) "이미 알려진 앱 CSS 결함"
. CSS
,
"무엇을 눈감아 주고 있는지" .
- . ···
.
.
- CSS "재현되지 않음"
.
- "검출기 자기검증" . */
interface KnownAppDefect {
route: string;
kind: Finding["kind"];
element: RegExp;
minWidth: number;
maxWidth: number;
note: string;
}
/* 2026-07-27: 3 CSS .
( 7 3 "격리 항목 미재현" )
- learner-home/dashboard · clip-x · .lh-recap__avatar (320~380px)
learner-home.css: 그리드 + .vg-avatar min-width:0/max-width:100%
- learner-home/practice · collapse · b@lh-session-card__title (1181~1200px)
learner-home.css: .lh-preview__hero flex-wrap
- persona-studio · clip-x · .ps-active-table (761~900px)
persona-studio.css: 트랙 + .ps-usage-table
. CSS . */
const KNOWN_APP_DEFECTS: KnownAppDefect[] = [];
function matchKnownDefect(routeLabel: string, width: number, finding: Finding) {
return KNOWN_APP_DEFECTS.find(
(known) =>
known.route === routeLabel &&
known.kind === finding.kind &&
known.element.test(finding.element) &&
width >= known.minWidth &&
width <= known.maxWidth,
);
}
/** 격리 항목이 실제로 재현됐는지 추적 — 재현되지 않으면 목록에서 지우라고 알린다. */
const quarantineHits = new Set<KnownAppDefect>();
/** 한 라우트를 모든 폭에서 스윕하고 결함을 모아 반환한다. */
async function sweepRoute(page: Page, route: RouteCase, widths: number[]): Promise<WidthFailure[]> {
await page.goto(route.url, { waitUntil: "domcontentloaded" });
await expect(page.locator(route.ready).first(), `[${route.label}] 렌더 대기 실패: ${route.ready}`).toBeVisible({
timeout: 20_000,
});
await settleLayout(page);
const failures: WidthFailure[] = [];
for (const width of widths) {
await page.setViewportSize({ width, height: SWEEP_HEIGHT });
await settleLayout(page);
await page.evaluate(() => {
window.scrollTo(0, 0);
const main = document.querySelector<HTMLElement>(".vg-main");
if (main) main.scrollTop = 0;
});
const findings = await page.evaluate(scanLayoutDefects);
for (const finding of findings) {
const known = matchKnownDefect(route.label, width, finding);
if (known) {
quarantineHits.add(known);
console.log(
`[breakpoint-sweep][격리된 앱 결함] ${route.label} @ ${width}px [${finding.kind}] ${finding.element}${finding.detail}`,
);
continue;
}
failures.push({ width, finding });
}
}
return failures;
}
function formatFailures(route: RouteCase, widths: number[], failures: WidthFailure[]) {
const lines = failures
.slice(0, 40)
.map((f) => ` · ${route.label} @ ${f.width}px [${f.finding.kind}] ${f.finding.element}${f.finding.detail}`);
const more = failures.length > 40 ? `\n · … 외 ${failures.length - 40}` : "";
return `[${route.label}] ${widths.length}개 폭(${widths[0]}~${widths[widths.length - 1]}px) 스윕에서 레이아웃 결함 ${failures.length}\n${lines.join("\n")}${more}`;
}
let index: BreakpointIndex;
test.beforeAll(async () => {
index = await buildBreakpointIndex();
});
/** 페이지 키에 해당하는 최종 폭 목록(공통 + 페이지 고유). */
function widthsForPage(page: keyof typeof PAGE_CSS) {
return widthsFor([...new Set([...index.common, ...(index.byPage[page] ?? [])])]);
}
async function runSweep(page: Page, routes: RouteCase[]) {
const report: string[] = [];
let total = 0;
for (const route of routes) {
const widths = widthsForPage(route.page);
const failures = await sweepRoute(page, route, widths);
if (failures.length) {
total += failures.length;
report.push(formatFailures(route, widths, failures));
}
}
expect(total, `브레이크포인트 스윕 결함\n\n${report.join("\n\n")}`).toBe(0);
}
test.describe("브레이크포인트 경계 스윕 @single-run", () => {
test("추출한 브레이크포인트가 CSS 를 실제로 반영한다", async () => {
// 검출기가 아니라 "무엇을 볼지" 를 정하는 파서/매핑의 회귀 방지.
// 값 자체는 하드코딩하지 않고 "구조가 살아 있는가" 만 본다.
expect(index.common.length, "공통 CSS(shell/global/auth-shell 등)에서 브레이크포인트를 하나도 못 찾았다").toBeGreaterThan(0);
const distinct = new Set(index.common);
for (const values of Object.values(index.byPage)) for (const value of values) distinct.add(value);
expect(distinct.size, "CSS 전체에서 추출한 브레이크포인트가 비정상적으로 적다 — 파서가 깨졌을 수 있다").toBeGreaterThan(10);
for (const pageKey of Object.keys(PAGE_CSS) as Array<keyof typeof PAGE_CSS>) {
const widths = widthsForPage(pageKey);
expect(Math.min(...widths), `${pageKey}: 클램프 하한 위반`).toBeGreaterThanOrEqual(MIN_WIDTH);
expect(Math.max(...widths), `${pageKey}: 클램프 상한 위반`).toBeLessThanOrEqual(MAX_WIDTH);
expect(widths.length, `${pageKey}: 라우트당 폭 상한 초과`).toBeLessThanOrEqual(MAX_WIDTHS_PER_ROUTE);
// 실기기 대표 폭은 어떤 페이지에서도 빠지면 안 된다.
expect(widths, `${pageKey}: 실기기 대표 폭 누락`).toEqual(expect.arrayContaining(DEVICE_WIDTHS));
// 브레이크포인트 B 마다 B-1/B/B+1 이 살아 있는지(클램프 경계 제외).
for (const bp of index.byPage[pageKey] ?? []) {
if (bp <= MIN_WIDTH || bp >= MAX_WIDTH) continue;
if (widths.length >= MAX_WIDTHS_PER_ROUTE) continue; // 상한으로 솎아 낸 경우는 예외
expect(widths, `${pageKey}: 브레이크포인트 ${bp}px 경계 3연폭 누락`).toEqual(
expect.arrayContaining([bp - 1, bp, bp + 1]),
);
}
}
if (index.unmapped.length) {
// 실패시키지 않고 로그만 — 새 CSS 는 공통으로 승격돼 이미 전 라우트에서 커버된다.
console.log(`[breakpoint-sweep] PAGE_CSS 에 없는 CSS(공통 승격): ${index.unmapped.join(", ")}`);
}
for (const pageKey of Object.keys(PAGE_CSS) as Array<keyof typeof PAGE_CSS>) {
console.log(`[breakpoint-sweep] ${pageKey}: ${widthsForPage(pageKey).length}`);
}
});
/**
* .
*
* "검출기가 살아 있다" .
* ( .)
* 3 CSS ,
* .
* (1) 0 collapse
* (2) clip-x
* (3) "초기화" offscreen
*/
test("검출기가 되살린 실제 결함 3건을 실제로 잡는다", async ({ page }) => {
test.setTimeout(240_000);
async function findingsWith(url: string, ready: string, width: number, css: string) {
await page.goto(url, { waitUntil: "domcontentloaded" });
await expect(page.locator(ready).first()).toBeVisible({ timeout: 20_000 });
await page.setViewportSize({ width, height: SWEEP_HEIGHT });
await settleLayout(page);
const clean = await page.evaluate(scanLayoutDefects);
const handle = await page.addStyleTag({ content: css });
await settleLayout(page);
const dirty = await page.evaluate(scanLayoutDefects);
await handle.evaluate((node) => {
(node as HTMLStyleElement).remove();
});
return { clean, dirty };
}
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page);
// (1) 설정 서브네비 라벨이 721~1080px 구간에서 폭 0 이 되던 결함.
const railLabels = await findingsWith(
"/settings",
".vg-set",
900,
".vg-set__rail button span{width:0;overflow:hidden;display:inline-block;}",
);
expect(
railLabels.dirty.filter((f) => f.kind === "collapse").length,
`설정 서브네비 라벨 붕괴를 못 잡았다: ${JSON.stringify(railLabels.dirty)}`,
).toBeGreaterThanOrEqual(3);
// (2) 회기 프리스타트 우측 패널이 1041~1240px 구간에서 잘리던 결함.
const prestart = await findingsWith(
`/learn/session/${persona.code}`,
".sx-head",
1100,
".vg-main__inner{overflow:hidden;} .sx-prestart{width:1400px;}",
);
expect(
prestart.dirty.filter((f) => f.kind === "clip-x").length,
`프리스타트 패널 잘림을 못 잡았다: ${JSON.stringify(prestart.dirty)}`,
).toBeGreaterThanOrEqual(1);
// (3) 티켓 "초기화" 버튼이 1180~1257px 구간에서 화면 밖으로 나가던 결함.
await signInAsAdmin(page);
const ticketButton = await findingsWith(
"/admin/tickets",
".vgops-root",
1220,
".vgops-ticket-filter .vg-btn:last-child{position:relative;left:400px;}",
);
expect(
ticketButton.dirty.filter((f) => f.kind === "offscreen").length,
`티켓 버튼 화면 밖 이탈을 못 잡았다: ${JSON.stringify(ticketButton.dirty)}`,
).toBeGreaterThanOrEqual(1);
// 주입 전에는 같은 결함이 없어야 한다 — 오탐 노이즈로 통과하는 것을 막는다.
expect(railLabels.clean.filter((f) => f.kind === "collapse"), "주입 전 설정 화면에 붕괴 오탐").toEqual([]);
expect(prestart.clean.filter((f) => f.kind === "clip-x"), "주입 전 프리스타트에 잘림 오탐").toEqual([]);
expect(ticketButton.clean.filter((f) => f.kind === "offscreen"), "주입 전 티켓 화면에 이탈 오탐").toEqual([]);
});
test("비인증 화면(로그인·아바타 프리뷰)이 모든 경계 폭에서 온전하다", async ({ page }) => {
test.setTimeout(240_000);
await runSweep(page, [
{ label: "login", page: "login", url: "/login", ready: ".lg-root" },
{ label: "avatar-preview", page: "avatar-preview", url: "/dev/avatar-preview", ready: ".ap" },
]);
});
test("가입 게이트 화면(온보딩·승인대기)이 모든 경계 폭에서 온전하다", async ({ page }) => {
test.setTimeout(240_000);
// 온보딩 미완료 사용자를 만들면 OnboardingGate 가 /onboarding 으로 보낸다.
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: `sweep.onboarding.${Date.now()}@hs.ac.kr`,
role: "learner",
display_name: "Sweep Onboarding",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
await runSweep(page, [
{ label: "onboarding", page: "onboarding", url: "/onboarding", ready: ".ob-page" },
]);
// 승인 대기 화면은 계정 상태에 의존하므로 /auth/me 응답만 최소로 덮어쓴다.
// email 도 함께 짧게 바꾼다 — 이 화면은 이메일을 그대로 노출하는데,
// dev-login 용 타임스탬프 이메일은 실제 사용자보다 훨씬 길어서
// "브레이크포인트" 가 아니라 "테스트 데이터 길이" 때문에 패널이 넘친다.
await page.route("**/api/auth/me", async (route) => {
const response = await route.fetch();
const body = (await response.json()) as Record<string, unknown>;
await route.fulfill({
response,
json: {
...body,
email: "pending@hs.ac.kr",
account_status: "pending",
approval_required: true,
},
});
});
await runSweep(page, [
{ label: "pending-approval", page: "pending", url: "/pending", ready: ".pa-page" },
]);
await page.unroute("**/api/auth/me");
});
test("learner 화면이 모든 경계 폭에서 온전하다", async ({ page }) => {
test.setTimeout(900_000);
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page);
// 리뷰 화면을 실제로 열려면 종료된 회기가 하나 필요하다(AI 턴 생성은 하지 않는다).
const created = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
expect(created.ok(), await created.text()).toBeTruthy();
const { session_id: endedSessionId } = (await created.json()) as { session_id: string };
await page.request.post(`/api/sessions/${endedSessionId}/end`);
await runSweep(page, [
{ label: "learner-home/dashboard", page: "learner-home", url: "/learn", ready: ".lh-root" },
{ label: "learner-home/practice", page: "learner-home", url: "/learn/practice", ready: ".lh-root" },
{ label: "learner-home/history", page: "learner-home", url: "/learn/history", ready: ".lh-root" },
{
label: "session/prestart",
page: "session",
url: `/learn/session/${persona.code}`,
ready: ".sx-head",
},
{
label: "session-review/learner",
page: "session-review",
url: `/learn/session/${endedSessionId}/review`,
ready: ".sr-root",
},
{ label: "settings/learner", page: "settings", url: "/settings", ready: ".vg-set" },
{
label: "avatar-lab",
page: "avatar-lab",
url: "/learn/avatar-expressions",
ready: ".axl",
},
]);
});
test("teacher 화면이 모든 경계 폭에서 온전하다", async ({ page }) => {
test.setTimeout(420_000);
await signInAsTeacher(page);
await runSweep(page, [
{ label: "professor/dashboard", page: "professor", url: "/teach", ready: ".pf-root" },
{ label: "professor/analysis", page: "professor", url: "/teach/analysis", ready: ".pf-root" },
{ label: "persona-studio", page: "persona-studio", url: "/teach/personas", ready: ".ps-root" },
{ label: "settings/teacher", page: "settings", url: "/settings", ready: ".vg-set" },
]);
});
test("admin 화면이 모든 경계 폭에서 온전하다", async ({ page }) => {
test.setTimeout(600_000);
await signInAsAdmin(page);
await runSweep(page, [
{ label: "admin/overview", page: "admin-console", url: "/admin", ready: ".vgops-root" },
{ label: "admin/users", page: "admin-console", url: "/admin/users", ready: ".vgops-root" },
{ label: "admin/access", page: "admin-console", url: "/admin/access", ready: ".vgops-root" },
{ label: "admin/tickets", page: "admin-console", url: "/admin/tickets", ready: ".vgops-root" },
{ label: "admin/ai", page: "admin-ai", url: "/admin/ai", ready: ".aic" },
]);
});
test.afterAll(() => {
// 격리 목록 위생 관리: 재현되지 않은 항목은 CSS 가 고쳐졌다는 뜻이니 지우면 된다.
// (일부 테스트만 -g 로 돌리면 당연히 미재현으로 찍히므로 실패시키지 않는다)
for (const known of KNOWN_APP_DEFECTS) {
if (!quarantineHits.has(known)) {
console.log(
`[breakpoint-sweep][격리 항목 미재현] ${known.route} / ${known.kind} / ${known.element} — 고쳐졌다면 KNOWN_APP_DEFECTS 에서 삭제하라`,
);
}
}
});
});

View file

@ -0,0 +1,36 @@
import { expect, test } from "@playwright/test";
test.skip(
process.env.E2E_PREVIEW_BUILD !== "1",
"프로덕션 build를 vite preview로 띄운 옵트인 검증이다.",
);
test("recovers a failed production lazy chunk with exactly one document reload @single-run", async ({
page,
}) => {
let loginChunkRequests = 0;
let documentRequests = 0;
page.on("request", (request) => {
if (request.isNavigationRequest()) documentRequests += 1;
});
await page.route("**/assets/Login-*.js", async (route) => {
loginChunkRequests += 1;
if (loginChunkRequests === 1) {
await route.abort("failed");
return;
}
await route.continue();
});
await page.goto("/login", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible({ timeout: 15_000 });
expect(loginChunkRequests).toBe(2);
expect(documentRequests).toBe(2);
await expect
.poll(() =>
page.evaluate(() => sessionStorage.getItem("vignette:chunk-recovery:/login")),
)
.toBeNull();
});

View file

@ -36,6 +36,7 @@ interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort: string | null;
updated_by: string | null;
updated_at: number | null;
}
@ -135,7 +136,18 @@ test.describe("database-backed runtime state", () => {
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
const nextModel = `db-persist-${slug}`;
const capabilityResponse = await page.request.get(
`/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(currentEngine.engine_mode)}`,
);
await expectResponseOk(capabilityResponse);
const capability = (await capabilityResponse.json()) as {
models: Array<{ id: string; default_reasoning_effort?: string | null }>;
};
const candidate =
capability.models.find((model) => model.id !== currentEngine.model) ?? capability.models[0];
if (!candidate) throw new Error("engine capability returned no selectable model");
const nextModel = candidate.id;
const nextEffort = candidate.default_reasoning_effort ?? null;
try {
const enginePatch = await page.request.patch("/api/admin/engine-config", {
@ -143,6 +155,7 @@ test.describe("database-backed runtime state", () => {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: nextModel,
reasoning_effort: nextEffort,
},
});
await expectResponseOk(enginePatch);
@ -151,6 +164,7 @@ test.describe("database-backed runtime state", () => {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: nextModel,
reasoning_effort: nextEffort,
updated_by: adminEmail,
});
expect(updatedEngine.updated_at).toBeGreaterThan(0);
@ -168,6 +182,7 @@ test.describe("database-backed runtime state", () => {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
reasoning_effort: currentEngine.reasoning_effort,
},
});
await expectResponseOk(restoreResponse);

View file

@ -56,6 +56,7 @@ interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort: string | null;
source: "database" | "runtime_cache" | "runtime_default";
durable: boolean;
updated_by: string | null;
@ -100,6 +101,7 @@ function engineConfigFixture(
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
reasoning_effort: "medium",
source: "database",
durable: true,
updated_by: "admin@twentyoz.kr",
@ -109,7 +111,7 @@ function engineConfigFixture(
}
interface AdminAiMockState {
counts: { usage: number; health: number; engine: number };
counts: { usage: number; health: number; engine: number; capabilities: number };
authUser: {
account_status?: "pending" | "approved" | "suspended";
onboarding_completed_at?: number | null;
@ -124,7 +126,7 @@ interface AdminAiMockState {
function createMockState(overrides: Partial<AdminAiMockState> = {}): AdminAiMockState {
return {
counts: { usage: 0, health: 0, engine: 0 },
counts: { usage: 0, health: 0, engine: 0, capabilities: 0 },
authUser: {},
usageForWindow: (days) => usageFixture(days),
engineConfig: engineConfigFixture(),
@ -198,6 +200,31 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
return;
}
if (method === "GET" && path.endsWith("/admin/engine-capabilities")) {
state.counts.capabilities += 1;
const provider = url.searchParams.get("engine_mode") ?? state.engineConfig.engine_mode;
await fulfillJson({
provider,
available: true,
source: "live_cli",
models: [
{
id: "gateway-default",
label: "게이트웨이 기본 모델",
description: "테스트 기본 모델",
reasoning_efforts: ["medium"],
default_reasoning_effort: "medium",
is_default: true,
},
],
default_model: "gateway-default",
default_reasoning_effort: "medium",
detail: "테스트 모델 목록",
fetched_at: 1_785_142_800,
});
return;
}
if (method === "GET" && path.endsWith("/admin/engine-config")) {
state.counts.engine += 1;
if (state.engineConfigStatus) {
@ -235,11 +262,11 @@ test.describe("full sweep: admin ai operations", () => {
).toBeVisible();
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveCount(0);
// 승인 대기 계정은 관리자 데이터 API를 호출하지 않아야 한다.
expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0 });
expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0, capabilities: 0 });
});
// checklist: admin-ai-aria-busy, admin-ai-refresh-button
test("marks the page aria-busy while loading and reloads all three data sets on refresh", async ({
test("marks the page aria-busy while loading and reloads all operations data on refresh", async ({
page,
}) => {
let gate: ReturnType<typeof deferred> | null = deferred();
@ -251,7 +278,7 @@ test.describe("full sweep: admin ai operations", () => {
await page.goto("/admin/ai");
const container = page.locator('[data-testid="admin-ai-page"]');
const refreshButton = page.getByRole("button", { name: "새로고침" });
const refreshButton = page.getByRole("button", { name: "새로고침", exact: true });
// usage 응답이 보류된 동안 aria-busy=true, 새로고침 버튼 비활성.
await expect(container).toHaveAttribute("aria-busy", "true");
@ -278,7 +305,12 @@ test.describe("full sweep: admin ai operations", () => {
await expect(refreshButton).toBeEnabled();
await expect
.poll(() => state.counts)
.toEqual({ usage: base.usage + 1, health: base.health + 1, engine: base.engine + 1 });
.toEqual({
usage: base.usage + 1,
health: base.health + 1,
engine: base.engine + 1,
capabilities: base.capabilities + 1,
});
});
// checklist: admin-ai-error-alert, admin-ai-engine-loading-empty
@ -302,7 +334,7 @@ test.describe("full sweep: admin ai operations", () => {
state.engineConfigStatus = undefined;
let gate: ReturnType<typeof deferred> | null = deferred();
state.usageGate = () => gate?.promise;
await page.getByRole("button", { name: "새로고침" }).click();
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(alert).toHaveCount(0);
gate.resolve();
@ -331,7 +363,7 @@ test.describe("full sweep: admin ai operations", () => {
await page.goto("/admin/ai");
const ledger = page.locator(".aic-ledger");
await expect(ledger).toContainText("$3.3300");
await expect(ledger).toContainText("$3.33");
// React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다.
const base = { ...state.counts };
expect(base.health).toBe(1);
@ -348,19 +380,20 @@ test.describe("full sweep: admin ai operations", () => {
);
await page.getByRole("button", { name: "7일" }).click();
await expect(page.getByRole("button", { name: "7일" })).toHaveAttribute("aria-pressed", "true");
await expect(ledger).toContainText("$1.1100");
await expect(ledger).toContainText("$1.11");
await expect(ledger).toContainText("7일 DB 집계");
// 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다.
slowWindow.resolve();
await staleResponse;
await expect(ledger).toContainText("$1.1100");
await expect(ledger).not.toContainText("$9.9900");
await expect(ledger).toContainText("$1.11");
await expect(ledger).not.toContainText("$9.99");
// 기간 변경은 usage만 재조회한다(health·engine-config는 초기 1회 그대로).
await expect.poll(() => state.counts.usage).toBe(base.usage + 2);
expect(state.counts.health).toBe(1);
expect(state.counts.engine).toBe(1);
expect(state.counts.capabilities).toBe(1);
});
// checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel
@ -423,7 +456,7 @@ test.describe("full sweep: admin ai operations", () => {
await expect(chart.locator(".aic-chart__day")).toHaveCount(3);
await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute(
"title",
"2026-07-13 $1.4200",
"2026-07-13 $1.42",
);
await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴");
@ -492,7 +525,7 @@ test.describe("full sweep: admin ai operations", () => {
updated_by: null,
updated_at: null,
});
await page.getByRole("button", { name: "새로고침" }).click();
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(rowValue("저장 원천")).toHaveText("런타임 적용");
await expect(rowValue("현재 소스")).toHaveText("runtime_default");
await expect(rowValue("최근 변경")).toHaveText("기록 없음");

View file

@ -377,12 +377,12 @@ test.describe("admin console full-sweep (fixtures)", () => {
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
const inlineError = page.locator(".ad-error");
const inlineError = page.locator(".vgops-error");
await expect(inlineError).toBeVisible();
await expect(inlineError).toHaveAttribute("role", "alert");
await expect(inlineError).toContainText("헬스 저장소 접근 불가");
// 다른 데이터 소스는 정상이므로 화면 나머지는 렌더된다.
await expect(page.locator(".ad-kpis").first()).toBeVisible();
await expect(page.locator(".vgops-kpis").first()).toBeVisible();
});
// 검증 checklist: admin-users-search-input
@ -417,15 +417,15 @@ test.describe("admin console full-sweep (fixtures)", () => {
await page.goto("/admin/users");
await page.getByRole("tab", { name: "사용자 목록" }).click();
const rows = page.locator(".ad-user-table tbody tr");
const rows = page.locator(".vgops-user-table tbody tr");
await expect(rows).toHaveCount(3);
await expect(page.locator(".ad-users-toolbar")).toContainText("3 / 3명");
await expect(page.locator(".vgops-users-toolbar")).toContainText("3 / 3명");
const search = page.getByLabel("사용자 검색");
await search.fill("교수자");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("bob@hs.ac.kr");
await expect(page.locator(".ad-users-toolbar")).toContainText("1 / 3명");
await expect(page.locator(".vgops-users-toolbar")).toContainText("1 / 3명");
await search.fill("cohort-alpha");
await expect(rows).toHaveCount(1);
@ -470,7 +470,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
await page.goto("/admin/users");
await page.getByRole("tab", { name: "사용자 목록" }).click();
const rows = page.locator(".ad-user-table tbody tr");
const rows = page.locator(".vgops-user-table tbody tr");
await expect(rows).toHaveCount(3);
// 기본 정렬: last_seen_at 내림차순.
@ -515,8 +515,8 @@ test.describe("admin console full-sweep (fixtures)", () => {
await page.goto("/admin/users");
await page.getByRole("tab", { name: "사용자 목록" }).click();
await expect(page.locator(".ad-user-table tbody tr")).toHaveCount(40);
await expect(page.locator(".ad-users-toolbar")).toContainText("45 / 45명");
await expect(page.locator(".vgops-user-table tbody tr")).toHaveCount(40);
await expect(page.locator(".vgops-users-toolbar")).toContainText("45 / 45명");
await expect(page.getByText(/나머지\s*5명을 더 좁혀 볼 수/)).toBeVisible();
});
@ -553,7 +553,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
// 토글 → 저장 시 admin_access가 PATCH로 반영된다.
await normalToggle.check();
const normalRow = page.locator(".ad-user-table tbody tr").filter({ hasText: "normal@hs.ac.kr" });
const normalRow = page.locator(".vgops-user-table tbody tr").filter({ hasText: "normal@hs.ac.kr" });
const saveButton = normalRow.getByRole("button", { name: "저장" });
await expect(saveButton).toBeEnabled();
const patchPromise = page.waitForResponse(isUserPatchResponse("normal"));
@ -604,7 +604,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
await expect(page.getByRole("heading", { name: "역할 분포" })).toBeVisible();
await expect(page.getByRole("heading", { name: "최근 활동" })).toBeVisible();
const meters = page.locator(".ad-role-meter");
const meters = page.locator(".vgops-role-meter");
await expect(meters).toHaveCount(3);
await expect(meters.nth(0)).toContainText("학습자");
await expect(meters.nth(0)).toContainText("5명");
@ -613,10 +613,10 @@ test.describe("admin console full-sweep (fixtures)", () => {
await expect(meters.nth(2)).toContainText("관리자");
await expect(meters.nth(2)).toContainText("2명");
const activityRows = page.locator(".ad-activity-table > div");
const activityRows = page.locator(".vgops-activity-table > div");
await expect(activityRows).toHaveCount(8);
await expect(activityRows.first()).toContainText("Activity User 1");
await expect(page.locator(".ad-activity-table")).not.toContainText("Activity User 9");
await expect(page.locator(".vgops-activity-table")).not.toContainText("Activity User 9");
});
// 검증 checklist: admin-users-states
@ -636,9 +636,9 @@ test.describe("admin console full-sweep (fixtures)", () => {
await page.goto("/admin/users");
// 로딩 스켈레톤(승인 큐)이 먼저 보인다.
await expect(page.locator(".ad-user--skeleton").first()).toBeVisible();
await expect(page.locator(".vgops-user--skeleton").first()).toBeVisible();
// 실패 후 InlineError가 표시된다.
await expect(page.locator(".ad-error")).toContainText("사용자 저장소 중단");
await expect(page.locator(".vgops-error")).toContainText("사용자 저장소 중단");
// 앱 결함: 이 클릭에서 페이지가 무한 렌더 루프로 프리즈되어 테스트가 타임아웃된다.
await page.getByRole("tab", { name: "사용자 목록" }).click();
await expect(
@ -657,8 +657,8 @@ test.describe("admin console full-sweep (fixtures)", () => {
// 로딩 스켈레톤 → 실패 InlineError (클릭 없이 표시만 검증).
mock.overrides.users = { delayMs: 1_500, status: 500, detail: "사용자 저장소 중단" };
await page.goto("/admin/users");
await expect(page.locator(".ad-user--skeleton").first()).toBeVisible();
await expect(page.locator(".ad-error")).toContainText("사용자 저장소 중단");
await expect(page.locator(".vgops-user--skeleton").first()).toBeVisible();
await expect(page.locator(".vgops-error")).toContainText("사용자 저장소 중단");
// 등록 0건 빈 상태.
delete mock.overrides.users;
@ -673,7 +673,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
];
await page.reload();
await page.getByRole("tab", { name: "사용자 목록" }).click();
await expect(page.locator(".ad-user-table tbody tr")).toHaveCount(1);
await expect(page.locator(".vgops-user-table tbody tr")).toHaveCount(1);
await page.getByLabel("사용자 검색").fill("no-match-full-sweep");
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
@ -737,7 +737,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
params.get("assigned_group") === "ops",
),
);
await page.locator(".ad-ticket-filter__check input").check();
await page.locator(".vgops-ticket-filter__check input").check();
await staleRequest;
await expect(clearButton).toBeEnabled();
@ -760,7 +760,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
await expect(page.getByLabel("카테고리 필터")).toHaveValue("all");
await expect(page.getByLabel("우선순위 필터")).toHaveValue("all");
await expect(page.getByLabel("담당 그룹 필터")).toHaveValue("");
await expect(page.locator(".ad-ticket-filter__check input")).not.toBeChecked();
await expect(page.locator(".vgops-ticket-filter__check input")).not.toBeChecked();
await expect(clearButton).toBeDisabled();
});
@ -776,7 +776,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
];
await page.goto("/admin/tickets");
const chips = page.locator(".ad-ticket-queues button");
const chips = page.locator(".vgops-ticket-queues button");
await expect(chips).toHaveCount(2);
// 건수 내림차순: 안전(2) → 기타(1).
await expect(chips.nth(0)).toContainText("안전");
@ -835,7 +835,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
mock.overrides.tickets = { status: 500, detail: "티켓 저장소 오류" };
await page.getByRole("button", { name: "새로고침" }).click();
const inlineError = page.locator(".ad-error");
const inlineError = page.locator(".vgops-error");
await expect(inlineError).toBeVisible();
await expect(inlineError).toHaveAttribute("role", "alert");
await expect(inlineError).toContainText("티켓 저장소 오류");
@ -876,7 +876,7 @@ test.describe("admin console full-sweep (real API)", () => {
await page.goto("/admin/users");
expect((await usersResponsePromise).ok()).toBeTruthy();
const approvalCard = page.locator(".ad-approval").filter({ hasText: email });
const approvalCard = page.locator(".vgops-approval").filter({ hasText: email });
await expect(approvalCard).toBeVisible();
await expect(approvalCard).toContainText("승인 대기");
@ -941,7 +941,7 @@ test.describe("admin console full-sweep (real API)", () => {
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
const row = page.locator(".ad-user-table tbody tr").filter({ hasText: email });
const row = page.locator(".vgops-user-table tbody tr").filter({ hasText: email });
await expect(row).toBeVisible();
const affiliationInput = row.getByLabel(`${email} 소속`);
@ -1019,7 +1019,7 @@ test.describe("admin console full-sweep (real API)", () => {
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
const row = page.locator(".ad-user-table tbody tr").filter({ hasText: email });
const row = page.locator(".vgops-user-table tbody tr").filter({ hasText: email });
await expect(row).toBeVisible();
const cohortInput = row.getByLabel(`${email} 코호트`);
await cohortInput.fill("co-a, co-b");
@ -1083,7 +1083,7 @@ test.describe("admin console full-sweep (real API)", () => {
await page.goto("/admin/tickets");
await ticketsResponsePromise;
const card = page.locator(".ad-ticket").filter({ hasText: subject });
const card = page.locator(".vgops-ticket").filter({ hasText: subject });
await expect(card).toBeVisible();
let patchCount = 0;
@ -1171,7 +1171,7 @@ test.describe("admin console full-sweep (real API)", () => {
await ticketsResponsePromise;
const linkedCard = page
.locator(".ad-ticket")
.locator(".vgops-ticket")
.filter({ hasText: subject })
.filter({ has: page.getByRole("button", { name: "해제" }) });
await expect(linkedCard).toBeVisible();

View file

@ -787,6 +787,10 @@ test.describe("full sweep — counseling session", () => {
const skipButton = page.getByRole("button", { name: "음성 건너뛰기" });
await expect(skipButton).toBeVisible();
await expect(page.locator(".sx-mic-block__l")).toHaveText("재생 중");
const composer = page.getByLabel("학습자 발화 입력");
await expect(composer).toBeEnabled();
await composer.fill("음성을 들으면서 다음 질문을 미리 씁니다.");
await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled();
await skipButton.click();
await expect(skipButton).toHaveCount(0);
@ -794,6 +798,7 @@ test.describe("full sweep — counseling session", () => {
"음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요.",
);
await expect(page.locator(".sx-mic-block__l")).toHaveText("마이크 꺼짐");
await expect(composer).toHaveValue("음성을 들으면서 다음 질문을 미리 씁니다.");
});
// checklist: session-pause-toggle, session-elapsed-live-region

View file

@ -199,6 +199,54 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
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 ({

View file

@ -426,6 +426,22 @@ test.describe("layout visual gate @single-run", () => {
await ensureShotDir();
});
// 이 게이트는 다크 표면을 기준으로 레이아웃을 캡처하고, 라이트 검사는 각 테스트가
// "라이트 모드로" 버튼을 눌러 명시적으로 전환한 뒤에만 한다. 예전에는 앱이 저장값
// 없을 때 무조건 dark 로 떨어져서 이 전제가 공짜로 성립했다. 2026-07-27 부터
// readInitialTheme() 이 prefers-color-scheme 을 따르므로(소유자 결정), 이 프로젝트에
// colorScheme 설정이 없으면 Chromium 기본값인 light 로 시작해 게이트가 깨진다.
// 게이트가 자기 전제를 직접 심는다. 테마 store 의 "저장값 우선" 규칙을 그대로 쓴다.
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
try {
localStorage.setItem("vignette.theme", "dark");
} catch {
/* storage 접근 불가 환경에서는 앱 폴백(dark)에 맡긴다 */
}
});
});
test("learner home stays contained and legible across all widths", async ({ page }) => {
await page.request.post("/api/auth/dev-login", {
data: {
@ -788,7 +804,7 @@ test.describe("layout visual gate @single-run", () => {
]) {
await page.setViewportSize(viewport);
await expectNoHorizontalOverflow(page);
const scrollReport = await page.locator(".ad-user-table-scroll").evaluate((element) => {
const scrollReport = await page.locator(".vgops-user-table-scroll").evaluate((element) => {
element.scrollLeft = 0;
const report = {
clientWidth: element.clientWidth,
@ -805,7 +821,7 @@ test.describe("layout visual gate @single-run", () => {
scrollReport.initialScroll,
);
await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible();
await page.locator(".ad-user-table-scroll").evaluate((element) => {
await page.locator(".vgops-user-table-scroll").evaluate((element) => {
element.scrollLeft = 0;
});
await page.screenshot({

View file

@ -196,7 +196,13 @@ test.describe("learner app shell and session launcher", () => {
await expect(launcher.getByRole("option")).toHaveCount(personas.length, { timeout: 15_000 });
for (const persona of personas) {
await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible();
await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).toBeVisible();
// display_name 은 가운뎃점 3개 이상이면 이름줄과 주호소줄로 나뉘어 렌더된다
// (2026-07-27 D3: 한 줄에 · 1개 제한). 이어 붙인 원문 대신 각 조각이 모두
// 옵션 안에 있는지 확인해, 표시 방식이 바뀌어도 내용 누락만 잡히게 한다.
const option = launcher.getByRole("option", { name: new RegExp(persona.code) });
for (const part of persona.display_name.split("·").map((piece) => piece.trim()).filter(Boolean)) {
await expect(option).toContainText(part);
}
}
await expectPersonaCardsUseContentHeight(page);

View file

@ -59,25 +59,25 @@ test.describe("public admin visual @public-auth", () => {
).toBeTruthy();
await expect(page).toHaveURL(/\/admin(?:$|[?#])/);
await expect(page.locator(".ad-root")).toBeVisible({ timeout: 15_000 });
await expect(page.locator("[data-vignette-admin-root]")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".vg-shell")).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible();
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible();
await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible();
await expect(page.locator(".ad-kpi b")).toHaveCount(4);
await expect(page.locator(".vgops-kpi b")).toHaveCount(4);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
const visualReport = await page.evaluate(() => {
const adminRoot = document.querySelector<HTMLElement>(".ad-root");
const adminRoot = document.querySelector<HTMLElement>("[data-vignette-admin-root]");
const main = document.querySelector<HTMLElement>("main");
const heading = Array.from(document.querySelectorAll<HTMLElement>("h1,h2,h3")).find(
(node) => node.textContent?.includes("현재 서비스 상태"),
);
const visibleNodes = Array.from(
document.querySelectorAll<HTMLElement>(
".ad-root,.ad-head,.ad-kpi,.ad-panel,.ad-service,.vg-shell,.vg-nav,h1,h2,h3,p,a,button",
".vgops-root,.vgops-head,.vgops-kpi,.vgops-panel,.vgops-service,.vg-shell,.vg-nav,h1,h2,h3,p,a,button",
),
).filter((node) => {
const rect = node.getBoundingClientRect();
@ -128,6 +128,119 @@ test.describe("public admin visual @public-auth", () => {
);
});
test("shows the live Codex and Agy model catalogs with safe defaults", async ({ page }) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
"Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
);
}
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
await page.goto("/admin/ai", { waitUntil: "domcontentloaded" });
await expect(page.locator('[data-testid="admin-ai-page"]')).toBeVisible({ timeout: 15_000 });
const provider = page.getByLabel("AI 엔진 공급자");
const model = page.getByLabel("AI 기본 모델");
const effort = page.getByLabel("AI 추론 강도");
await expect(provider.locator("option")).toHaveCount(6);
await provider.selectOption("codex_cli");
await expect(model).toBeEnabled({ timeout: 30_000 });
await expect(model).toHaveValue("gpt-5.6-terra");
await expect(effort).toHaveValue("medium");
await expect(page.getByText("7개 모델 확인됨")).toBeVisible();
await provider.selectOption("agy_cli");
await expect(model).toBeEnabled({ timeout: 30_000 });
await expect(model).toHaveValue("gemini-3.6-flash-high");
await expect(effort).toHaveValue("high");
await expect(page.getByText("11개 모델 확인됨")).toBeVisible();
expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]);
expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]);
});
test("keeps the public admin visible with EasyList cosmetic filters active", async ({
page,
}) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
"Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
);
}
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
await page.addInitScript(() => {
// 모든 공개 관리자 navigation의 첫 paint부터 실제 EasyList 충돌 규칙을 적용한다.
const style = document.createElement("style");
style.dataset.testEasylist = "true";
style.textContent = ".ad-root,.ad-section{display:none!important}";
const install = () => {
const target = document.head ?? document.documentElement;
if (!target) return false;
target.append(style);
return true;
};
if (!install()) {
const observer = new MutationObserver(() => {
if (install()) observer.disconnect();
});
observer.observe(document, { childList: true, subtree: true });
}
});
const sections = [
{ path: "/admin", heading: "현재 서비스 상태", root: "[data-vignette-admin-root]" },
{ path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" },
{ path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" },
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" },
{ path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" },
] as const;
for (const section of sections) {
await page.goto(section.path, { waitUntil: "domcontentloaded" });
await expect(page.locator(section.root)).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: section.heading })).toBeVisible();
await expect(page.locator('[class^="ad-"],[class*=" ad-"]')).toHaveCount(0);
}
await page.waitForTimeout(4_000);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
const adminRoot = page.locator("[data-vignette-admin-root]");
await expect(adminRoot).toBeVisible();
const visibility = await adminRoot.evaluate((root) => {
const rect = root.getBoundingClientRect();
const style = getComputedStyle(root);
return {
width: rect.width,
height: rect.height,
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
};
});
expect(visibility.width, JSON.stringify(visibility)).toBeGreaterThan(900);
expect(visibility.height, JSON.stringify(visibility)).toBeGreaterThan(500);
expect(visibility.display, JSON.stringify(visibility)).not.toBe("none");
expect(visibility.visibility, JSON.stringify(visibility)).not.toBe("hidden");
expect(visibility.opacity, JSON.stringify(visibility)).not.toBe("0");
expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]);
expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]);
});
test("renders every public admin section without a silent blank pane", async ({ page }) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
@ -143,21 +256,24 @@ test.describe("public admin visual @public-auth", () => {
});
const sections = [
{ path: "/admin", heading: "현재 서비스 상태" },
{ path: "/admin/users", heading: "가입 승인과 권한 관리" },
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위" },
{ path: "/admin/tickets", heading: "사용자 문제 큐" },
{ path: "/admin", heading: "현재 서비스 상태", root: "[data-vignette-admin-root]" },
{ path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" },
{ path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" },
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" },
{ path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" },
] as const;
for (const section of sections) {
await page.goto(section.path, { waitUntil: "domcontentloaded" });
await expect(page.locator(".ad-root")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(section.root)).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: section.heading })).toBeVisible();
await expect(page.locator(".ad-diagnostic")).toHaveCount(0);
await expect(page.locator(".vgops-diagnostic")).toHaveCount(0);
const report = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>(".ad-root");
const report = await page.evaluate((rootSelector) => {
const root = document.querySelector<HTMLElement>(rootSelector);
const main = document.querySelector<HTMLElement>(".vg-main");
const rect = root?.getBoundingClientRect();
const mainRect = main?.getBoundingClientRect();
const visibleNodes = root
? Array.from(root.querySelectorAll<HTMLElement>("h1,h2,p,button,input,select,article,section"))
.filter((node) => {
@ -166,6 +282,8 @@ test.describe("public admin visual @public-auth", () => {
return (
nodeRect.width > 1 &&
nodeRect.height > 1 &&
nodeRect.bottom > (mainRect?.top ?? 0) &&
nodeRect.top < (mainRect?.bottom ?? window.innerHeight) &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
@ -178,20 +296,38 @@ test.describe("public admin visual @public-auth", () => {
width: rect?.width ?? 0,
height: rect?.height ?? 0,
scrollY: window.scrollY,
mainScrollTop: main?.scrollTop ?? -1,
};
});
}, section.root);
expect(report.textLength, JSON.stringify({ section, report })).toBeGreaterThan(120);
expect(report.visibleNodes, JSON.stringify({ section, report })).toBeGreaterThan(5);
expect(report.width, JSON.stringify({ section, report })).toBeGreaterThan(300);
expect(report.height, JSON.stringify({ section, report })).toBeGreaterThan(120);
expect(report.scrollY, JSON.stringify({ section, report })).toBe(0);
expect(report.mainScrollTop, JSON.stringify({ section, report })).toBe(0);
}
expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]);
expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]);
});
test("keeps the operating console visible from the primary learner workspace", async ({ page }) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
"Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
);
}
await page.goto("/learn", { waitUntil: "domcontentloaded" });
const adminEntry = page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" });
await expect(adminEntry).toBeVisible({ timeout: 15_000 });
await adminEntry.click();
await expect(page).toHaveURL(/\/admin(?:$|[?#])/);
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
});
test("returns a restored admin tab to visible content after pageshow", async ({ page }) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
@ -201,13 +337,15 @@ test.describe("public admin visual @public-auth", () => {
await page.goto("/admin", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.locator(".ad-root")).toBeVisible();
await expect(page.locator("[data-vignette-admin-root]")).toBeVisible();
const beforeRestore = await page.evaluate(() => {
document.documentElement.style.minHeight = "2200px";
document.body.style.minHeight = "2200px";
window.scrollTo(0, 900);
return window.scrollY;
const main = document.querySelector<HTMLElement>(".vg-main");
const root = document.querySelector<HTMLElement>("[data-vignette-admin-root]");
if (!main || !root) throw new Error("admin scroll container missing");
root.style.minHeight = "2200px";
main.scrollTop = 900;
return main.scrollTop;
});
expect(beforeRestore).toBeGreaterThan(0);
@ -216,7 +354,13 @@ test.describe("public admin visual @public-auth", () => {
});
await expect
.poll(() => page.evaluate(() => window.scrollY), { timeout: 5_000 })
.poll(
() =>
page.evaluate(
() => document.querySelector<HTMLElement>(".vg-main")?.scrollTop ?? -1,
),
{ timeout: 5_000 },
)
.toBe(0);
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeInViewport();
});

View file

@ -89,9 +89,12 @@ test.describe("production readiness gates", () => {
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort: string | null;
};
expect(engine).toMatchObject({ durable: true, source: "database" });
expect(["claude_cli", "claude_api", "openai", "solar"]).toContain(engine.engine_mode);
expect(["claude_cli", "claude_api", "codex_cli", "agy_cli", "openai", "solar"]).toContain(
engine.engine_mode,
);
expect(engine.engine_url).toMatch(/^https?:\/\//);
expect(engine.model.trim().length).toBeGreaterThan(0);
});

View file

@ -90,6 +90,16 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
});
});
// 텍스트 턴 TTS는 이 fixture의 검증 대상이 아니다. 실제 8000 포트로 새지 않게
// 명시적으로 실패시키고, 음성 실패가 작성 중인 초안을 지우지 않는지만 본다.
await page.route("**/api/voice/speech", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "voice synthesis disabled in fixture" }),
});
});
await page.route("**/api/personas", async (route) => {
await route.fulfill({
status: 200,
@ -659,11 +669,15 @@ test.describe("P1 MVP core loop", () => {
await api.streamSeen.promise;
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
const composer = page.getByLabel("학습자 발화 입력");
await expect(composer).toBeEnabled();
await expect(page.getByRole("button", { name: "보내기" })).toBeDisabled();
await composer.fill("다음 질문을 미리 작성합니다.");
api.streamGate.resolve();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await expect(composer).toHaveValue("다음 질문을 미리 작성합니다.");
await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled();
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();

View file

@ -152,6 +152,7 @@ interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort?: string | null;
}
interface PrepostMeasureItem {
@ -1430,124 +1431,36 @@ test.describe("session persistence", () => {
}
});
test("runs manual AI evaluation retry from teacher review UI into durable DB state @single-run", async ({
test("rejects an unreachable AI gateway before it can poison the evaluation runtime @single-run", async ({
page,
}) => {
test.setTimeout(240_000);
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "manual evaluation retry requires DB and engine");
test.skip(!health.db || !health.engine, "engine configuration validation requires DB and engine");
await signInAsLearner(page);
const sessionId = await createActiveSessionWithTurn(page);
const endedResponse = await page.request.post(`/api/sessions/${sessionId}/end`);
await expectResponseOk(endedResponse);
await withGlobalEngineConfigLock("manual-evaluation-retry-ui", async () => {
await withGlobalEngineConfigLock("engine-config-fail-closed", async () => {
await signInAsAdmin(page);
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
try {
const brokenEnginePatch = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: "http://127.0.0.1:9",
model: currentEngine.model,
},
});
await expectResponseOk(brokenEnginePatch);
const brokenEnginePatch = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: "http://127.0.0.1:9",
model: currentEngine.model,
reasoning_effort: currentEngine.reasoning_effort,
},
});
expect(brokenEnginePatch.status()).toBe(422);
await signInAsTeacher(page);
const failedReevaluationResponse = await page.request.post(
`/api/eval/sessions/${sessionId}/reevaluate`,
{ data: { scope: "session_end" } },
);
expect(
failedReevaluationResponse.ok(),
"broken engine should create a failed durable evaluation seed",
).toBe(false);
await signInAsAdmin(page);
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
},
});
await expectResponseOk(restoreResponse);
await signInAsTeacher(page);
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.getByText("평가 실패")).toBeVisible({ timeout: 15_000 });
const retryButton = page.getByRole("button", { name: "AI 평가 재시도" });
await expect(retryButton).toBeVisible();
const reevaluateResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/eval/sessions/${sessionId}/reevaluate`)
);
});
await retryButton.click();
const reevaluateResponse = await reevaluateResponsePromise;
await expectResponseOk(reevaluateResponse);
const reevaluation = (await reevaluateResponse.json()) as {
scope?: string;
error?: string | null;
turns_evaluated?: number;
};
expect(reevaluation.scope).toBe("session_end");
expect(reevaluation.error ?? "").toBe("");
expect(reevaluation.turns_evaluated ?? 0).toBeGreaterThan(0);
await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", {
timeout: 30_000,
});
await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨");
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
const evaluationResponse = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`);
await expectResponseOk(evaluationResponse);
const evaluation = (await evaluationResponse.json()) as EvaluationSummaryResponse;
expect(evaluation.status).toBe("ready");
expect(evaluation.durable).toBe(true);
expect(evaluation.deep?.scope).toBe("session_end");
expect(evaluation.deep?.turns_evaluated).toBe(reevaluation.turns_evaluated);
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.reviewReady).toBe(true);
expect(review.supervisorState).toBe("평가 완료");
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
await expectResponseOk(dashboardResponse);
const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse;
expect(dashboard.source).toBe("database");
const dashboardSession = [...(dashboard.pending_reviews ?? []), ...(dashboard.recent_sessions ?? [])].find(
(session) => session.session_id === sessionId,
);
expect(dashboardSession, "teacher dashboard should expose the retried session").toBeTruthy();
expect(dashboardSession?.evaluation_status).toBe("ready");
expect(dashboardSession?.review_ready).toBe(true);
expect(dashboardSession?.supervisor_state).toBe("평가 완료");
} finally {
await signInAsAdmin(page);
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
},
});
await expectResponseOk(restoreResponse);
}
const persistedResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(persistedResponse);
const persisted = (await persistedResponse.json()) as AdminEngineConfigResponse;
expect(persisted).toMatchObject(currentEngine);
});
});
});

View file

@ -44,6 +44,7 @@ interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort: string | null;
updated_by: string | null;
updated_at: number | null;
}
@ -117,11 +118,28 @@ async function waitForReactInputCommit(page: Page) {
);
}
function hasEngineConfigRequestBody(engineMode: string, model: string) {
interface EngineCapabilitiesResponse {
available: boolean;
models: Array<{
id: string;
reasoning_efforts?: string[];
default_reasoning_effort?: string | null;
}>;
}
function hasEngineConfigRequestBody(engineMode: string, model: string, reasoningEffort: string | null) {
return (response: Response) => {
try {
const body = response.request().postDataJSON() as { engine_mode?: string; model?: string };
return body.engine_mode === engineMode && body.model === model;
const body = response.request().postDataJSON() as {
engine_mode?: string;
model?: string;
reasoning_effort?: string | null;
};
return (
body.engine_mode === engineMode &&
body.model === model &&
body.reasoning_effort === reasoningEffort
);
} catch {
return false;
}
@ -143,7 +161,9 @@ async function expectNoEngineSegmentClipping(page: Page) {
height: number;
}> = [];
const segment = document.querySelector<HTMLElement>("#set-engine .vg-set__seg");
const segment = document.querySelector<HTMLElement>(
"#set-engine [aria-label='AI 엔진 공급자']",
);
if (!segment) {
return [
{
@ -666,19 +686,31 @@ test.describe("settings page", () => {
const engine = page.locator("#set-engine");
await expect(engine).toBeVisible();
await expect(engine.locator("input").nth(0)).toHaveValue(originalEngineConfig.engine_url);
await expect(engine.locator("input").nth(1)).toHaveValue(originalEngineConfig.model);
await expect(engine.getByLabel("AI 연결 주소")).toHaveValue(originalEngineConfig.engine_url);
await expect(engine.getByLabel("AI 엔진 공급자")).toHaveValue(
originalEngineConfig.engine_mode,
);
await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(originalEngineConfig.model);
const nextMode =
originalEngineConfig.engine_mode === "claude_api" ? "claude_cli" : "claude_api";
const nextModel = `e2e-model-${slugFor(testInfo)}`;
const capabilityResponse = await page.request.get(
`/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(originalEngineConfig.engine_mode)}`,
);
await expectResponseOk(capabilityResponse);
const capability = (await capabilityResponse.json()) as EngineCapabilitiesResponse;
const candidate =
capability.models.find((model) => model.id !== originalEngineConfig.model) ??
capability.models[0];
if (!candidate) throw new Error("engine capability returned no selectable model");
const nextMode = originalEngineConfig.engine_mode;
const nextModel = candidate.id;
const nextEffort = candidate.default_reasoning_effort ?? null;
try {
const nextModeButton = engine.locator(`[data-engine-mode="${nextMode}"]`);
await nextModeButton.click();
await expect(nextModeButton).toHaveAttribute("aria-checked", "true");
await engine.locator("input").nth(1).fill(nextModel);
await expect(engine.locator("input").nth(1)).toHaveValue(nextModel);
await engine.getByLabel("AI 기본 모델").selectOption(nextModel);
await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(nextModel);
if (nextEffort) {
await engine.getByLabel("AI 추론 강도").selectOption(nextEffort);
}
await waitForReactInputCommit(page);
const enginePatchResponse = await runAndWaitForApiResponse(
@ -688,16 +720,17 @@ test.describe("settings page", () => {
async () => {
await engine.locator(".vg-set__foot .vg-btn").click();
},
hasEngineConfigRequestBody(nextMode, nextModel),
hasEngineConfigRequestBody(nextMode, nextModel, nextEffort),
);
await expectResponseOk(enginePatchResponse);
const updatedEngine = (await enginePatchResponse.json()) as AdminEngineConfigResponse;
expect(updatedEngine).toMatchObject({
engine_mode: nextMode,
model: nextModel,
reasoning_effort: nextEffort,
updated_by: email,
});
await expect(engine.locator("input").nth(1)).toHaveValue(nextModel);
await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(nextModel);
const healthResponse = await page.request.get("/api/admin/health");
await expectResponseOk(healthResponse);
@ -709,6 +742,7 @@ test.describe("settings page", () => {
engine_mode: originalEngineConfig.engine_mode,
engine_url: originalEngineConfig.engine_url,
model: originalEngineConfig.model,
reasoning_effort: originalEngineConfig.reasoning_effort,
},
});
await expectResponseOk(restoreResponse);
@ -742,12 +776,13 @@ test.describe("settings page", () => {
const engine = page.locator("#set-engine");
await expect(engine).toBeVisible();
await expect(engine.locator("[data-engine-mode]")).toHaveCount(4);
await expect(engine.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6);
await expectNoEngineSegmentClipping(page);
await expect(engine.locator("input").nth(0)).toBeVisible();
await expect(engine.locator("input").nth(0)).toHaveValue(engineConfig!.engine_url);
await expect(engine.locator("input").nth(1)).toBeVisible();
await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model);
await expect(engine.getByLabel("AI 연결 주소")).toBeVisible();
await expect(engine.getByLabel("AI 연결 주소")).toHaveValue(engineConfig!.engine_url);
await expect(engine.getByLabel("AI 기본 모델")).toBeVisible();
await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(engineConfig!.model);
await expect(engine.getByLabel("AI 추론 강도")).toBeVisible();
await expectNoSettingsControlClipping(page);
await expectNoHorizontalOverflow(page);

View file

@ -590,7 +590,8 @@ test.describe("teacher console", () => {
await expect(page.getByText("직장 적응 훈련 페르소나")).toBeVisible();
await page.getByRole("button", { name: "새로운 페르소나 만들기" }).click();
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
await page.getByRole("button", { name: /설정 임상·회기·말투 조정/ }).click();
// 2026-07-27 D3: 나열형 메타의 가운뎃점을 쉼표로 낮췄다 ("임상·회기·말투" → "임상, 회기, 말투").
await page.getByRole("button", { name: /설정\s+임상, 회기, 말투 조정/ }).click();
await expect(page.getByRole("tab", { name: "개요" })).toBeVisible();
await page.getByRole("button", { name: "이전" }).click();
await expect(page).toHaveURL(/step=generate/);