전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리 (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가. RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset 루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉 마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤 스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등. - 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규 학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual), 저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence + 결정론 파생 폴백). - 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의 CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect. - 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest 421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과, layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드 142~144차 노트.
This commit is contained in:
parent
0ae94499f2
commit
4511383cd9
55 changed files with 9333 additions and 573 deletions
501
apps/web/e2e/full-sweep-admin-ai.spec.ts
Normal file
501
apps/web/e2e/full-sweep-admin-ai.spec.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
// 2026-07-27 E2E 전수 순회 — 관리자 AI 설정(/admin/ai) 영역의 "신규 spec 필요" 항목 검증.
|
||||
// docs/ops/e2e-full-sweep-2026-07-27.md §8 (admin-ai) 체크리스트 중 기존 spec이 없는 항목을
|
||||
// route fixture로 상태를 고정해 검증한다. 실제 AI 엔진 턴 생성은 하지 않는다.
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
interface AdminUsageBreakdown {
|
||||
provider: string;
|
||||
model: string;
|
||||
turns: number;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
}
|
||||
|
||||
interface AdminUsageDailyCost {
|
||||
day: string;
|
||||
turns: number;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
}
|
||||
|
||||
interface AdminUsageEvaluatorCache {
|
||||
enabled: boolean;
|
||||
entries: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
stores: number;
|
||||
evictions: number;
|
||||
requests: number;
|
||||
hit_rate: number;
|
||||
}
|
||||
|
||||
interface AdminUsageResponse {
|
||||
source: "database" | "server_session_registry";
|
||||
durable: boolean;
|
||||
generated_at: number;
|
||||
window_days: number;
|
||||
total_turns: number;
|
||||
metered_turns: number;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
budget: {
|
||||
limit_usd: number;
|
||||
used_ratio: number;
|
||||
remaining_usd: number | null;
|
||||
status: "disabled" | "ok" | "warn" | "exceeded";
|
||||
};
|
||||
evaluator_cache?: AdminUsageEvaluatorCache;
|
||||
by_provider: AdminUsageBreakdown[];
|
||||
daily_cost?: AdminUsageDailyCost[];
|
||||
}
|
||||
|
||||
interface AdminEngineConfigResponse {
|
||||
engine_mode: string;
|
||||
engine_url: string;
|
||||
model: string;
|
||||
source: "database" | "runtime_cache" | "runtime_default";
|
||||
durable: boolean;
|
||||
updated_by: string | null;
|
||||
updated_at: number | null;
|
||||
}
|
||||
|
||||
function usageFixture(
|
||||
days: number,
|
||||
overrides: Partial<AdminUsageResponse> = {},
|
||||
): AdminUsageResponse {
|
||||
return {
|
||||
source: "database",
|
||||
durable: true,
|
||||
generated_at: 1_785_142_800,
|
||||
window_days: days,
|
||||
total_turns: 0,
|
||||
metered_turns: 0,
|
||||
tokens_in: 0,
|
||||
tokens_out: 0,
|
||||
cost_usd: 0,
|
||||
budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled" },
|
||||
evaluator_cache: {
|
||||
enabled: false,
|
||||
entries: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
stores: 0,
|
||||
evictions: 0,
|
||||
requests: 0,
|
||||
hit_rate: 0,
|
||||
},
|
||||
by_provider: [],
|
||||
daily_cost: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function engineConfigFixture(
|
||||
overrides: Partial<AdminEngineConfigResponse> = {},
|
||||
): AdminEngineConfigResponse {
|
||||
return {
|
||||
engine_mode: "openai",
|
||||
engine_url: "http://127.0.0.1:9099",
|
||||
model: "gateway-default",
|
||||
source: "database",
|
||||
durable: true,
|
||||
updated_by: "admin@twentyoz.kr",
|
||||
updated_at: 1_785_142_800,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface AdminAiMockState {
|
||||
counts: { usage: number; health: number; engine: number };
|
||||
authUser: {
|
||||
account_status?: "pending" | "approved" | "suspended";
|
||||
onboarding_completed_at?: number | null;
|
||||
};
|
||||
usageForWindow: (days: number) => AdminUsageResponse;
|
||||
/** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */
|
||||
usageGate?: (days: number) => Promise<void> | undefined;
|
||||
engineConfig: AdminEngineConfigResponse;
|
||||
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
|
||||
engineConfigStatus?: number;
|
||||
}
|
||||
|
||||
function createMockState(overrides: Partial<AdminAiMockState> = {}): AdminAiMockState {
|
||||
return {
|
||||
counts: { usage: 0, health: 0, engine: 0 },
|
||||
authUser: {},
|
||||
usageForWindow: (days) => usageFixture(days),
|
||||
engineConfig: engineConfigFixture(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const method = request.method();
|
||||
const path = url.pathname;
|
||||
const fulfillJson = (body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
|
||||
if (method === "GET" && path.endsWith("/auth/me")) {
|
||||
await fulfillJson({
|
||||
user_id: "sweep-ai-admin",
|
||||
email: "sweep-ai-admin@twentyoz.kr",
|
||||
display_name: "Sweep AI Admin",
|
||||
role: "admin",
|
||||
admin_access: true,
|
||||
super_admin: true,
|
||||
account_status: state.authUser.account_status ?? "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: null,
|
||||
onboarding_completed_at: state.authUser.onboarding_completed_at ?? 1_782_900_000,
|
||||
nickname: "",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && path.endsWith("/admin/usage")) {
|
||||
state.counts.usage += 1;
|
||||
const days = Number(url.searchParams.get("window_days") ?? 30);
|
||||
const gate = state.usageGate?.(days);
|
||||
if (gate) await gate;
|
||||
await fulfillJson(state.usageForWindow(days));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && path.endsWith("/admin/health")) {
|
||||
state.counts.health += 1;
|
||||
await fulfillJson({
|
||||
status: "ok",
|
||||
environment: "dev",
|
||||
engine_mode: "openai",
|
||||
services: [
|
||||
{
|
||||
key: "engine",
|
||||
name: "응답 엔진",
|
||||
status: "ok",
|
||||
detail: "엔진 응답 정상",
|
||||
metric: "120ms",
|
||||
load: 0.2,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && path.endsWith("/admin/engine-config")) {
|
||||
state.counts.engine += 1;
|
||||
if (state.engineConfigStatus) {
|
||||
await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(state.engineConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "PATCH" && path.endsWith("/admin/engine-config")) {
|
||||
const body = request.postDataJSON() as Partial<AdminEngineConfigResponse>;
|
||||
await fulfillJson({ ...state.engineConfig, ...body });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson({ detail: `unmocked API request: ${method} ${path}` }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("full sweep: admin ai operations", () => {
|
||||
// checklist: admin-ai-guard-pending-approval
|
||||
test("redirects a pending-approval admin from /admin/ai to /pending", async ({ page }) => {
|
||||
const state = createMockState({
|
||||
authUser: { account_status: "pending", onboarding_completed_at: 1_782_900_000 },
|
||||
});
|
||||
await mockAdminAiSession(page, state);
|
||||
|
||||
await page.goto("/admin/ai");
|
||||
|
||||
await expect(page).toHaveURL(/\/pending$/);
|
||||
await expect(page.getByText("승인 대기")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveCount(0);
|
||||
// 승인 대기 계정은 관리자 데이터 API를 호출하지 않아야 한다.
|
||||
expect(state.counts).toEqual({ usage: 0, health: 0, engine: 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 ({
|
||||
page,
|
||||
}) => {
|
||||
let gate: ReturnType<typeof deferred> | null = deferred();
|
||||
const state = createMockState({
|
||||
usageGate: () => gate?.promise,
|
||||
});
|
||||
await mockAdminAiSession(page, state);
|
||||
|
||||
await page.goto("/admin/ai");
|
||||
|
||||
const container = page.locator('[data-testid="admin-ai-page"]');
|
||||
const refreshButton = page.getByRole("button", { name: "새로고침" });
|
||||
|
||||
// usage 응답이 보류된 동안 aria-busy=true, 새로고침 버튼 비활성.
|
||||
await expect(container).toHaveAttribute("aria-busy", "true");
|
||||
await expect(refreshButton).toBeDisabled();
|
||||
|
||||
gate.resolve();
|
||||
gate = null;
|
||||
await expect(container).toHaveAttribute("aria-busy", "false");
|
||||
await expect(refreshButton).toBeEnabled();
|
||||
// React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다.
|
||||
const base = { ...state.counts };
|
||||
expect(base.health).toBe(1);
|
||||
expect(base.engine).toBe(1);
|
||||
|
||||
// 새로고침 클릭 → 세 엔드포인트 모두 재조회, 로딩 동안 다시 비활성.
|
||||
gate = deferred();
|
||||
await refreshButton.click();
|
||||
await expect(container).toHaveAttribute("aria-busy", "true");
|
||||
await expect(refreshButton).toBeDisabled();
|
||||
|
||||
gate.resolve();
|
||||
gate = null;
|
||||
await expect(container).toHaveAttribute("aria-busy", "false");
|
||||
await expect(refreshButton).toBeEnabled();
|
||||
await expect
|
||||
.poll(() => state.counts)
|
||||
.toEqual({ usage: base.usage + 1, health: base.health + 1, engine: base.engine + 1 });
|
||||
});
|
||||
|
||||
// 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 ({
|
||||
page,
|
||||
}) => {
|
||||
const state = createMockState({ engineConfigStatus: 500 });
|
||||
await mockAdminAiSession(page, state);
|
||||
|
||||
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();
|
||||
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: "새로고침" }).click();
|
||||
await expect(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);
|
||||
});
|
||||
|
||||
// checklist: admin-ai-window-toggle
|
||||
test("refetches only usage on window change and ignores a stale late response", async ({
|
||||
page,
|
||||
}) => {
|
||||
const slowWindow = deferred();
|
||||
const state = createMockState({
|
||||
usageForWindow: (days) =>
|
||||
usageFixture(days, {
|
||||
total_turns: 10,
|
||||
metered_turns: 10,
|
||||
cost_usd: days === 90 ? 9.99 : days === 7 ? 1.11 : 3.33,
|
||||
}),
|
||||
usageGate: (days) => (days === 90 ? slowWindow.promise : undefined),
|
||||
});
|
||||
await mockAdminAiSession(page, state);
|
||||
|
||||
await page.goto("/admin/ai");
|
||||
const ledger = page.locator(".aic-ledger");
|
||||
await expect(ledger).toContainText("$3.3300");
|
||||
// React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다.
|
||||
const base = { ...state.counts };
|
||||
expect(base.health).toBe(1);
|
||||
expect(base.engine).toBe(1);
|
||||
|
||||
// 90일 클릭(응답 보류) 후 곧바로 7일 클릭 → 7일 데이터가 반영된다.
|
||||
const staleResponse = page.waitForResponse((response) =>
|
||||
response.url().includes("/api/admin/usage?window_days=90"),
|
||||
);
|
||||
await page.getByRole("button", { name: "90일" }).click();
|
||||
await expect(page.getByRole("button", { name: "90일" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
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("7일 DB 집계");
|
||||
|
||||
// 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다.
|
||||
slowWindow.resolve();
|
||||
await staleResponse;
|
||||
await expect(ledger).toContainText("$1.1100");
|
||||
await expect(ledger).not.toContainText("$9.9900");
|
||||
|
||||
// 기간 변경은 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);
|
||||
});
|
||||
|
||||
// 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,
|
||||
}) => {
|
||||
const state = createMockState({
|
||||
usageForWindow: (days) =>
|
||||
usageFixture(days, {
|
||||
total_turns: 40,
|
||||
metered_turns: 30,
|
||||
tokens_in: 125_000,
|
||||
tokens_out: 18_500,
|
||||
cost_usd: 6.6212,
|
||||
budget: { limit_usd: 20, used_ratio: 0.33106, remaining_usd: 13.3788, status: "ok" },
|
||||
evaluator_cache: {
|
||||
enabled: true,
|
||||
entries: 12,
|
||||
hits: 8,
|
||||
misses: 2,
|
||||
stores: 2,
|
||||
evictions: 1,
|
||||
requests: 10,
|
||||
hit_rate: 0.8,
|
||||
},
|
||||
by_provider: [
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-5-mini",
|
||||
turns: 30,
|
||||
tokens_in: 125_000,
|
||||
tokens_out: 18_500,
|
||||
cost_usd: 6.6212,
|
||||
},
|
||||
],
|
||||
daily_cost: [
|
||||
{ day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
|
||||
{ day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
|
||||
{ day: "2026-07-15", turns: 12, tokens_in: 52_000, tokens_out: 7_600, cost_usd: 3.1212 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
await mockAdminAiSession(page, state);
|
||||
|
||||
await page.goto("/admin/ai");
|
||||
|
||||
// DB 계량 커버리지 트랙: metered/total 비율과 미계량 턴 안내.
|
||||
const budgetPanel = page.locator(".aic-budget");
|
||||
await expect(budgetPanel).toContainText("DB 계량 커버리지");
|
||||
await expect(budgetPanel).toContainText("75%");
|
||||
await expect(budgetPanel.locator('[aria-label="DB 계량 커버리지 75%"]')).toHaveCount(1);
|
||||
await expect(budgetPanel).toContainText(
|
||||
"10개 턴은 provider·model·token·cost 계량 필드가 없습니다.",
|
||||
);
|
||||
|
||||
// 일별 비용 차트: role=img aria-label, 날짜·비용 툴팁, 막대 개수.
|
||||
const chart = page.locator(".aic-chart[role='img']");
|
||||
await expect(chart).toBeVisible();
|
||||
await expect(chart).toHaveAttribute("aria-label", "30일 일별 AI 비용 막대 차트");
|
||||
await expect(chart.locator(".aic-chart__day")).toHaveCount(3);
|
||||
await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute(
|
||||
"title",
|
||||
"2026-07-13 $1.4200",
|
||||
);
|
||||
await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴");
|
||||
|
||||
// 평가 캐시 패널: 사용 중 배지, hit-rate, 6개 카운트, 원문 미저장 안내.
|
||||
const cachePanel = page.locator(".aic-panel").filter({ hasText: "평가 캐시 효율" });
|
||||
await expect(cachePanel).toContainText("사용 중");
|
||||
await expect(cachePanel.locator(".aic-cache-score")).toContainText("80%");
|
||||
await expect(cachePanel.locator(".aic-cache-score")).toContainText("hit-rate");
|
||||
const miniGrid = cachePanel.locator(".aic-mini-grid");
|
||||
await expect(miniGrid).toContainText("요청");
|
||||
await expect(miniGrid.locator("div").filter({ hasText: "요청" }).locator("dd")).toHaveText("10");
|
||||
await expect(miniGrid.locator("div").filter({ hasText: "적중" }).locator("dd")).toHaveText("8");
|
||||
await expect(miniGrid.locator("div").filter({ hasText: "미스" }).locator("dd")).toHaveText("2");
|
||||
await expect(miniGrid.locator("div").filter({ hasText: "저장" }).locator("dd")).toHaveText("2");
|
||||
await expect(
|
||||
miniGrid.locator("div").filter({ hasText: "현재 엔트리" }).locator("dd"),
|
||||
).toHaveText("12");
|
||||
await expect(miniGrid.locator("div").filter({ hasText: "축출" }).locator("dd")).toHaveText("1");
|
||||
await expect(cachePanel).toContainText(
|
||||
"캐시는 성공적으로 파싱된 평가 결과만 보관하며 원문 프롬프트와 응답은 저장하지 않습니다.",
|
||||
);
|
||||
});
|
||||
|
||||
// checklist: admin-ai-daily-chart-empty, admin-ai-provider-table-empty
|
||||
test("shows empty states for the daily chart and the provider ledger", async ({ page }) => {
|
||||
const state = createMockState({
|
||||
usageForWindow: (days) => usageFixture(days, { by_provider: [], daily_cost: [] }),
|
||||
});
|
||||
await mockAdminAiSession(page, state);
|
||||
|
||||
await page.goto("/admin/ai");
|
||||
|
||||
await expect(page.getByText("선택한 기간에 비용이 계량된 AI 호출이 없습니다.")).toBeVisible();
|
||||
await expect(page.getByText("모델별 계량 행이 아직 없습니다.")).toBeVisible();
|
||||
await expect(page.locator(".aic-chart")).toHaveCount(0);
|
||||
await expect(page.locator(".aic-table")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// checklist: admin-ai-engine-config-meta
|
||||
test("shows engine config metadata for durable DB and runtime-only sources", async ({ page }) => {
|
||||
const state = createMockState({
|
||||
engineConfig: engineConfigFixture({
|
||||
source: "database",
|
||||
durable: true,
|
||||
updated_by: "admin@twentyoz.kr",
|
||||
updated_at: 1_785_142_800,
|
||||
}),
|
||||
});
|
||||
await mockAdminAiSession(page, state);
|
||||
|
||||
await page.goto("/admin/ai");
|
||||
|
||||
const meta = page.locator(".aic-config-meta");
|
||||
await expect(meta).toBeVisible();
|
||||
const rowValue = (label: string) =>
|
||||
meta.locator("div").filter({ hasText: label }).locator("dd");
|
||||
await expect(rowValue("저장 원천")).toHaveText("DB 영구 저장");
|
||||
await expect(rowValue("현재 소스")).toHaveText("database");
|
||||
await expect(rowValue("최근 변경")).not.toHaveText("기록 없음");
|
||||
await expect(rowValue("변경자")).toHaveText("admin@twentyoz.kr");
|
||||
|
||||
// 런타임 적용 변형: durable=false·기록 없음 표시.
|
||||
state.engineConfig = engineConfigFixture({
|
||||
source: "runtime_default",
|
||||
durable: false,
|
||||
updated_by: null,
|
||||
updated_at: null,
|
||||
});
|
||||
await page.getByRole("button", { name: "새로고침" }).click();
|
||||
await expect(rowValue("저장 원천")).toHaveText("런타임 적용");
|
||||
await expect(rowValue("현재 소스")).toHaveText("runtime_default");
|
||||
await expect(rowValue("최근 변경")).toHaveText("기록 없음");
|
||||
await expect(rowValue("변경자")).toHaveText("기록 없음");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue