import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test"; import { completeOnboarding, expectNoHorizontalOverflow, useRealApi, withGlobalEngineConfigLock, } from "./support"; type Role = "learner" | "admin"; interface UserProfileResponse { user_id: string; email: string; display_name: string; role: string; cohort_ids: string[]; affiliation: string; } interface UserPreferencesResponse { theme: string; voice_preset_id: string; voice_rate: number; notifications: { session_done: boolean; safety_signal: boolean; learner_progress: boolean; product_news: boolean; }; } interface UserSupportTicketsResponse { source: "database"; durable: boolean; tickets: Array<{ ticket_id: string; status: string; subject: string; resolution_note: string; }>; } interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; reasoning_effort: string | null; updated_by: string | null; updated_at: number | null; } async function expectResponseOk(response: APIResponse | Response) { if (!response.ok()) { const body = await response.text(); const method = typeof (response as Response).request === "function" ? (response as Response).request().method() : "API"; expect( response.ok(), `Expected ${method} ${response.url()} to be OK, got ${response.status()}: ${body}`, ).toBeTruthy(); } } function isApiResponse(method: string, pathnameSuffix: string) { return (response: Response) => { const url = new URL(response.url()); return response.request().method() === method && url.pathname.endsWith(pathnameSuffix); }; } function isApiRequest(method: string, pathnameSuffix: string) { return (request: { method: () => string; url: () => string }) => { const url = new URL(request.url()); return request.method() === method && url.pathname.endsWith(pathnameSuffix); }; } async function runAndWaitForApiResponse( page: Page, method: string, pathnameSuffix: string, action: () => Promise, responsePredicate: (response: Response) => boolean = () => true, ) { const responsePromise = page.waitForResponse( (response) => isApiResponse(method, pathnameSuffix)(response) && responsePredicate(response), { timeout: 10_000, }, ); const failedRequestPromise = page .waitForEvent("requestfailed", { predicate: isApiRequest(method, pathnameSuffix), timeout: 10_000, }) .then((request) => { throw new Error( `${method} ${pathnameSuffix} failed before a response: ${ request.failure()?.errorText ?? "unknown network error" }`, ); }); await action(); return Promise.race([responsePromise, failedRequestPromise]); } async function waitForReactInputCommit(page: Page) { await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => resolve()); }); }), ); } interface EngineCapabilitiesResponse { available: boolean; models: Array<{ id: string; reasoning_efforts?: string[]; default_reasoning_effort?: string | null; }>; } function hasEngineConfigRequestBody(engineMode: string, model: string, reasoningEffort: string | null) { return (response: Response) => { try { const body = response.request().postDataJSON() as { engine_mode?: string; model?: string; reasoning_effort?: string | null; }; return ( body.engine_mode === engineMode && body.model === model && body.reasoning_effort === reasoningEffort ); } catch { return false; } }; } async function expectNoEngineSegmentClipping(page: Page) { await expect .poll(async () => page.evaluate(() => { const issues: Array<{ target: string; text: string; inlineOverflow: number; blockOverflow: number; leftOverflow: number; rightOverflow: number; width: number; height: number; }> = []; const segment = document.querySelector( "#set-engine [aria-label='AI 엔진 공급자']", ); if (!segment) { return [ { target: "segment", text: "missing", inlineOverflow: 0, blockOverflow: 0, leftOverflow: 0, rightOverflow: 0, width: 0, height: 0, }, ]; } const segmentRect = segment.getBoundingClientRect(); const segmentInlineOverflow = Math.ceil(segment.scrollWidth - segment.clientWidth); const segmentBlockOverflow = Math.ceil(segment.scrollHeight - segment.clientHeight); if (segmentInlineOverflow > 1 || segmentBlockOverflow > 1) { issues.push({ target: "segment", text: "", inlineOverflow: segmentInlineOverflow, blockOverflow: segmentBlockOverflow, leftOverflow: 0, rightOverflow: 0, width: Math.ceil(segmentRect.width), height: Math.ceil(segmentRect.height), }); } for (const button of Array.from( segment.querySelectorAll(".vg-set__seg-btn"), )) { const rect = button.getBoundingClientRect(); const inlineOverflow = Math.ceil(button.scrollWidth - button.clientWidth); const blockOverflow = Math.ceil(button.scrollHeight - button.clientHeight); const leftOverflow = Math.ceil(segmentRect.left - rect.left); const rightOverflow = Math.ceil(rect.right - segmentRect.right); if ( inlineOverflow > 1 || blockOverflow > 1 || leftOverflow > 1 || rightOverflow > 1 ) { issues.push({ target: "button", text: (button.textContent ?? "").replace(/\s+/g, " ").trim(), inlineOverflow, blockOverflow, leftOverflow, rightOverflow, width: Math.ceil(rect.width), height: Math.ceil(rect.height), }); } } return issues; }), ) .toEqual([]); } async function expectNoSettingsControlClipping(page: Page) { await expect .poll(async () => page.evaluate(() => { const selector = [ ".vg-set", ".vg-set__rail", ".vg-set__rail-card", ".vg-set__nav", ".vg-set__nav-item", ".vg-set__forms", ".vg-set__group", ".vg-set__profile", ".vg-set__field", ".vg-set__control-block", ".vg-set__opt", ".vg-set__tickets", ".vg-set__ticket", ".vg-set__ticket-note", ".vg-set__voice", ".vg-set__range-row", ".vg-set__seg", ".vg-set__seg-btn", ".vg-btn", ".vg-toggle", ].join(","); const issues: Array<{ target: string; text: string; inlineOverflow: number; blockOverflow: number; width: number; height: number; }> = []; for (const element of Array.from(document.querySelectorAll(selector))) { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); const visible = style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0; if (!visible) continue; const inlineOverflow = Math.ceil(element.scrollWidth - element.clientWidth); const blockOverflow = Math.ceil(element.scrollHeight - element.clientHeight); const allowsInlineScroll = element.classList.contains("vg-set__nav"); if ((!allowsInlineScroll && inlineOverflow > 1) || blockOverflow > 1) { issues.push({ target: `.${Array.from(element.classList).join(".") || element.tagName.toLowerCase()}`, text: (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80), inlineOverflow, blockOverflow, width: Math.ceil(rect.width), height: Math.ceil(rect.height), }); } } return issues.slice(0, 8); }), ) .toEqual([]); } async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobile") { await expect .poll(async () => page.evaluate((layoutMode) => { const countColumns = (template: string) => template === "none" ? 0 : template.split(" ").filter(Boolean).length; const root = document.querySelector(".vg-set"); const forms = document.querySelector(".vg-set__forms"); const railTitle = document.querySelector(".vg-set__rail-title"); const railCard = document.querySelector(".vg-set__rail-card"); const nav = document.querySelector(".vg-set__nav"); const account = document.querySelector("#set-account"); const appearance = document.querySelector("#set-appearance"); const notify = document.querySelector("#set-notify"); const voice = document.querySelector("#set-voice"); if ( !root || !forms || !railTitle || !railCard || !nav || !account || !appearance || !notify || !voice ) { return { ready: false }; } const rootStyle = getComputedStyle(root); const rootColumns = countColumns(getComputedStyle(root).gridTemplateColumns); const formColumns = countColumns(getComputedStyle(forms).gridTemplateColumns); const railTitleRect = railTitle.getBoundingClientRect(); const railCardRect = railCard.getBoundingClientRect(); const navRect = nav.getBoundingClientRect(); const accountStyle = getComputedStyle(account); const appearanceRect = appearance.getBoundingClientRect(); const notifyRect = notify.getBoundingClientRect(); const voiceRect = voice.getBoundingClientRect(); if (layoutMode === "desktop") { return { ready: true, rootIsTwoColumnGrid: rootStyle.display === "grid" && rootColumns === 2, formColumns, railTitleVisible: railTitleRect.height > 28, railCardHidden: railCardRect.height === 0, voiceBeforeNotify: voiceRect.top < notifyRect.top, }; } return { ready: true, rootSingleColumn: rootStyle.display !== "grid", formColumns, railCardHidden: railCardRect.height === 0, navSingleLine: navRect.height <= 54, compactPanelPadding: Number.parseFloat(accountStyle.paddingTop) <= 14, }; }, mode), ) .toEqual( mode === "desktop" ? { ready: true, rootIsTwoColumnGrid: true, formColumns: 1, railTitleVisible: true, railCardHidden: true, voiceBeforeNotify: true, } : { ready: true, rootSingleColumn: true, formColumns: 1, railCardHidden: true, navSingleLine: true, compactPanelPadding: true, }, ); } function slugFor(testInfo: TestInfo) { let hash = 0; for (const char of testInfo.title) { hash = (hash * 31 + char.charCodeAt(0)) >>> 0; } const project = testInfo.project.name.includes("mobile") ? "mob" : "desk"; return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${hash.toString(36)}`; } function deferred() { let resolve!: () => void; const promise = new Promise((next) => { resolve = next; }); return { promise, resolve }; } function testEmail(role: Role, testInfo: TestInfo) { const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr"; return `settings.${role}.${slugFor(testInfo)}@${domain}`; } async function signInAs( page: Page, role: Role, testInfo: TestInfo, displayName: string, email = testEmail(role, testInfo), ) { const res = await page.request.post("/api/auth/dev-login", { data: { email, role, display_name: displayName, }, }); await expectResponseOk(res); await completeOnboarding(page, { legal_name: displayName, affiliation: "한신대학교", department: role === "admin" ? "운영팀" : "상담심리학과", grade_level: role === "admin" ? "관리자" : "3학년", phone: role === "admin" ? "010-2222-2222" : "010-3333-3333", contact_address: "경기도 오산시 한신대학교", nickname: displayName, self_introduction: `${displayName} 설정 E2E 사용자입니다.`, }); return email; } async function openSettings(page: Page, options: { admin?: boolean } = {}) { const profilePromise = page.waitForResponse(isApiResponse("GET", "/users/me")); const preferencesPromise = page.waitForResponse( isApiResponse("GET", "/users/me/preferences"), ); const voicePresetsPromise = page.waitForResponse( isApiResponse("GET", "/users/me/voice-presets"), ); const supportTicketsPromise = page.waitForResponse( isApiResponse("GET", "/users/support-tickets"), ); const engineConfigPromise = options.admin ? page.waitForResponse(isApiResponse("GET", "/admin/engine-config")) : null; await page.goto("/settings"); const [ profileResponse, preferencesResponse, voicePresetsResponse, supportTicketsResponse, engineConfigResponse, ] = await Promise.all([ profilePromise, preferencesPromise, voicePresetsPromise, supportTicketsPromise, engineConfigPromise, ]); await expectResponseOk(profileResponse); await expectResponseOk(preferencesResponse); await expectResponseOk(voicePresetsResponse); await expectResponseOk(supportTicketsResponse); if (engineConfigResponse) await expectResponseOk(engineConfigResponse); const profile = (await profileResponse.json()) as UserProfileResponse; const preferences = (await preferencesResponse.json()) as UserPreferencesResponse; const supportTickets = (await supportTicketsResponse.json()) as UserSupportTicketsResponse; const engineConfig = engineConfigResponse ? ((await engineConfigResponse.json()) as AdminEngineConfigResponse) : null; await expect(page).toHaveURL(/\/settings$/); await expect(page.locator(".vg-set")).toBeVisible(); return { profile, preferences, supportTickets, engineConfig }; } test.describe("settings page", () => { test.beforeEach(async ({ page }) => { await useRealApi(page); }); test("learner opens settings with server-backed account details", async ({ page }, testInfo) => { await page.addInitScript(() => { window.localStorage.setItem( "vignette.dev-auth", JSON.stringify({ email: "browser-local@hs.ac.kr", name: "Browser Local Learner", role: "learner", }), ); }); const displayName = `Settings Learner ${testInfo.project.name}`; const email = await signInAs(page, "learner", testInfo, displayName); const { profile } = await openSettings(page); expect(profile).toMatchObject({ email, display_name: displayName, role: "learner", }); const account = page.locator("#set-account"); await expect(account.locator(".vg-set__profile-meta .n")).toHaveText(displayName); await expect(account.locator(".vg-set__profile-meta .e")).toHaveText(email); await expect(account.locator("input").nth(0)).toHaveValue(displayName); await expect(account.locator("input").nth(1)).toHaveValue(email); await expect(page.getByText("Browser Local Learner")).toHaveCount(0); await expect(page.getByText("browser-local@hs.ac.kr")).toHaveCount(0); }); test("does not expose fallback settings before server state arrives", async ({ page, }, testInfo) => { const displayName = `Settings Loading Admin ${testInfo.project.name}`; await signInAs(page, "admin", testInfo, displayName); const preferencesGate = deferred(); const preferencesSeen = deferred(); const voicesGate = deferred(); const voicesSeen = deferred(); const engineGate = deferred(); const engineSeen = deferred(); await page.route("**/api/users/me/preferences", async (route) => { preferencesSeen.resolve(); await preferencesGate.promise; await route.continue(); }); await page.route("**/api/users/me/voice-presets", async (route) => { voicesSeen.resolve(); await voicesGate.promise; await route.continue(); }); await page.route("**/api/admin/engine-config", async (route) => { engineSeen.resolve(); await engineGate.promise; await route.continue(); }); await page.goto("/settings"); await Promise.all([preferencesSeen.promise, voicesSeen.promise, engineSeen.promise]); await expect(page.getByTestId("settings-preferences-loading")).toBeVisible(); await expect(page.getByTestId("settings-voice-loading")).toBeVisible(); await expect(page.getByTestId("settings-notify-loading")).toBeVisible(); await expect(page.getByTestId("settings-engine-loading")).toBeVisible(); await expect(page.getByText("soft-young-fem")).toHaveCount(0); await expect(page.getByRole("radio", { name: "Claude CLI 게이트웨이" })).toHaveCount(0); await expect(page.getByLabel("말하기 속도")).toHaveCount(0); await expect(page.getByRole("button", { name: "음성 저장" })).toHaveCount(0); await expect(page.getByRole("button", { name: "알림 저장" })).toHaveCount(0); await expect(page.getByRole("button", { name: "운영 설정 저장" })).toHaveCount(0); preferencesGate.resolve(); voicesGate.resolve(); engineGate.resolve(); await expect(page.getByTestId("settings-preferences-loading")).toHaveCount(0); await expect(page.getByRole("button", { name: "음성 저장" })).toBeVisible(); await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible(); await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeVisible(); }); test("learner saves display name and preferences", async ({ page }, testInfo) => { const displayName = `Settings Save Learner ${testInfo.project.name}`; await signInAs(page, "learner", testInfo, displayName); await openSettings(page); const account = page.locator("#set-account"); const displayNameInput = account.locator("input").nth(0); const nextName = `Updated Learner ${testInfo.project.name}`; await expect(displayNameInput).toHaveValue(displayName); const profilePatchResponse = await runAndWaitForApiResponse( page, "PATCH", "/users/me", async () => { await displayNameInput.fill(nextName); await expect(displayNameInput).toHaveValue(nextName); await waitForReactInputCommit(page); await account.locator(".vg-set__foot .vg-btn").click(); }, ); await expectResponseOk(profilePatchResponse); const profile = (await profilePatchResponse.json()) as UserProfileResponse; expect(profile.display_name).toBe(nextName); const persistedProfileResponse = await page.request.get("/api/users/me"); await expectResponseOk(persistedProfileResponse); const persistedProfile = (await persistedProfileResponse.json()) as UserProfileResponse; expect(persistedProfile.display_name).toBe(nextName); await expect(account.locator(".vg-set__profile-meta .n")).toHaveText(nextName); await expect(displayNameInput).toHaveValue(nextName); const notify = page.locator("#set-notify"); const productNews = notify.getByRole("switch").last(); const currentProductNews = (await productNews.getAttribute("aria-checked")) === "true"; const nextProductNews = !currentProductNews; await productNews.click(); await expect(productNews).toHaveAttribute("aria-checked", String(nextProductNews)); const preferencesPatchResponse = await runAndWaitForApiResponse( page, "PATCH", "/users/me/preferences", async () => { const saveButton = notify.locator(".vg-set__foot .vg-btn"); await saveButton.scrollIntoViewIfNeeded(); await saveButton.focus(); await page.keyboard.press("Enter"); }, ); await expectResponseOk(preferencesPatchResponse); const preferences = (await preferencesPatchResponse.json()) as UserPreferencesResponse; expect(preferences.notifications.product_news).toBe(nextProductNews); }); test("learner sees only own support ticket status and resolution note", async ({ page, }, testInfo) => { const learnerName = `Settings Ticket Learner ${testInfo.project.name}`; const learnerEmail = await signInAs(page, "learner", testInfo, learnerName); const subject = `설정 티켓 상태 ${slugFor(testInfo)}`; const hiddenBody = `설정 화면에는 보이면 안 되는 문의 본문 ${slugFor(testInfo)}`; const create = await page.request.post("/api/users/support-tickets", { data: { category: "session_review", priority: "high", subject, body: hiddenBody, source_path: "/settings", }, }); await expectResponseOk(create); const created = (await create.json()) as { ticket_id: string }; await signInAs( page, "admin", testInfo, `Settings Ticket Admin ${testInfo.project.name}`, `settings.ticket.admin.${slugFor(testInfo)}@twentyoz.kr`, ); const resolutionNote = `재처리 완료 ${slugFor(testInfo)}`; const patch = await page.request.patch(`/api/admin/tickets/${created.ticket_id}`, { data: { status: "resolved", assigned_group: "서비스 운영자", resolution_note: resolutionNote, }, }); await expectResponseOk(patch); await signInAs(page, "learner", testInfo, learnerName, learnerEmail); const { supportTickets } = await openSettings(page); expect(supportTickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBe( true, ); const support = page.locator("#set-support"); await expect(support).toContainText(subject); await expect(support).toContainText("해결"); await expect(support).toContainText(resolutionNote); await expect(support).not.toContainText(hiddenBody); }); test("admin sees and updates the AI engine settings panel @single-run", async ({ page }, testInfo) => { const displayName = `Settings Admin ${testInfo.project.name}`; const email = await signInAs(page, "admin", testInfo, displayName); await withGlobalEngineConfigLock(`settings-${slugFor(testInfo)}`, async () => { const { engineConfig } = await openSettings(page, { admin: true }); expect(engineConfig).not.toBeNull(); const originalEngineConfig = engineConfig!; const engine = page.locator("#set-engine"); await expect(engine).toBeVisible(); await expect(engine.getByLabel("AI 연결 주소")).toHaveValue(originalEngineConfig.engine_url); await expect(engine.getByLabel("AI 엔진 공급자")).toHaveValue( originalEngineConfig.engine_mode, ); await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(originalEngineConfig.model); const capabilityResponse = await page.request.get( `/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(originalEngineConfig.engine_mode)}`, ); await expectResponseOk(capabilityResponse); const capability = (await capabilityResponse.json()) as EngineCapabilitiesResponse; const candidate = capability.models.find((model) => model.id !== originalEngineConfig.model) ?? capability.models[0]; if (!candidate) throw new Error("engine capability returned no selectable model"); const nextMode = originalEngineConfig.engine_mode; const nextModel = candidate.id; const nextEffort = candidate.default_reasoning_effort ?? null; try { await engine.getByLabel("AI 기본 모델").selectOption(nextModel); await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(nextModel); if (nextEffort) { await engine.getByLabel("AI 추론 강도").selectOption(nextEffort); } await waitForReactInputCommit(page); const enginePatchResponse = await runAndWaitForApiResponse( page, "PATCH", "/admin/engine-config", async () => { await engine.locator(".vg-set__foot .vg-btn").click(); }, hasEngineConfigRequestBody(nextMode, nextModel, nextEffort), ); await expectResponseOk(enginePatchResponse); const updatedEngine = (await enginePatchResponse.json()) as AdminEngineConfigResponse; expect(updatedEngine).toMatchObject({ engine_mode: nextMode, model: nextModel, reasoning_effort: nextEffort, updated_by: email, }); await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(nextModel); const healthResponse = await page.request.get("/api/admin/health"); await expectResponseOk(healthResponse); const health = await healthResponse.json(); expect(health.engine_mode).toBe(nextMode); } finally { const restoreResponse = await page.request.patch("/api/admin/engine-config", { data: { engine_mode: originalEngineConfig.engine_mode, engine_url: originalEngineConfig.engine_url, model: originalEngineConfig.model, reasoning_effort: originalEngineConfig.reasoning_effort, }, }); await expectResponseOk(restoreResponse); } }); }); test("admin settings use a compact clipping-safe control layout", async ({ page, }, testInfo) => { const isMobile = testInfo.project.name.includes("mobile"); const displayName = `Settings Layout Admin ${testInfo.project.name}`; await signInAs(page, "admin", testInfo, displayName); await openSettings(page, { admin: true }); await expectPracticalSettingsLayout(page, isMobile ? "mobile" : "desktop"); await expectNoSettingsControlClipping(page); await expectNoHorizontalOverflow(page); }); test("admin engine settings panel stays readable at a mobile viewport", async ({ page, }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); const displayName = `Settings Mobile Admin ${testInfo.project.name}`; await signInAs(page, "admin", testInfo, displayName); const { engineConfig } = await openSettings(page, { admin: true }); expect(engineConfig).not.toBeNull(); const engine = page.locator("#set-engine"); await expect(engine).toBeVisible(); await expect(engine.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6); await expectNoEngineSegmentClipping(page); await expect(engine.getByLabel("AI 연결 주소")).toBeVisible(); await expect(engine.getByLabel("AI 연결 주소")).toHaveValue(engineConfig!.engine_url); await expect(engine.getByLabel("AI 기본 모델")).toBeVisible(); await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(engineConfig!.model); await expect(engine.getByLabel("AI 추론 강도")).toBeVisible(); await expectNoSettingsControlClipping(page); await expectNoHorizontalOverflow(page); }); test("does not horizontally overflow at a mobile viewport", async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await signInAs(page, "admin", testInfo, "E2E Admin", "admin@twentyoz.kr"); await openSettings(page, { admin: true }); await expectNoSettingsControlClipping(page); await expectNoHorizontalOverflow(page); }); });