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); } } } 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", }, }); 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", }, }); 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]; } 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); }