관리자 설정 로딩과 부트 진단 복구
This commit is contained in:
parent
d58eb67764
commit
fb640a75ac
7 changed files with 767 additions and 100 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue