관리자 설정 로딩과 부트 진단 복구

This commit is contained in:
Yun Chan 2026-09-22 23:06:54 +09:00
parent d58eb67764
commit fb640a75ac
7 changed files with 767 additions and 100 deletions

View file

@ -920,6 +920,10 @@ test.describe("admin route guards", () => {
await expect(page.getByTestId("auth-restore-failed")).toBeVisible();
await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible();
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
// 관리자 auth 복구 UI(.vg-route-error)는 3.5초/8초 watchdog 뒤에도
// 정상적인 오류 복구 화면으로 남고 부트 진단과 겹치지 않는다.
await page.waitForTimeout(8_600);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
await page.getByTestId("auth-restore-retry").click();
@ -1073,7 +1077,22 @@ test.describe("admin route guards", () => {
await expect(page.locator(".aic-chart__day").last()).toContainText("≤$3.56");
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);
expect(
await page
.getByLabel("AI 엔진 공급자")
.locator("option")
.evaluateAll((options) => options.map((option) => option.value)),
).toEqual([
"claude_cli",
"claude_api",
"codex_cli",
"codex_api",
"agy_cli",
"antigravity_api",
"openai",
"openrouter",
"solar",
]);
const usageRequest = page.waitForRequest((request) =>
request.url().includes("/api/admin/usage?window_days=7"),

View file

@ -86,6 +86,24 @@ interface AdminEngineConfigResponse {
updated_at: number | null;
}
interface EngineCapabilitiesResponse {
provider: string;
available: boolean;
source: string;
models: Array<{
id: string;
label: string;
description: string;
reasoning_efforts: string[];
default_reasoning_effort: string | null;
is_default: boolean;
}>;
default_model: string;
default_reasoning_effort: string | null;
detail: string;
fetched_at: number;
}
function usageFixture(
days: number,
overrides: Partial<AdminUsageResponse> = {},
@ -154,9 +172,14 @@ interface AdminAiMockState {
usageForWindow: (days: number) => AdminUsageResponse;
/** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */
usageGate?: (days: number) => Promise<void> | undefined;
usageStatus?: (days: number) => number | undefined;
engineConfig: AdminEngineConfigResponse;
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
engineConfigStatus?: number;
engineConfigGate?: () => Promise<void> | undefined;
capabilityGate?: (provider: string, engineUrl: string) => Promise<void> | undefined;
capabilityStatus?: (provider: string, engineUrl: string) => number | undefined;
capabilityFor?: (provider: string, engineUrl: string) => EngineCapabilitiesResponse;
}
function createMockState(overrides: Partial<AdminAiMockState> = {}): AdminAiMockState {
@ -211,6 +234,11 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
const days = Number(url.searchParams.get("window_days") ?? 30);
const gate = state.usageGate?.(days);
if (gate) await gate;
const status = state.usageStatus?.(days);
if (status) {
await fulfillJson({ detail: `usage ${days} unavailable` }, status);
return;
}
await fulfillJson(state.usageForWindow(days));
return;
}
@ -238,7 +266,15 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
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({
const engineUrl = url.searchParams.get("engine_url") ?? state.engineConfig.engine_url;
const gate = state.capabilityGate?.(provider, engineUrl);
if (gate) await gate;
const status = state.capabilityStatus?.(provider, engineUrl);
if (status) {
await fulfillJson({ detail: `capability ${provider} unavailable` }, status);
return;
}
await fulfillJson(state.capabilityFor?.(provider, engineUrl) ?? {
provider,
available: true,
source: "live_cli",
@ -262,6 +298,8 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
if (method === "GET" && path.endsWith("/admin/engine-config")) {
state.counts.engine += 1;
const gate = state.engineConfigGate?.();
if (gate) await gate;
if (state.engineConfigStatus) {
await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus);
return;
@ -374,7 +412,7 @@ test.describe("full sweep: admin ai operations", () => {
});
// checklist: admin-ai-error-alert, admin-ai-engine-loading-empty
test("shows a role=alert banner with the engine panel placeholder on load failure and clears it on refresh", async ({
test("shows an explicit engine state after an engine-config failure and recovers on refresh", async ({
page,
}) => {
const state = createMockState({ engineConfigStatus: 500 });
@ -382,29 +420,151 @@ test.describe("full sweep: admin ai operations", () => {
await page.goto("/admin/ai");
// engine-config 로드 실패 → role=alert 배너에 오류 메시지 노출.
const alert = page.locator(".aic-alert[role='alert']");
await expect(alert).toBeVisible();
await expect(alert).toContainText("engine config unavailable");
// 엔진 패널은 폼 대신 로딩/빈 상태 문구를 표시한다.
await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toBeVisible();
const enginePanel = page.locator(".aic-engine");
await expect(enginePanel.getByRole("alert")).toContainText("engine config unavailable");
await expect(
enginePanel.getByText("현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."),
).toBeVisible();
await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0);
// 다음 요청이 시작되면 오류 배너가 초기화된다: 응답을 보류한 채 새로고침.
state.engineConfigStatus = undefined;
let gate: ReturnType<typeof deferred> | null = deferred();
state.usageGate = () => gate?.promise;
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(alert).toHaveCount(0);
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099");
await expect(enginePanel.getByRole("alert")).toHaveCount(0);
gate.resolve();
gate = null;
// 성공 응답 이후에도 배너는 없고 엔진 설정 폼이 렌더된다.
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(alert).toHaveCount(0);
});
test("shows engine config before a slow usage response completes", async ({ page }) => {
const usageGate = deferred();
const state = createMockState({ usageGate: () => usageGate.promise });
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099");
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "true");
usageGate.resolve();
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false");
});
test("shows a retryable Korean error when engine-config read times out", async ({ page }) => {
test.setTimeout(30_000);
const engineGate = deferred();
const state = createMockState({ engineConfigGate: () => engineGate.promise });
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
await expect(
page.locator(".aic-engine").getByText(
"AI 엔진 설정 조회 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.",
),
).toBeVisible({ timeout: 20_000 });
engineGate.resolve();
});
test("keeps the latest provider selection when an earlier capability request finishes late", async ({
page,
}) => {
const claudeGate = deferred();
const state = createMockState({
capabilityGate: (provider) => (provider === "claude_api" ? claudeGate.promise : undefined),
capabilityFor: (provider) => ({
provider,
available: true,
source: "live_cli",
models: [
{
id: `${provider}-model`,
label: `${provider} 모델`,
description: "테스트 모델",
reasoning_efforts: ["medium"],
default_reasoning_effort: "medium",
is_default: true,
},
],
default_model: `${provider}-model`,
default_reasoning_effort: "medium",
detail: "테스트 모델 목록",
fetched_at: 1_785_142_800,
}),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const provider = page.getByLabel("AI 엔진 공급자");
const model = page.getByLabel("AI 기본 모델");
await expect(model.getByRole("option", { name: "openai 모델" })).toBeAttached();
const lateCapabilityResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
url.pathname.endsWith("/admin/engine-capabilities") &&
url.searchParams.get("engine_mode") === "claude_api"
);
});
await provider.selectOption("claude_api");
await provider.selectOption("openrouter");
await expect(model).toHaveValue("openrouter-model");
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(provider).toHaveValue("openrouter");
await expect(model).toHaveValue("openrouter-model");
claudeGate.resolve();
await lateCapabilityResponse;
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
await expect(model).toHaveValue("openrouter-model");
});
test("clears prior capability models when refresh changes the engine URL and lookup fails", async ({
page,
}) => {
const state = createMockState({
capabilityFor: (provider, engineUrl) => ({
provider,
available: true,
source: "live_cli",
models: [
{
id: `model-${engineUrl}`,
label: `모델 ${engineUrl}`,
description: "테스트 모델",
reasoning_efforts: ["medium"],
default_reasoning_effort: "medium",
is_default: true,
},
],
default_model: `model-${engineUrl}`,
default_reasoning_effort: "medium",
detail: "테스트 모델 목록",
fetched_at: 1_785_142_800,
}),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const model = page.getByLabel("AI 기본 모델");
await expect(model.getByRole("option", { name: "모델 http://127.0.0.1:9099" })).toBeAttached();
state.engineConfig = engineConfigFixture({
engine_url: "http://127.0.0.1:9100",
model: "restored-model",
});
state.capabilityStatus = () => 500;
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9100");
await expect(model).toHaveValue("restored-model");
await expect(model.getByRole("option", { name: "모델 http://127.0.0.1:9099" })).toHaveCount(0);
await expect(page.locator(".aic-engine").getByRole("alert")).toContainText(
"capability openai unavailable",
);
});
// checklist: admin-ai-window-toggle
test("refetches only usage on window change and ignores a stale late response", async ({
page,
@ -457,6 +617,30 @@ test.describe("full sweep: admin ai operations", () => {
expect(state.counts.capabilities).toBe(1);
});
test("does not let a superseded usage response clear a newer usage error", async ({ page }) => {
const slowWindow = deferred();
const state = createMockState({
usageGate: (days) => (days === 90 ? slowWindow.promise : undefined),
usageStatus: (days) => (days === 7 ? 500 : undefined),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
const staleResponse = page.waitForResponse((response) =>
response.url().includes("/api/admin/usage?window_days=90"),
);
await page.getByRole("button", { name: "90일" }).click();
await page.getByRole("button", { name: "7일" }).click();
const alert = page.getByRole("alert").filter({ hasText: "usage 7 unavailable" });
await expect(alert).toContainText("usage 7 unavailable");
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false");
slowWindow.resolve();
await staleResponse;
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
await expect(alert).toContainText("usage 7 unavailable");
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveAttribute("aria-busy", "false");
});
// checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel
test("renders the coverage track, daily cost chart, and evaluator cache panel from metered usage", async ({
page,

View file

@ -232,9 +232,15 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
await page.goto("/learn");
await expect(page.getByText(BOOT_SCREEN_TEXT)).toBeVisible();
const bootScreen = page.getByText(BOOT_SCREEN_TEXT);
await expect(bootScreen).toBeVisible();
await expect(bootScreen).toHaveAttribute("data-vignette-boot-loading", "true");
// 부트스트랩이 끝나지 않는 동안은 로그인으로 이동하지 않는다
await expect(page).toHaveURL(/\/learn$/);
// 3.5초와 8초 watchdog은 화면에 실제로 보이는 bounded BootScreen을
// 빈 React root로 오인하지 않는다.
await page.waitForTimeout(8_600);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
releaseMe();
@ -242,6 +248,154 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
});
test("removes a watchdog panel when the delayed boot screen becomes visible", async ({ page }) => {
test.setTimeout(15_000);
let releaseMain!: () => void;
const mainGate = new Promise<void>((resolve) => {
releaseMain = resolve;
});
let releaseMe!: () => void;
const meGate = new Promise<void>((resolve) => {
releaseMe = resolve;
});
await page.route("**/src/main.tsx*", async (route) => {
await mainGate;
await route.continue();
});
await page.route("**/api/auth/me", async (route) => {
await meGate;
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.goto("/learn", { waitUntil: "commit" });
await expect(page.locator("#root")).toBeEmpty();
await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({ timeout: 4_500 });
releaseMain();
await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveAttribute(
"data-vignette-boot-loading",
"true",
);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0, { timeout: 1_500 });
releaseMe();
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
});
test("reports a boot screen that remains visible through the 20-second watchdog", async ({ page }) => {
test.setTimeout(35_000);
let releaseChunk!: () => void;
const gate = new Promise<void>((resolve) => {
releaseChunk = resolve;
});
await page.route("**/src/pages/Login.tsx*", async (route) => {
await gate;
await route.continue();
});
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.route("**/api/auth/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
}),
});
});
const meResolved = page.waitForResponse((response) => response.url().includes("/api/auth/me"));
await page.goto("/login", { waitUntil: "domcontentloaded" });
await meResolved;
await expect(page.getByText(BOOT_SCREEN_TEXT).filter({ visible: true })).toHaveAttribute(
"data-vignette-boot-loading",
"true",
);
await page.waitForTimeout(8_600);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({ timeout: 14_000 });
await expect(page.locator("#vignette-boot-diagnostic")).toHaveAttribute(
"data-vignette-boot-reason",
"watchdog",
);
releaseChunk();
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
});
test("keeps manual and runtime diagnostics when a delayed boot screen appears after watchdog", async ({ page }) => {
test.setTimeout(15_000);
let releaseMain!: () => void;
const mainGate = new Promise<void>((resolve) => {
releaseMain = resolve;
});
let releaseMe!: () => void;
const meGate = new Promise<void>((resolve) => {
releaseMe = resolve;
});
await page.route("**/src/main.tsx*", async (route) => {
await mainGate;
await route.continue();
});
await page.route("**/api/auth/me", async (route) => {
await meGate;
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.goto("/learn", { waitUntil: "commit" });
const diagnostic = page.locator("#vignette-boot-diagnostic");
await expect(diagnostic).toBeVisible({ timeout: 4_500 });
await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "watchdog");
await page.evaluate(() => {
(window as Window & { __VIGNETTE_BOOT_DIAG__?: { check(): void } }).__VIGNETTE_BOOT_DIAG__?.check();
});
await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "manual");
await page.evaluate(() => {
window.dispatchEvent(new ErrorEvent("error", { message: "boot diagnostic runtime fixture" }));
});
await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "runtime-error");
releaseMain();
await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveAttribute(
"data-vignette-boot-loading",
"true",
);
await page.waitForTimeout(250);
await expect(diagnostic).toBeVisible();
await expect(diagnostic).toHaveAttribute("data-vignette-boot-reason", "runtime-error");
await expect(diagnostic.locator("dd").first()).toHaveText("runtime-error");
releaseMe();
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
});
// checklist: shell-suspense-fallback
// 모든 페이지가 lazy import이므로 라우트 청크가 로드되는 동안
// Suspense fallback(BootScreen)이 표시된다. Vite dev 모듈 URL을 지연시켜 재현.
@ -254,7 +408,35 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
await gate;
await route.continue();
});
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.route("**/api/auth/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
}),
});
});
const meResolved = page.waitForResponse(
(res) => res.url().includes("/api/auth/me"),
{ timeout: 15_000 },
@ -285,7 +467,35 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
page.on("request", (request) => {
if (request.isNavigationRequest()) documentRequests += 1;
});
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "not authenticated" }),
});
});
await page.route("**/api/auth/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
],
allowed_email_domains: [],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
}),
});
});
await page.goto("/login");
await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
expect(documentRequests).toBe(1);