/** * uc-admin-console.spec.ts — 관리자 콘솔 유스케이스 여정 스펙 * * 목적: 운영 관리자가 실제로 수행하는 콘솔 여정을 route fixture만으로 검증한다. * - /admin/access 권한 정책 화면 탭 조작(역할·그룹·권한 매트릭스) * - 신규 가입 승인·보류 처리 플로우(PATCH 본문·큐 갱신·요약 카운트) * - 운영 티켓 상세 열람, 해결 처리, 해결 노트 작성 기대(learner Settings가 노출하는 * resolution_note를 관리자가 작성할 수단이 있는지) * - AI 엔진 설정 변경 확인 단계: 게이트웨이 검증 fail-closed 표시, 연결 주소 변경 재검증 * - 서비스 헬스 이벤트/지표 표시(overview) * - G8 지속 개선 게이트: 승인 차단 사유 상시 노출과 키보드(Enter) 제출 * - 사용자 검색(슈퍼 관리자 키워드·소속 필드) * * 근거: src/pages/Admin.tsx, src/pages/AdminAi.tsx, * src/pages/admin/ContinuousImprovementCockpit.tsx, src/lib/api.gen.ts 계약. * 실 API 없이 전부 route fixture로 동작하며, 실제 AI 엔진 턴 생성은 없다. * 기존 admin.spec.ts / full-sweep-admin*.spec.ts / continuous-improvement-admin.spec.ts가 * 다룬 시나리오(역할별 라우트 가드, 검색 by 이메일·역할·코호트, 티켓 필터 쿼리, * usage 윈도 전환, 콘텐츠·모델 게이트 승인 플로우)는 반복하지 않는다. */ import { mkdir } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page, type Route } from "@playwright/test"; const NOW = 1_755_400_000; // 2026-08-17 근처 고정 기준 const CREATED_AT = "2026-08-06T08:30:00Z"; const MODEL_GATE_ID = "90000000-0000-4000-8000-000000000001"; const RELEASE_GATE_ID = "90000000-0000-4000-8000-000000000002"; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } interface FixtureUser { user_id: string; email: string; display_name: string; role: "learner" | "teacher" | "admin"; admin_access: boolean; learner_feedback_enabled: boolean; super_admin: boolean; account_status: "pending" | "approved" | "suspended"; affiliation: string; cohort_ids: string[]; active_sessions: number; created_at: number; last_seen_at: number; source: "database"; } function makeUser(overrides: Partial & { user_id: string }): FixtureUser { return { email: `${overrides.user_id}@hs.ac.kr`, display_name: overrides.user_id, role: "learner", admin_access: false, learner_feedback_enabled: true, super_admin: false, account_status: "approved", affiliation: "", cohort_ids: [], active_sessions: 0, created_at: NOW - 86_400, last_seen_at: NOW - 3_600, source: "database", ...overrides, }; } interface FixtureTicket { ticket_id: string; reporter: { email: string; display_name: string; role: string }; category: string; priority: "low" | "normal" | "high" | "urgent"; status: "open" | "triaged" | "in_progress" | "resolved" | "closed"; subject: string; body: string; source_path: string; assigned_group: string; fingerprint: string; resolution_note: string; event_count: number; last_event_at: number | null; parent_ticket_id: string | null; duplicate_count: number; duplicate_parent_candidate_id: string | null; child_ticket_count: number; resolved_at: number | null; created_at: number; updated_at: number; } interface FixtureProtocol { protocol_id: string; source_id: string; title: string; source: string; version: number; license: "A" | "B" | "C" | "D"; external_llm_ok: boolean; content: string; content_hash: string; status: "draft" | "active" | "retired"; registered_by: string; registered_at: string; activated_at: string | null; retired_at: string | null; } function makeProtocol( overrides: Partial & { protocol_id: string; title: string }, ): FixtureProtocol { return { source_id: `protocol:${overrides.protocol_id}`, source: "https://example.edu/protocols/default", version: 1, license: "B", external_llm_ok: false, content: "위험도를 먼저 확인한다.", content_hash: "a".repeat(64), status: "draft", registered_by: "uc-admin-1", registered_at: CREATED_AT, activated_at: null, retired_at: null, ...overrides, }; } function makeTicket( overrides: Partial & { ticket_id: string; subject: string }, ): FixtureTicket { return { reporter: { email: "learner1@hs.ac.kr", display_name: "이서연", role: "learner" }, category: "session_review", priority: "normal", status: "open", body: "문제 상황 설명", source_path: "/learn", assigned_group: "", fingerprint: "f".repeat(64), resolution_note: "", event_count: 0, last_event_at: null, parent_ticket_id: null, duplicate_count: 0, duplicate_parent_candidate_id: null, child_ticket_count: 0, resolved_at: null, created_at: NOW - 40_000, updated_at: NOW - 20_000, ...overrides, }; } function ticketsSummary(tickets: FixtureTicket[]) { const active = tickets.filter( (ticket) => ticket.status !== "resolved" && ticket.status !== "closed", ); const byCategory: Record = {}; for (const ticket of active) { byCategory[ticket.category] = (byCategory[ticket.category] ?? 0) + 1; } return { total: tickets.length, open_count: active.length, high_priority_count: active.filter( (ticket) => ticket.priority === "high" || ticket.priority === "urgent", ).length, stale_count: 0, by_category: byCategory, by_priority: {}, by_status: {}, }; } interface AdminMockOptions { users?: FixtureUser[]; tickets?: FixtureTicket[]; protocols?: FixtureProtocol[]; protocolActivationFailures?: number; services?: Array<{ key: string; name: string; status: "ok" | "degraded" | "down"; detail: string; metric: string; load: number; }>; healthStatus?: "ok" | "degraded" | "down"; engineMode?: string; uptime?: { sample_count: number; ok_ratio: number; last_down_at: number | null }; capabilitiesAvailable?: boolean; capabilitiesDetail?: string; superAdmin?: boolean; } /** * 관리자 세션 + 운영 콘솔 API 전체를 fixture로 응답한다. * catch-all(404) 뒤에 개별 분기가 우선하도록 단일 핸들러 내부에서 분기한다. * 사용자/티켓 PATCH는 상태를 갱신해 이후 재조회에 반영한다(결정론 유지). */ async function installAdminMock(page: Page, options: AdminMockOptions = {}) { const users = options.users ?? []; let tickets = options.tickets ?? []; let protocols = options.protocols ?? []; let protocolActivationFailures = options.protocolActivationFailures ?? 0; const createdUserBodies: Array> = []; const createdProtocolBodies: Array> = []; const patchedUserBodies: Array> = []; const patchedTicketBodies: Array> = []; await page.route("**/api/**", async (route: Route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname; const method = request.method(); const fulfill = (body: unknown, status = 200) => route.fulfill(jsonRoute(body, status)); if (method === "GET" && path.endsWith("/auth/me")) { await fulfill({ user_id: "uc-admin-1", email: "uc-admin@twentyoz.kr", display_name: "운영 관리자", role: "admin", admin_access: true, super_admin: options.superAdmin ?? true, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: NOW - 1_000_000, onboarding_completed_at: NOW - 1_000_000, nickname: "운영", self_introduction: "", avatar_url: "", }); return; } if (method === "GET" && path.endsWith("/admin/health")) { await fulfill({ status: options.healthStatus ?? "ok", environment: "dev", engine_mode: options.engineMode ?? "claude_cli", services: options.services ?? [ { key: "engine", name: "AI 엔진", status: "ok", detail: "게이트웨이 응답 정상", metric: "240ms", load: 0.24, }, ], }); return; } if (method === "GET" && path.endsWith("/admin/users")) { await fulfill({ source: "database", durable: true, users }); return; } if (method === "GET" && path.endsWith("/admin/protocols")) { await fulfill({ protocols, total: protocols.length }); return; } if (method === "POST" && path.endsWith("/admin/protocols")) { const body = request.postDataJSON() as Record; createdProtocolBodies.push(body); const protocolId = `71000000-0000-4000-8000-${String( createdProtocolBodies.length, ).padStart(12, "0")}`; const created = makeProtocol({ protocol_id: protocolId, title: String(body.title ?? ""), source: String(body.source ?? ""), version: Number(body.version ?? 1), license: (body.license as FixtureProtocol["license"] | undefined) ?? "B", external_llm_ok: body.external_llm_ok === true, content: String(body.content ?? ""), content_hash: "b".repeat(64), }); protocols = [created, ...protocols]; await fulfill(created, 201); return; } const protocolAction = /\/admin\/protocols\/([^/]+)\/(activate|retire)$/.exec(path); if (method === "POST" && protocolAction) { const [, protocolId, action] = protocolAction; const index = protocols.findIndex((item) => item.protocol_id === protocolId); if (index === -1) { await fulfill({ detail: "unknown protocol" }, 404); return; } if (action === "activate") { if (protocolActivationFailures > 0) { protocolActivationFailures -= 1; await fulfill({ detail: "vector unavailable" }, 503); return; } if (protocols[index].status !== "draft") { await fulfill({ detail: "draft required" }, 409); return; } protocols[index] = { ...protocols[index], status: "active", activated_at: CREATED_AT, }; await fulfill({ protocol: protocols[index], chunks_indexed: 2, skipped_unchanged: false, embedded: true, degraded: false, }); return; } if (protocols[index].status !== "active") { await fulfill({ detail: "active required" }, 409); return; } protocols[index] = { ...protocols[index], status: "retired", retired_at: CREATED_AT, }; await fulfill(protocols[index]); return; } if (method === "POST" && path.endsWith("/admin/users")) { const body = request.postDataJSON() as Record; createdUserBodies.push(body); const created = makeUser({ user_id: `preregistered-${createdUserBodies.length}`, email: String(body.email ?? ""), display_name: String(body.display_name ?? ""), role: (body.role as FixtureUser["role"] | undefined) ?? "learner", admin_access: Boolean(body.admin_access), learner_feedback_enabled: body.learner_feedback_enabled !== false, account_status: (body.account_status as FixtureUser["account_status"] | undefined) ?? "pending", affiliation: String(body.affiliation ?? ""), cohort_ids: Array.isArray(body.cohort_ids) ? body.cohort_ids.map((value) => String(value)) : [], created_at: NOW, last_seen_at: NOW, }); users.unshift(created); await fulfill(created, 201); return; } const userPatch = /\/admin\/users\/([^/]+)$/.exec(path); if (method === "PATCH" && userPatch) { const body = request.postDataJSON() as Record; patchedUserBodies.push({ user_id: userPatch[1], ...body }); const index = users.findIndex((user) => user.user_id === userPatch[1]); if (index === -1) { await fulfill({ detail: "unknown user" }, 404); return; } users[index] = { ...users[index], ...body } as FixtureUser; await fulfill(users[index]); return; } if (method === "GET" && path.endsWith("/admin/usage")) { await fulfill({ source: "database", durable: true, generated_at: NOW - 600, window_days: Number(url.searchParams.get("window_days") ?? 7), total_turns: 12, metered_turns: 12, token_metered_turns: 12, token_unmetered_turns: 0, tokens_in: 42_000, tokens_out: 5_600, cost_usd: 1.25, recorded_cost_usd: 1.25, estimated_cost_usd: 0, budget: { limit_usd: 20, used_ratio: 0.0625, remaining_usd: 18.75, status: "ok" }, evaluator_cache: { enabled: true, entries: 4, hits: 6, misses: 2, stores: 2, evictions: 0, requests: 8, hit_rate: 0.75, }, by_provider: [], daily_cost: [], }); return; } if (method === "GET" && path.endsWith("/admin/uptime")) { const uptime = options.uptime ?? { sample_count: 288, ok_ratio: 0.996, last_down_at: NOW - 200_000, }; await fulfill({ source: "database", durable: true, window_hours: 24, sample_count: uptime.sample_count, ok_ratio: uptime.ok_ratio, degraded_events: 1, down_events: 1, last_down_at: uptime.last_down_at, }); return; } if (method === "GET" && path.endsWith("/admin/tickets")) { await fulfill({ source: "database", durable: true, generated_at: NOW - 300, tickets, summary: ticketsSummary(tickets), }); return; } const ticketPatch = /\/admin\/tickets\/([^/]+)$/.exec(path); if (method === "PATCH" && ticketPatch) { const body = request.postDataJSON() as Record; patchedTicketBodies.push({ ticket_id: ticketPatch[1], ...body }); tickets = tickets.map((ticket) => ticket.ticket_id === ticketPatch[1] ? { ...ticket, ...body, resolved_at: body.status === "resolved" ? NOW - 100 : ticket.resolved_at, updated_at: NOW - 100, } : ticket, ) as FixtureTicket[]; const updated = tickets.find((ticket) => ticket.ticket_id === ticketPatch[1]); await fulfill(updated ?? { detail: "unknown ticket" }, updated ? 200 : 404); return; } if (method === "GET" && path.endsWith("/admin/engine-config")) { await fulfill({ engine_mode: "openai", engine_url: "http://127.0.0.1:9099", model: "gateway-default", reasoning_effort: "medium", source: "database", durable: true, updated_by: "uc-admin@twentyoz.kr", updated_at: NOW - 5_000, }); return; } if (method === "GET" && path.endsWith("/admin/engine-capabilities")) { if (options.capabilitiesAvailable === false) { await fulfill({ provider: url.searchParams.get("engine_mode") ?? "openai", available: false, source: "error", models: [], default_model: null, default_reasoning_effort: null, detail: options.capabilitiesDetail ?? "게이트웨이에 연결할 수 없어 모델 검증에 실패했습니다.", fetched_at: NOW - 10, }); return; } await fulfill({ provider: url.searchParams.get("engine_mode") ?? "openai", available: true, source: "live_cli", models: [ { id: "gateway-default", label: "게이트웨이 기본 모델", description: "fixture 기본 모델", reasoning_efforts: ["low", "medium", "high"], default_reasoning_effort: "medium", is_default: true, }, ], default_model: "gateway-default", default_reasoning_effort: "medium", detail: "fixture 모델 목록", fetched_at: NOW - 10, }); return; } await fulfill({ detail: `unmocked API request: ${method} ${path}` }, 404); }); return { createdUserBodies, createdProtocolBodies, patchedUserBodies, patchedTicketBodies, }; } /** G8 지속 개선 콕핏 fixture — 모델 게이트는 증거 2종만, 릴리스 게이트는 4종 완비. */ function ciView() { const artifact = ( suffix: string, ownerKind: "model_change_gate" | "release_gate", ownerId: string, kind: "baseline" | "threshold" | "provenance" | "rollback", ) => ({ artifact_record_id: `91000000-0000-4000-8000-0000000000${suffix}`, owner_kind: ownerKind, owner_id: ownerId, artifact_kind: kind, artifact_id: `uc-g8-${kind}-${suffix}`, content_sha256: kind.slice(0, 1).repeat(64), provenance_uri: `audit://uc/g8/${ownerId}/${kind}`, created_at: CREATED_AT, }); return { content_qualifications: [], model_change_gates: [ { gate_id: MODEL_GATE_ID, gate_decision: "promote", reasons: ["synthetic benchmark threshold passed"], state: "pending_human_approval", created_at: CREATED_AT, }, ], release_gates: [ { gate_id: RELEASE_GATE_ID, release_id: "uc-g8-release-2026-08-17", qualified: true, state: "pending_human_approval", created_at: CREATED_AT, }, ], gate_artifacts: [ artifact("01", "model_change_gate", MODEL_GATE_ID, "baseline"), artifact("02", "model_change_gate", MODEL_GATE_ID, "threshold"), artifact("05", "release_gate", RELEASE_GATE_ID, "baseline"), artifact("06", "release_gate", RELEASE_GATE_ID, "threshold"), artifact("07", "release_gate", RELEASE_GATE_ID, "provenance"), artifact("08", "release_gate", RELEASE_GATE_ID, "rollback"), ], approvals: [] as Array>, catalog_entries: [] as Array>, lifecycle_events: [] as Array>, incidents: [], regression_dag_nodes: [], data_classification: "synthetic_replay_red_team_coverage_drift", silent_auto_promotion_allowed: false, raw_transcript_included: false, pii_included: false, clinical_claim_allowed: false, }; } async function installCiMock(page: Page) { const view = ciView(); const approvalBodies: Array> = []; await page.route("**/api/**", async (route: Route) => { const request = route.request(); const path = new URL(request.url()).pathname; const fulfill = (body: unknown, status = 200) => route.fulfill(jsonRoute(body, status)); if (request.method() === "GET" && path.endsWith("/auth/me")) { await fulfill({ user_id: "uc-ci-admin-1", email: "uc-ci-admin@twentyoz.kr", display_name: "개선 관리자", role: "admin", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: NOW - 1_000_000, onboarding_completed_at: NOW - 1_000_000, nickname: "", self_introduction: "", avatar_url: "", }); return; } if (request.method() === "GET" && path.endsWith("/continuous-improvement")) { await fulfill(view); return; } if (request.method() === "POST" && path.endsWith("/continuous-improvement/approvals")) { const body = request.postDataJSON() as Record; approvalBodies.push(body); view.approvals.push({ approval_event_id: body.approval_event_id, target_kind: body.target_kind, target_id: body.target_id, decision: body.decision, reason_code: body.reason_code, evidence_refs: body.evidence_refs, created_at: CREATED_AT, }); view.lifecycle_events.unshift({ lifecycle_event_id: body.effect_record_id, target_kind: body.target_kind, target_id: body.target_id, event_type: "promotion", event_status: "approved", evidence_refs: body.evidence_refs, created_at: CREATED_AT, }); await fulfill( { submission_id: body.submission_id, approval_event_id: body.approval_event_id, target_kind: body.target_kind, target_id: body.target_id, decision: body.decision, effect_record_id: body.effect_record_id, idempotent_replay: false, }, 201, ); return; } await fulfill({ detail: `unmocked ${request.method()} ${path}` }, 404); }); return { view, approvalBodies }; } test.describe("관리자 콘솔 유스케이스 여정", () => { // usecase: 관리자가 권한 정책 화면에서 역할 탭을 확인하고 그룹 탭으로 전환한다 test("권한 화면에서 역할 정책을 읽고 그룹 정책 탭으로 전환한다", async ({ page }) => { await installAdminMock(page); await page.goto("/admin/access"); await expect( page.getByRole("heading", { name: "역할, 그룹, 접근 범위" }), ).toBeVisible(); // 기본 탭은 역할 — 3개 역할 정책 카드가 권한 칩과 함께 보인다. await expect(page.getByRole("tab", { name: "역할" })).toHaveAttribute( "aria-selected", "true", ); const adminPolicy = page.locator(".vgops-policy").filter({ has: page.getByRole("heading", { name: "관리자" }), }); await expect(adminPolicy).toContainText("4개 권한"); await expect(adminPolicy).toContainText("서비스 헬스"); await expect(adminPolicy).toContainText("학습자 상담 내용은 기본 운영 화면에서 제외"); await expect( page.locator(".vgops-policy").filter({ has: page.getByRole("heading", { name: "학습자" }), }), ).toContainText("타 사용자 데이터와 운영 리소스 접근 불가"); // 그룹 탭으로 전환하면 운영 그룹 정책 초안 카드가 대신 보인다. await page.getByRole("tab", { name: "그룹" }).click(); await expect(page.getByRole("tab", { name: "그룹" })).toHaveAttribute( "aria-selected", "true", ); const operatorGroup = page.locator(".vgops-policy").filter({ has: page.getByRole("heading", { name: "서비스 운영자" }), }); await expect(operatorGroup).toContainText("정책 초안"); await expect(operatorGroup).toContainText("티켓 큐"); await expect(page.getByRole("heading", { name: "관리자", exact: true })).toHaveCount(0); }); // usecase: 관리자가 권한 매트릭스 탭에서 리소스별 역할 권한을 대조한다 test("권한 매트릭스 탭에서 AI 운영 설정 접근 범위를 확인한다", async ({ page }) => { await installAdminMock(page); await page.goto("/admin/access"); await page.getByRole("tab", { name: "권한 매트릭스" }).click(); const table = page.locator(".vgops-access-table"); await expect(table).toBeVisible(); await expect(table.locator(".vgops-access-table__head")).toContainText("리소스"); await expect(table.locator(".vgops-access-table__head")).toContainText("교수자"); const aiRow = table.locator(".vgops-access-row").filter({ hasText: "AI 운영 설정" }); await expect(aiRow).toContainText("수정"); const aiRowCells = aiRow.locator("span"); await expect(aiRowCells.nth(0)).toHaveText("수정"); await expect(aiRowCells.nth(1)).toHaveText("없음"); await expect(aiRowCells.nth(2)).toHaveText("없음"); const reviewRow = table.locator(".vgops-access-row").filter({ hasText: "회기 리뷰" }); await expect(reviewRow).toContainText("담당 코호트"); await expect(reviewRow).toContainText("본인 회기"); }); // usecase: 관리자가 신규 가입 요청을 승인해 대기 큐를 비운다 test("가입 승인 큐에서 신규 가입을 승인하면 큐와 카운트가 갱신된다", async ({ page }) => { const pendingUser = makeUser({ user_id: "signup-1", email: "new-learner@hs.ac.kr", display_name: "신규 학습자", account_status: "pending", affiliation: "상담학과", cohort_ids: ["cohort-2026a"], created_at: NOW - 7_200, }); const { patchedUserBodies } = await installAdminMock(page, { users: [pendingUser, makeUser({ user_id: "old-1", display_name: "기존 학습자" })], }); await page.goto("/admin/users"); // 대기 1건이 탭 라벨과 요약 카운트에 함께 보인다. await expect(page.getByRole("tab", { name: "가입 승인 1" })).toHaveAttribute( "aria-selected", "true", ); const metrics = page.locator(".vgops-approval-metrics span"); await expect(metrics.nth(0)).toContainText("1"); await expect(metrics.nth(0)).toContainText("대기"); const card = page.locator(".vgops-approval").filter({ hasText: "new-learner@hs.ac.kr" }); await expect(card).toContainText("승인 대기"); await expect(card).toContainText("상담학과"); await expect(card).toContainText("cohort-2026a"); const patchPromise = page.waitForRequest( (request) => request.method() === "PATCH" && request.url().includes("/admin/users/signup-1"), ); await card.getByRole("button", { name: "승인" }).click(); const patch = await patchPromise; expect(patch.postDataJSON()).toMatchObject({ account_status: "approved" }); // 승인되면 큐가 비고 승인 카운트가 늘어난다. await expect(page.getByText("처리할 가입 요청이 없습니다")).toBeVisible(); await expect(metrics.nth(0)).toContainText("0"); await expect(metrics.nth(1)).toContainText("2"); await expect(page.getByRole("tab", { name: "가입 승인", exact: true })).toBeVisible(); }); // usecase: 관리자가 의심스러운 가입 요청을 보류(정지) 처리한다 test("가입 승인 큐에서 가입 요청을 보류하면 보류 카운트로 이동한다", async ({ page }) => { const { patchedUserBodies } = await installAdminMock(page, { users: [ makeUser({ user_id: "signup-a", email: "keep@hs.ac.kr", display_name: "정상 가입", account_status: "pending", }), makeUser({ user_id: "signup-b", email: "suspicious@hs.ac.kr", display_name: "의심 가입", account_status: "pending", }), ], }); await page.goto("/admin/users"); const metrics = page.locator(".vgops-approval-metrics span"); await expect(metrics.nth(0)).toContainText("2"); const suspicious = page .locator(".vgops-approval") .filter({ hasText: "suspicious@hs.ac.kr" }); const patchPromise = page.waitForRequest( (request) => request.method() === "PATCH" && request.url().includes("/admin/users/signup-b"), ); await suspicious.getByRole("button", { name: "보류" }).click(); const patch = await patchPromise; expect(patch.postDataJSON()).toMatchObject({ account_status: "suspended" }); expect(patchedUserBodies).toHaveLength(1); // 보류된 계정은 큐에서 내려가고 나머지 대기 건만 남는다. await expect(suspicious).toHaveCount(0); await expect( page.locator(".vgops-approval").filter({ hasText: "keep@hs.ac.kr" }), ).toBeVisible(); await expect(metrics.nth(0)).toContainText("1"); await expect(metrics.nth(2)).toContainText("1"); }); // usecase: 관리자가 티켓 큐에서 접수 상세(신고자·담당·이력·출처)를 열람한다 test("티켓 카드에서 신고자, 담당 그룹, 처리 이력, 출처 경로를 상세 열람한다", async ({ page, }) => { await installAdminMock(page, { tickets: [ makeTicket({ ticket_id: "ticket-detail-1", subject: "세션 리뷰가 저장되지 않음", body: "리뷰 화면에서 저장을 눌러도 결과가 사라집니다.", status: "in_progress", priority: "high", category: "session_review", assigned_group: "서비스 운영자", event_count: 3, source_path: "/review/123", duplicate_count: 2, duplicate_parent_candidate_id: "ticket-parent-0", child_ticket_count: 1, }), ], }); await page.goto("/admin/tickets"); const card = page .locator(".vgops-ticket") .filter({ hasText: "세션 리뷰가 저장되지 않음" }); await expect(card).toBeVisible(); await expect(card).toContainText("리뷰 화면에서 저장을 눌러도 결과가 사라집니다."); const meta = card.locator(".vgops-ticket__meta"); await expect(meta).toContainText("세션/리뷰"); await expect(meta).toContainText("learner1@hs.ac.kr"); await expect(meta).toContainText("담당 서비스 운영자"); await expect(meta).toContainText("처리 이력 3건"); await expect(card).toContainText("중복 후보 2건"); await expect(card).toContainText("하위 티켓 1건"); await expect(card.locator("small")).toContainText("/review/123"); await expect(card).toContainText("높음"); await expect(card).toContainText("처리 중"); // 이미 처리 중인 티켓은 "처리 중" 전환 버튼이 비활성으로 잠긴다. await expect(card.getByRole("button", { name: "처리 중" })).toBeDisabled(); }); // usecase: 관리자가 티켓을 해결 처리해 최근 해결 이력으로 내린다 test("티켓 해결 버튼으로 해결 처리하면 최근 해결 이력으로 이동한다", async ({ page }) => { const { patchedTicketBodies } = await installAdminMock(page, { tickets: [ makeTicket({ ticket_id: "ticket-resolve-1", subject: "음성 입력이 브라우저에서 멈춤", category: "voice_browser", status: "open", }), ], }); await page.goto("/admin/tickets"); const card = page .locator(".vgops-ticket") .filter({ hasText: "음성 입력이 브라우저에서 멈춤" }); await expect(card).toBeVisible(); const patchPromise = page.waitForRequest( (request) => request.method() === "PATCH" && request.url().includes("/admin/tickets/ticket-resolve-1"), ); await card.getByRole("button", { name: "해결" }).click(); const patch = await patchPromise; expect(patch.postDataJSON()).toMatchObject({ status: "resolved" }); expect(patchedTicketBodies).toHaveLength(1); // 재조회 후 미해결 큐는 비고 해결 이력 섹션에 같은 제목이 남는다. await expect(page.getByText("미해결 운영 티켓이 없습니다")).toBeVisible(); await expect(page.getByRole("heading", { name: "최근 해결 이력" })).toBeVisible(); const history = page .locator(".vgops-ticket--history") .filter({ hasText: "음성 입력이 브라우저에서 멈춤" }); await expect(history).toBeVisible(); await expect(history).toContainText("해결"); }); // usecase: 관리자가 해결 처리하면서 학습자에게 보일 해결 노트를 작성하려 한다 test("티켓 해결 시 해결 노트를 작성할 입력 수단이 제공된다", async ({ page }) => { // 근거: API 계약(AdminSupportTicketResponse.resolution_note, PATCH의 // resolution_note?: string|null)과 학습자 Settings.tsx가 resolution_note를 // 표시한다. 그러나 관리자 큐(Admin.tsx TicketActions)에는 노트 입력이 없다 — // 실패하면 제품 결함 의심으로 남긴다. await installAdminMock(page, { tickets: [ makeTicket({ ticket_id: "ticket-note-1", subject: "계정 권한이 갑자기 사라짐", category: "account_access", status: "in_progress", }), ], }); await page.goto("/admin/tickets"); const card = page .locator(".vgops-ticket") .filter({ hasText: "계정 권한이 갑자기 사라짐" }); await expect(card).toBeVisible(); // 해결 전 노트를 남길 수 있는 입력(텍스트박스)이 카드 안에 있어야 한다. await expect(card.getByRole("textbox", { name: /해결 노트|resolution/i })).toBeVisible(); }); // usecase: 관리자가 AI 엔진 설정에서 게이트웨이 검증 실패를 fail-closed로 확인한다 test("게이트웨이 모델 검증이 실패하면 저장이 잠기고 경고가 표시된다", async ({ page }) => { await installAdminMock(page, { capabilitiesAvailable: false, capabilitiesDetail: "게이트웨이에 연결할 수 없어 모델 검증에 실패했습니다.", }); await page.goto("/admin/ai"); await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible(); // fail-closed: 검증 불가 상태가 role=alert로 노출된다. const capabilityBox = page.locator(".aic-capability"); await expect(capabilityBox).toHaveAttribute("role", "alert"); await expect(capabilityBox).toContainText("모델 목록을 확인할 수 없음"); await expect(capabilityBox).toContainText( "게이트웨이에 연결할 수 없어 모델 검증에 실패했습니다.", ); // 모델 선택은 잠기고 저장된 모델은 "확인되지 않음"으로 명시된다. await expect(page.getByLabel("AI 기본 모델")).toBeDisabled(); await expect(page.getByLabel("AI 기본 모델")).toContainText( "gateway-default · 현재 목록에서 확인되지 않음", ); // 저장 버튼도 닫힌다(검증 없는 설정 저장 불가). await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeDisabled(); }); // usecase: 관리자가 연결 주소를 바꾸면 저장 전에 재검증 확인 단계를 거친다 test("연결 주소 변경 후 목록 재확인을 거쳐야 저장이 다시 열린다", async ({ page }) => { await installAdminMock(page); await page.goto("/admin/ai"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default"); await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeEnabled(); // 주소를 바꾸는 즉시 기존 검증이 무효화되고 재확인을 요구한다. await page.getByLabel("AI 연결 주소").fill("http://127.0.0.1:9199"); const capabilityBox = page.locator(".aic-capability"); await expect(capabilityBox).toContainText( "연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.", ); await expect(capabilityBox).toHaveAttribute("role", "alert"); await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeDisabled(); // 새 주소로 목록을 재조회해야 저장이 다시 열린다. const catalogRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return ( url.pathname.endsWith("/admin/engine-capabilities") && url.searchParams.get("engine_url") === "http://127.0.0.1:9199" ); }); await page.getByRole("button", { name: "목록 새로고침" }).click(); await catalogRequest; await expect(capabilityBox).toContainText("1개 모델 확인됨"); await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeEnabled(); }); // usecase: 관리자가 운영 홈에서 서비스 헬스 이벤트와 지표를 읽는다 test("운영 홈에서 제한 운영 서비스와 가용성 지표를 확인한다", async ({ page }) => { await installAdminMock(page, { healthStatus: "degraded", engineMode: "claude_cli", services: [ { key: "engine", name: "AI 엔진", status: "ok", detail: "게이트웨이 응답 정상", metric: "240ms", load: 0.24, }, { key: "db", name: "데이터베이스", status: "ok", detail: "커넥션 풀 정상", metric: "풀 3/10", load: 0.3, }, { key: "evaluation", name: "평가 파이프라인", status: "degraded", detail: "평가 대기열이 지연되고 있습니다", metric: "대기 12건", load: 0.82, }, ], uptime: { sample_count: 288, ok_ratio: 0.996, last_down_at: NOW - 200_000 }, }); await page.goto("/admin"); // 상태 배너: degraded 문구와 환경·엔진 모드 라벨. const status = page.locator(".vgops-status"); await expect(status).toContainText("일부 서비스가 제한된 상태입니다."); await expect(status).toContainText("개발 · Claude CLI 게이트웨이"); // KPI: 정상 2/3과 점검 1건. const healthKpi = page.locator(".vgops-kpi").filter({ hasText: "서비스 헬스" }); await expect(healthKpi).toContainText("2/3"); await expect(healthKpi).toContainText("1개 점검"); // 서비스 카드: 각 서비스의 상태 배지·상세·지표. const cards = page.locator(".vgops-service:not(.vgops-service--skeleton)"); await expect(cards).toHaveCount(3); const degradedCard = cards.filter({ hasText: "평가 파이프라인" }); await expect(degradedCard).toContainText("제한 운영"); await expect(degradedCard).toContainText("평가 대기열이 지연되고 있습니다"); await expect(degradedCard).toContainText("대기 12건"); await expect(cards.filter({ hasText: "AI 엔진" })).toContainText("240ms"); // 가용성 패널: 업타임 표본 정상률과 최근 중단 기록. const availability = page.locator(".vgops-panel").filter({ hasText: "가용성" }); await expect(availability).toContainText("샘플 정상률"); await expect(availability).toContainText("99.6%"); await expect(availability).toContainText("제한 운영"); }); // usecase: 관리자가 G8 게이트 카드에서 승인이 막힌 이유를 상시 확인한다 test("G8 게이트의 승인 차단 사유가 조작 없이도 상시 노출된다", async ({ page }) => { await installCiMock(page); await page.goto("/admin/continuous-improvement"); await expect( page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" }), ).toBeVisible(); // 증거 2종뿐인 모델 게이트: 이유가 문장으로 노출되고 버튼 라벨도 잠금 상태를 말한다. const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`); await expect( modelGate.getByText("필수 증거 4종이 모두 있어야 기록 가능"), ).toBeVisible(); await expect(modelGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled(); // 증거 4종 완비 릴리스 게이트: 사유 미입력이 차단 이유로 노출된다. const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`); await expect( releaseGate.getByText("승인 사유를 먼저 입력해야 함"), ).toBeVisible(); const submitButton = releaseGate.getByRole("button", { name: "사람 승인 기록" }); await expect(submitButton).toBeDisabled(); // 차단 사유는 입력·버튼과 aria-describedby로 연결되어 보조기술에도 상시 전달된다. const requirementId = `approval-requirement-${RELEASE_GATE_ID}`; await expect(releaseGate.getByLabel("사람 승인 사유")).toHaveAttribute( "aria-describedby", requirementId, ); await expect(submitButton).toHaveAttribute("aria-describedby", requirementId); // 사유를 입력하면 준비 문구로, 지우면 다시 차단 사유로 살아있게 갱신된다. const reason = releaseGate.getByLabel("사람 승인 사유"); await reason.fill("릴리스 증거 4종을 독립 검토함"); await expect(releaseGate.getByText("증거 4종과 승인 사유가 준비됨")).toBeVisible(); await expect(submitButton).toBeEnabled(); await reason.fill(""); await expect(releaseGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible(); await expect(submitButton).toBeDisabled(); }); // usecase: 관리자가 마우스 없이 사유 입력 후 Enter로 릴리스 승격을 원장에 기록한다 test("릴리스 게이트 승인 사유를 키보드 Enter로 제출해 원장에 기록한다", async ({ page, }) => { const mock = await installCiMock(page); await page.goto("/admin/continuous-improvement"); const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`); await expect(releaseGate).toContainText("릴리스 uc-g8-release-2026-08-17"); await expect(releaseGate).toContainText("승격 후보"); const reason = releaseGate.getByLabel("사람 승인 사유"); await reason.fill("기준선·임계값·출처·롤백 증거를 모두 확인함"); await reason.press("Enter"); await expect(releaseGate.getByText("append-only 승인 원장 기록됨")).toBeVisible(); expect(mock.approvalBodies).toHaveLength(1); expect(mock.approvalBodies[0]).toMatchObject({ target_kind: "release_gate", target_id: RELEASE_GATE_ID, decision: "approve_promotion", reason_code: "기준선·임계값·출처·롤백 증거를 모두 확인함", }); expect(mock.approvalBodies[0].evidence_refs).toHaveLength(4); // 기록 후 입력 폼은 닫히고 다시 제출할 수 없다(append-only). await expect(releaseGate.getByLabel("사람 승인 사유")).toHaveCount(0); }); // usecase: 관리자가 슈퍼 관리자 키워드로 특정 권한 보유자를 찾아낸다 test("사용자 목록에서 슈퍼 키워드 검색으로 슈퍼 관리자만 남긴다", async ({ page }) => { await installAdminMock(page, { users: [ makeUser({ user_id: "user-learner", email: "lee@hs.ac.kr", display_name: "이서연", affiliation: "간호학과", cohort_ids: ["cohort-2026a"], }), makeUser({ user_id: "user-teacher", email: "park@hs.ac.kr", display_name: "박교수", role: "teacher", affiliation: "상담학과", }), makeUser({ user_id: "user-super", email: "kim-ops@twentyoz.kr", display_name: "김운영", role: "admin", admin_access: true, super_admin: true, affiliation: "운영팀", }), ], }); await page.goto("/admin/users"); await page.getByRole("tab", { name: "사용자 목록" }).click(); const rows = page.locator(".vgops-user-table tbody tr"); await expect(rows).toHaveCount(3); await expect(page.locator(".vgops-users-toolbar")).toContainText("3 / 3명"); // super_admin 플래그는 "슈퍼 관리자" 파생 키워드로 검색된다. await page.getByLabel("사용자 검색").fill("슈퍼"); await expect(rows).toHaveCount(1); await expect(rows.first()).toContainText("kim-ops@twentyoz.kr"); await expect(rows.first()).toContainText("슈퍼"); await expect(page.locator(".vgops-users-toolbar")).toContainText("1 / 3명"); }); // usecase: 관리자가 소속(학과)으로 사용자를 좁혀 확인한다 test("사용자 목록에서 소속 학과명으로 검색해 해당 학과만 남긴다", async ({ page }) => { await installAdminMock(page, { users: [ makeUser({ user_id: "user-nursing", email: "lee@hs.ac.kr", display_name: "이서연", affiliation: "간호학과", }), makeUser({ user_id: "user-counsel-1", email: "park@hs.ac.kr", display_name: "박교수", role: "teacher", affiliation: "상담학과", }), makeUser({ user_id: "user-counsel-2", email: "choi@hs.ac.kr", display_name: "최학생", affiliation: "상담학과", }), ], }); await page.goto("/admin/users"); await page.getByRole("tab", { name: "사용자 목록" }).click(); const rows = page.locator(".vgops-user-table tbody tr"); await expect(rows).toHaveCount(3); await page.getByLabel("사용자 검색").fill("상담학과"); await expect(rows).toHaveCount(2); await expect(rows.filter({ hasText: "park@hs.ac.kr" })).toHaveCount(1); await expect(rows.filter({ hasText: "choi@hs.ac.kr" })).toHaveCount(1); await expect(page.locator(".vgops-users-toolbar")).toContainText("2 / 3명"); // 검색을 지우면 전체가 복원된다. await page.getByLabel("사용자 검색").fill(""); await expect(rows).toHaveCount(3); }); // usecase: 관리자가 외부 연구참여자를 exact-email로 사전등록하고 별도로 승인한다 test("외부 연구참여자 사전등록은 pending으로 생성되고 승인 큐를 거친다", async ({ page, }) => { const { createdUserBodies, patchedUserBodies } = await installAdminMock(page, { users: [], }); await page.goto("/admin/users"); await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click(); await expect( page.getByRole("heading", { name: "외부 연구참여자 사전등록" }), ).toBeVisible(); await expect(page.getByText("정확한 이메일 주소로만 Google·개발 로그인을 통과")) .toBeVisible(); const fixedApprovalStatus = page.getByLabel("새 사용자 승인 상태"); await expect(fixedApprovalStatus).toHaveAttribute("data-value", "pending"); await expect(fixedApprovalStatus).toHaveText("승인 대기"); await expect( page.getByRole("combobox", { name: "새 사용자 승인 상태" }), ).toHaveCount(0); const exactEmail = "participant.exact@gmail.com"; await page.getByLabel("새 사용자 이메일").fill(exactEmail); await page.getByLabel("새 사용자 표시 이름").fill("외부 연구참여자"); await page.getByLabel("새 사용자 코호트").fill("external-study-2026"); const createRequest = page.waitForRequest( (request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/admin/users"), ); await page.getByRole("button", { name: "연구참여자 사전등록" }).click(); const request = await createRequest; expect(request.postDataJSON()).toMatchObject({ email: exactEmail, display_name: "외부 연구참여자", role: "learner", account_status: "pending", cohort_ids: ["external-study-2026"], }); expect(createdUserBodies).toHaveLength(1); await expect(page.getByRole("tab", { name: "사용자 목록" })).toHaveAttribute( "aria-selected", "true", ); await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click(); await expect(page.getByLabel("새 사용자 승인 상태")).toHaveAttribute( "data-value", "pending", ); const secondEmail = "participant.second@outlook.com"; await page.getByLabel("새 사용자 이메일").fill(secondEmail); await page.getByLabel("새 사용자 표시 이름").fill("두 번째 연구참여자"); const secondCreateRequest = page.waitForRequest( (nextRequest) => nextRequest.method() === "POST" && new URL(nextRequest.url()).pathname.endsWith("/admin/users"), ); await page.getByRole("button", { name: "연구참여자 사전등록" }).click(); const secondRequest = await secondCreateRequest; expect(secondRequest.postDataJSON()).toMatchObject({ email: secondEmail, display_name: "두 번째 연구참여자", role: "learner", account_status: "pending", }); expect(createdUserBodies).toHaveLength(2); expect(createdUserBodies.every((body) => body.account_status === "pending")).toBe(true); await page.getByRole("tab", { name: "가입 승인 2" }).click(); const card = page.locator(".vgops-approval").filter({ hasText: exactEmail }); await expect(card).toContainText("승인 대기"); await card.getByRole("button", { name: "승인" }).click(); const secondCard = page.locator(".vgops-approval").filter({ hasText: secondEmail }); await expect(secondCard).toContainText("승인 대기"); await secondCard.getByRole("button", { name: "승인" }).click(); expect(patchedUserBodies).toContainEqual({ user_id: "preregistered-1", account_status: "approved", }); expect(patchedUserBodies).toContainEqual({ user_id: "preregistered-2", account_status: "approved", }); await expect(page.getByText("처리할 가입 요청이 없습니다")).toBeVisible(); }); test("사용자별 학습자 AI 피드백을 끄고 관리자 API에 저장한다", async ({ page }) => { const user = makeUser({ user_id: "feedback-policy-user", email: "feedback-policy@hs.ac.kr", display_name: "피드백 정책 대상", }); const { patchedUserBodies } = await installAdminMock(page, { users: [user] }); await page.goto("/admin/users"); await page.getByRole("tab", { name: "사용자 목록" }).click(); const row = page .locator(".vgops-user-table tbody tr") .filter({ hasText: user.email }); const tableScroll = page.locator(".vgops-user-table-scroll"); await expect .poll(() => tableScroll.evaluate( (element) => element.scrollWidth > element.clientWidth, ), ) .toBe(true); const toggle = row.getByLabel(`${user.email} 학습자 AI 피드백`); await expect(toggle).toBeChecked(); await toggle.focus(); await toggle.press("Space"); await expect(toggle).not.toBeChecked(); const patchRequest = page.waitForRequest( (request) => request.method() === "PATCH" && request.url().includes(`/admin/users/${user.user_id}`), ); await row.getByRole("button", { name: "저장" }).click(); const request = await patchRequest; expect(request.postDataJSON()).toMatchObject({ learner_feedback_enabled: false }); expect(patchedUserBodies.at(-1)).toMatchObject({ user_id: user.user_id, learner_feedback_enabled: false, }); await expect(toggle).not.toBeChecked(); }); test("상담 프로토콜을 초안 등록하고 활성화한 뒤 퇴역한다", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); const { createdProtocolBodies } = await installAdminMock(page, { protocols: [] }); await page.goto("/admin/access"); const protocolTab = page.getByRole("tab", { name: "상담 프로토콜" }); await protocolTab.focus(); await protocolTab.press("Enter"); await expect(page.getByRole("heading", { name: "상담 프로토콜 초안 등록" })) .toBeVisible(); const title = "초기면담 위기 확인 프로토콜"; await page.getByLabel("프로토콜 제목").fill(title); await page .getByLabel("프로토콜 출처") .fill("https://example.edu/protocols/intake-v2"); await page.getByLabel("프로토콜 버전").fill("0"); await page.getByLabel("프로토콜 라이선스").selectOption("B"); await page.getByLabel("외부 LLM 사용 허용").check(); await page .getByLabel("프로토콜 원문") .fill("자해 위험과 즉시 안전 여부를 먼저 확인한다."); await page.getByRole("button", { name: "초안 등록", exact: true }).click(); await expect(page.getByRole("alert")).toContainText( "버전은 1부터 1,000,000 사이의 정수여야 합니다.", ); expect(createdProtocolBodies).toHaveLength(0); await page.getByLabel("프로토콜 버전").fill("2"); const createRequest = page.waitForRequest( (request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/admin/protocols"), ); await page.getByRole("button", { name: "초안 등록", exact: true }).click(); const request = await createRequest; expect(request.postDataJSON()).toMatchObject({ title, version: 2, license: "B", external_llm_ok: true, }); expect(createdProtocolBodies).toHaveLength(1); const card = page.locator(".vgops-protocol-card").filter({ hasText: title }); await expect(card).toContainText("초안"); const activate = card.getByRole("button", { name: `${title} 활성화` }); await activate.focus(); await activate.press("Enter"); await expect(card).toContainText("활성"); const retire = card.getByRole("button", { name: `${title} 퇴역` }); await retire.focus(); await retire.press("Enter"); await expect(card).toContainText("퇴역"); await expect(card.getByText("변경 불가")).toBeVisible(); }); test("모바일에서 C/D 외부 사용을 차단하고 활성화 오류를 복구한다", async ({ page }) => { await page.setViewportSize({ width: 320, height: 568 }); const protocol = makeProtocol({ protocol_id: "71000000-0000-4000-8000-000000000099", title: "모바일 복구 프로토콜", license: "A", external_llm_ok: true, }); await installAdminMock(page, { protocols: [protocol], protocolActivationFailures: 1, }); await page.goto("/admin/access"); const protocolTab = page.getByRole("tab", { name: "상담 프로토콜" }); await protocolTab.click(); await expect(protocolTab).toHaveAttribute("aria-selected", "true"); await page.getByLabel("프로토콜 라이선스").selectOption("C"); const externalToggle = page.getByLabel("외부 LLM 사용 허용"); await expect(externalToggle).toBeDisabled(); await expect(externalToggle).not.toBeChecked(); await expect(page.getByText("라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다.")) .toBeVisible(); const card = page.locator(".vgops-protocol-card").filter({ hasText: protocol.title, }); const activate = card.getByRole("button", { name: `${protocol.title} 활성화` }); await activate.click(); await expect(page.getByRole("alert")).toContainText("vector unavailable"); await activate.click(); await expect(card).toContainText("활성"); await expect(page.getByRole("alert")).toHaveCount(0); const protocolRegion = page.locator(".vgops-protocols"); await expect .poll(() => protocolRegion.evaluate( (element) => element.scrollWidth <= element.clientWidth, ), ) .toBe(true); await expect .poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ) .toBe(true); const touchTargets = page.locator( ".vgops-protocols button, .vgops-protocols input:not([type=checkbox]), " + ".vgops-protocols select, .vgops-protocols textarea, .vgops-protocol-check", ); const targetCount = await touchTargets.count(); expect(targetCount).toBeGreaterThan(0); for (let index = 0; index < targetCount; index += 1) { const box = await touchTargets.nth(index).boundingBox(); expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); } expect((await protocolTab.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); }); test("모바일 티켓 새로고침과 해결 처리가 동작한다 @single-run", async ({ page, }) => { const outputDir = path.resolve( process.cwd(), "../../outputs/ux-audit-2026-09-12/admin-ticket-targets", ); await mkdir(outputDir, { recursive: true }); await page.setViewportSize({ width: 320, height: 844 }); await installAdminMock(page, { tickets: [ makeTicket({ ticket_id: "ticket-mobile-active", subject: "모바일 티켓 처리", status: "open", }), makeTicket({ ticket_id: "ticket-mobile-history", subject: "해결된 모바일 티켓", status: "resolved", resolved_at: NOW - 600, }), ], }); await page.goto("/admin/tickets"); const refresh = page .locator('[data-admin-section="tickets"] .vgops-head') .getByRole("button", { name: "새로고침" }); const activeCard = page.locator(".vgops-ticket").filter({ hasText: "모바일 티켓 처리", }); await expect(refresh).toBeVisible(); await expect(activeCard).toBeVisible(); await page.screenshot({ path: path.join(outputDir, "tickets-top-320.png") }); const refreshBox = await refresh.boundingBox(); expect(refreshBox?.width ?? 0).toBeGreaterThanOrEqual(44); expect(refreshBox?.height ?? 0).toBeGreaterThanOrEqual(44); await page.setViewportSize({ width: 710, height: 844 }); const refreshAt720 = await refresh.boundingBox(); expect(refreshAt720?.width ?? 0).toBeGreaterThanOrEqual(44); expect(refreshAt720?.height ?? 0).toBeGreaterThanOrEqual(44); await page.setViewportSize({ width: 320, height: 844 }); const reload = page.waitForResponse( (response) => response.request().method() === "GET" && new URL(response.url()).pathname.endsWith("/admin/tickets"), ); await refresh.click(); await reload; await expect(activeCard).toBeVisible(); const patch = page.waitForRequest( (request) => request.method() === "PATCH" && request.url().includes("/admin/tickets/ticket-mobile-active"), ); await activeCard.getByRole("button", { name: "해결" }).click(); await patch; const history = page.locator(".vgops-ticket--history").filter({ hasText: "모바일 티켓 처리", }); await expect(history).toBeVisible(); await history.scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(outputDir, "tickets-history-320.png") }); await expect .poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ) .toBe(true); }); });