import { promises as fs } from "node:fs"; import path from "node:path"; import { expect, type Page } from "@playwright/test"; export interface E2EPersona { code: string; display_name: string; source: string; degraded: boolean; } export async function useRealApi(_page: Page) { // No-op marker for tests that should use the configured Vite /api proxy unless // a spec explicitly installs route fixtures for a focused UI or error state. // This helper alone is not proof that every test in the file is fixture-free. } function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } let e2eEmailCounter = 0; function uniqueE2EEmail(prefix: string, domain: string) { e2eEmailCounter += 1; return `${prefix}.${Date.now()}.${e2eEmailCounter}@${domain}`; } export async function withGlobalEngineConfigLock( label: string, action: () => Promise, ): Promise { const dir = path.join(process.cwd(), "node_modules", ".tmp"); const lockPath = path.join(dir, "engine-config.lock"); const startedAt = Date.now(); await fs.mkdir(dir, { recursive: true }); while (true) { try { const handle = await fs.open(lockPath, "wx"); try { await handle.writeFile(`${process.pid} ${label} ${new Date().toISOString()}`); return await action(); } finally { await handle.close().catch(() => undefined); await fs.unlink(lockPath).catch(() => undefined); } } catch (err) { const code = typeof err === "object" && err !== null && "code" in err ? String((err as { code?: unknown }).code) : ""; if (code !== "EEXIST") throw err; const stat = await fs.stat(lockPath).catch(() => null); if (stat && Date.now() - stat.mtimeMs > 60_000) { await fs.unlink(lockPath).catch(() => undefined); continue; } if (Date.now() - startedAt > 30_000) { throw new Error(`Timed out waiting for engine config lock: ${label}`); } await sleep(100); } } } const E2E_COHORT_ID = "e2e-hanshin"; export async function signInAsLearner(page: Page) { const res = await page.request.post("/api/auth/dev-login", { data: { email: uniqueE2EEmail("learner", "hs.ac.kr"), role: "learner", display_name: "E2E Learner", cohort_ids: [E2E_COHORT_ID], }, }); expect(res.ok(), await res.text()).toBeTruthy(); await completeOnboarding(page, { legal_name: "E2E Learner", affiliation: "한신대학교", department: "상담심리학과", grade_level: "3학년", phone: "010-0000-0000", contact_address: "경기도 오산시 한신대학교", }); } export async function signInAsTeacher(page: Page) { const res = await page.request.post("/api/auth/dev-login", { data: { email: uniqueE2EEmail("teacher", "hs.ac.kr"), role: "teacher", display_name: "E2E Teacher", cohort_ids: [E2E_COHORT_ID], }, }); expect(res.ok(), await res.text()).toBeTruthy(); await completeOnboarding(page, { legal_name: "E2E Teacher", affiliation: "한신대학교", department: "상담심리학과", grade_level: "교수", phone: "010-1111-1111", contact_address: "경기도 오산시 한신대학교", }); } export async function signInAsAdmin(page: Page) { const res = await page.request.post("/api/auth/dev-login", { data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin", }, }); expect(res.ok(), await res.text()).toBeTruthy(); await completeOnboarding(page, { legal_name: "E2E Admin", affiliation: "한신대학교", department: "운영", grade_level: "관리자", phone: "010-2222-2222", contact_address: "경기도 오산시 한신대학교", }); } export async function completeOnboarding( page: Page, profile: { legal_name: string; affiliation: string; department: string; grade_level: string; phone: string; contact_address: string; nickname?: string; self_introduction?: string; avatar_url?: string; }, ) { const onboarding = await page.request.post("/api/users/me/onboarding", { data: { ...profile, nickname: profile.nickname ?? profile.legal_name, self_introduction: profile.self_introduction ?? "상담 시뮬레이션 훈련을 위한 테스트 사용자입니다.", avatar_url: profile.avatar_url ?? "", terms_accepted: true, privacy_accepted: true, }, }); expect(onboarding.ok(), await onboarding.text()).toBeTruthy(); } export async function fetchAvailablePersonas(page: Page): Promise { const res = await page.request.get("/api/personas"); expect(res.ok(), await res.text()).toBeTruthy(); const personas = (await res.json()) as E2EPersona[]; const usable = personas.filter((persona) => persona.source === "database" && !persona.degraded); expect(usable.length, `Expected at least one database persona: ${JSON.stringify(personas)}`).toBeGreaterThan(0); return usable; } export async function fetchAvailablePersona(page: Page, index = 0): Promise { const personas = await fetchAvailablePersonas(page); return personas[index] ?? personas[0]; } /** * 새 회기의 첫 발화 전에 append-only Alliance pre 기준을 잠근다. * 실제 API를 사용하는 세션 E2E가 제품의 fail-closed 진입 계약을 우회하지 않게 한다. */ export async function completeAlliancePreCheckpoint(page: Page) { const heading = page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다", }); await expect(heading).toBeVisible({ timeout: 15_000 }); for (const axis of ["목표", "과업", "유대"] as const) { /* elapsed/HMR 갱신으로 checkpoint subtree가 교체될 수 있다. 매 poll마다 현재 label 좌표를 다시 얻어 실제 포인터로 누르고, 새 DOM의 checked 상태를 읽는다. input property를 직접 쓰거나 제품 gate를 우회하지 않는다. */ await expect .poll( async () => { const group = page.getByRole("group", { name: new RegExp(`^${axis}`) }); const selected = group.getByRole("radio", { name: "3 보통이다" }); if (await selected.isChecked().catch(() => false)) return true; const label = selected.locator("xpath=ancestor::label[1]"); const box = await label.boundingBox().catch(() => null); if (!box) return false; await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); return page .getByRole("group", { name: new RegExp(`^${axis}`) }) .getByRole("radio", { name: "3 보통이다" }) .isChecked() .catch(() => false); }, { message: `${axis} 동맹 기준이 실제 포인터 입력으로 선택되어야 한다`, timeout: 15_000, intervals: [50, 100, 200, 400], }, ) .toBe(true); } const saved = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname.includes("/sessions/") && url.pathname.includes("/alliance-pulses") ); }, { timeout: 15_000 }); const submit = page.getByRole("button", { name: "기준 잠그고 첫 발화 준비", }); await submit.scrollIntoViewIfNeeded(); const currentSubmit = page.getByRole("button", { name: "기준 잠그고 첫 발화 준비", }); await expect(currentSubmit).toBeEnabled(); const submitBox = await currentSubmit.boundingBox(); expect(submitBox, "Alliance pre 저장 버튼 좌표").not.toBeNull(); await page.mouse.click( (submitBox?.x ?? 0) + (submitBox?.width ?? 0) / 2, (submitBox?.y ?? 0) + (submitBox?.height ?? 0) / 2, ); const response = await saved; expect(response.ok(), await response.text()).toBeTruthy(); await expect( page.locator('.sx-page--active textarea[aria-label="학습자 발화 입력"]'), ).toBeEnabled({ timeout: 15_000 }); } export async function expectNoHorizontalOverflow(page: Page) { await expect .poll(async () => { try { return await page.evaluate(() => { const doc = document.documentElement; return Math.ceil(doc.scrollWidth - doc.clientWidth); }); } catch (err) { if (isNavigationRace(err)) return Number.MAX_SAFE_INTEGER; throw err; } }) .toBeLessThanOrEqual(1); const overflow = await page.evaluate(() => { const doc = document.documentElement; const viewportWidth = doc.clientWidth; const delta = Math.ceil(doc.scrollWidth - viewportWidth); const offenders = Array.from(document.querySelectorAll("body *")) .map((el) => { const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); return { tag: el.tagName.toLowerCase(), className: String(el.className || ""), text: (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80), left: Math.floor(rect.left), right: Math.ceil(rect.right), width: Math.ceil(rect.width), visible: style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0, }; }) .filter((item) => item.visible && (item.left < -1 || item.right > viewportWidth + 1)) .slice(0, 8); return { delta, viewportWidth, offenders }; }); expect( overflow.delta, `Horizontal overflow ${overflow.delta}px at ${overflow.viewportWidth}px viewport. Offenders: ${JSON.stringify( overflow.offenders, )}`, ).toBeLessThanOrEqual(1); } export async function expectNoDocumentOverflow(page: Page) { await expect .poll(async () => { try { return await page.evaluate(() => { const doc = document.documentElement; return { x: Math.ceil(doc.scrollWidth - doc.clientWidth), y: Math.ceil(doc.scrollHeight - doc.clientHeight), }; }); } catch (err) { if (isNavigationRace(err)) return { x: Number.MAX_SAFE_INTEGER, y: Number.MAX_SAFE_INTEGER }; throw err; } }) .toEqual({ x: 0, y: 0 }); } function isNavigationRace(err: unknown) { return err instanceof Error && /Execution context was destroyed|most likely because of a navigation/i.test(err.message); }