feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue