import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test"; import { 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 AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; 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()); }); }), ); } function hasEngineConfigRequestBody(engineMode: string, model: string) { return (response: Response) => { try { const body = response.request().postDataJSON() as { engine_mode?: string; model?: string }; return body.engine_mode === engineMode && body.model === model; } catch { return false; } }; } 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); 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 engineConfigPromise = options.admin ? page.waitForResponse(isApiResponse("GET", "/admin/engine-config")) : null; await page.goto("/settings"); const [profileResponse, preferencesResponse, voicePresetsResponse, engineConfigResponse] = await Promise.all([ profilePromise, preferencesPromise, voicePresetsPromise, engineConfigPromise, ]); await expectResponseOk(profileResponse); await expectResponseOk(preferencesResponse); await expectResponseOk(voicePresetsResponse); if (engineConfigResponse) await expectResponseOk(engineConfigResponse); const profile = (await profileResponse.json()) as UserProfileResponse; const preferences = (await preferencesResponse.json()) as UserPreferencesResponse; const engineConfig = engineConfigResponse ? ((await engineConfigResponse.json()) as AdminEngineConfigResponse) : null; await expect(page).toHaveURL(/\/settings$/); await expect(page.locator(".vg-set")).toBeVisible(); return { profile, preferences, 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("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.locator("input").nth(0)).toHaveValue(originalEngineConfig.engine_url); await expect(engine.locator("input").nth(1)).toHaveValue(originalEngineConfig.model); const nextMode = originalEngineConfig.engine_mode === "claude_api" ? "claude_cli" : "claude_api"; const nextModel = `e2e-model-${slugFor(testInfo)}`; try { const nextModeButton = engine.locator(`[data-engine-mode="${nextMode}"]`); await nextModeButton.click(); await expect(nextModeButton).toHaveAttribute("aria-checked", "true"); await engine.locator("input").nth(1).fill(nextModel); await expect(engine.locator("input").nth(1)).toHaveValue(nextModel); 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), ); await expectResponseOk(enginePatchResponse); const updatedEngine = (await enginePatchResponse.json()) as AdminEngineConfigResponse; expect(updatedEngine).toMatchObject({ engine_mode: nextMode, model: nextModel, updated_by: email, }); await expect(engine.locator("input").nth(1)).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, }, }); await expectResponseOk(restoreResponse); } }); }); 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.locator("[data-engine-mode]")).toHaveCount(4); await expect(engine.locator("input").nth(0)).toBeVisible(); await expect(engine.locator("input").nth(0)).toHaveValue(engineConfig!.engine_url); await expect(engine.locator("input").nth(1)).toBeVisible(); await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model); 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 expectNoHorizontalOverflow(page); }); });