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

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

@ -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,