import { promises as fs } from "node:fs"; import path from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { completeOnboarding, signInAsAdmin, signInAsLearner, signInAsTeacher, useRealApi } from "./support"; const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173"; const VIEWPORT_HEIGHT = 900; const VIEWPORT_WIDTHS = [320, 390, 768, 1024, 1440] as const; const SEGMENT_STEP_RATIO = 0.8; const MAX_SEGMENTS = 20; const REPO_ROOT = path.resolve(process.cwd(), "..", ".."); const OUTPUT_DIR = process.env.UX_AUDIT_OUTPUT_DIR ?? path.join(REPO_ROOT, "outputs", "ux-audit-2026-09-12", "surfaces"); const MANIFEST_PATH = path.join(OUTPUT_DIR, "manifest.json"); type Role = "learner" | "teacher" | "admin" | "fixture"; type ScrollOwnerKind = "main" | "root" | "document"; interface Surface { route: string; role: Role; source: "real-api" | "fixture"; } interface ScrollPlan { owner: ScrollOwnerKind; selector: string | null; offsets: number[]; incomplete: boolean; scrollHeight: number; clientHeight: number; } interface ManifestEntry { route: string; role: Role; source: Surface["source"]; width: number; height: number; scrollowner: ScrollOwnerKind; offset: number; file: string; timestamp: string; screenheading: string; consoleerror: string[]; httperror: string[]; status: "captured" | "http-error"; incomplete: boolean; scrollHeight: number; clientHeight: number; } const manifest: ManifestEntry[] = []; function fileStem(route: string) { return route.replace(/^\//, "").replaceAll("/", "-") || "home"; } function routePattern(route: string) { return new RegExp(`${route.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:[?#]|$)`); } function assertLocalAuditBaseUrl() { const parsed = new URL(BASE_URL); expect(parsed.protocol, `UI 감사는 HTTPS 운영 서버를 대상으로 실행할 수 없다: ${BASE_URL}`).toBe("http:"); expect( ["localhost", "127.0.0.1", "::1"], `UI 감사는 로컬 호스트에서만 dev 계정을 생성할 수 있다: ${BASE_URL}`, ).toContain(parsed.hostname); return parsed.origin; } async function writeManifest(baseURL: string) { await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.writeFile( MANIFEST_PATH, `${JSON.stringify( { generatedAt: new Date().toISOString(), baseURL, viewportWidths: VIEWPORT_WIDTHS, viewportHeight: VIEWPORT_HEIGHT, entries: manifest, }, null, 2, )}\n`, "utf8", ); } async function loginFreshLearner(page: Page, prefix: string) { const response = await page.request.post("/api/auth/dev-login", { data: { email: `${prefix}.${Date.now()}@hs.ac.kr`, role: "learner", display_name: "UX Audit Fixture Learner", }, }); expect(response.ok(), await response.text()).toBeTruthy(); } async function loginOnboardedLearner(page: Page, prefix: string) { await loginFreshLearner(page, prefix); await completeOnboarding(page, { legal_name: "UX 감사 학습자", affiliation: "한신대학교", department: "상담심리학과", grade_level: "3학년", phone: "010-0000-0000", contact_address: "경기도 오산시 한신대학교", }); } async function installPendingFixture(page: Page) { await page.route("**/api/auth/me", async (route) => { if (route.request().method() !== "GET") { await route.fallback(); return; } const response = await route.fetch(); const body = (await response.json()) as Record; await route.fulfill({ response, json: { ...body, email: "pending@hs.ac.kr", account_status: "pending", approval_required: true, }, }); }); } async function waitForSurfaceReady(page: Page, route: string) { await expect(page).toHaveURL(routePattern(route), { timeout: 20_000 }); await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 20_000 }); await page.waitForLoadState("networkidle", { timeout: 20_000 }); await expect .poll( () => page.locator( ".g6-loading, .cic-loading, .lh-loading, .vg-loading, [data-loading='true']", ).count(), { timeout: 10_000 }, ) .toBe(0); await page.waitForTimeout(250); } async function readScrollPlan(page: Page): Promise { return page.evaluate(({ maxSegments, stepRatio }) => { const candidates: Array<{ owner: ScrollOwnerKind; selector: string | null; element: HTMLElement }> = []; const main = document.querySelector("#vg-main-content"); const root = document.querySelector("#root"); if (main) candidates.push({ owner: "main", selector: "#vg-main-content", element: main }); if (root) candidates.push({ owner: "root", selector: "#root", element: root }); const documentScroller = document.scrollingElement as HTMLElement | null; if (documentScroller) candidates.push({ owner: "document", selector: null, element: documentScroller }); const selected = candidates.find((candidate) => candidate.element.scrollHeight > candidate.element.clientHeight + 1) ?? candidates.at(-1); if (!selected) throw new Error("No scroll owner found for UX audit capture"); const maxOffset = Math.max(0, selected.element.scrollHeight - selected.element.clientHeight); const step = Math.max(1, Math.floor(selected.element.clientHeight * stepRatio)); const allOffsets = [0]; for (let offset = step; offset < maxOffset; offset += step) allOffsets.push(offset); if (allOffsets.at(-1) !== maxOffset) allOffsets.push(maxOffset); return { owner: selected.owner, selector: selected.selector, offsets: allOffsets.slice(0, maxSegments), incomplete: allOffsets.length > maxSegments, scrollHeight: selected.element.scrollHeight, clientHeight: selected.element.clientHeight, }; }, { maxSegments: MAX_SEGMENTS, stepRatio: SEGMENT_STEP_RATIO }); } async function scrollToOffset(page: Page, plan: ScrollPlan, offset: number) { await page.evaluate( ({ selector, offset: nextOffset }) => { const target = selector ? document.querySelector(selector) : (document.scrollingElement as HTMLElement | null); if (!target) throw new Error(`Scroll owner missing: ${selector ?? "document"}`); target.scrollTop = nextOffset; target.dispatchEvent(new Event("scroll")); }, { selector: plan.selector, offset }, ); await page.waitForTimeout(100); } async function captureSurface( page: Page, surface: Surface, baseURL: string, consoleErrors: string[], httpErrors: string[], ) { for (const width of VIEWPORT_WIDTHS) { const consoleStart = consoleErrors.length; const httpStart = httpErrors.length; await page.setViewportSize({ width, height: VIEWPORT_HEIGHT }); await page.goto(`${baseURL}${surface.route}`, { waitUntil: "domcontentloaded" }); await waitForSurfaceReady(page, surface.route); const horizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, ); expect(horizontalOverflow, `${surface.route} ${width}px 문서 가로 오버플로`).toBeLessThanOrEqual(1); const plan = await readScrollPlan(page); const heading = (await page.getByRole("heading").first().innerText()).replace(/\s+/g, " ").trim(); for (const [segment, offset] of plan.offsets.entries()) { await scrollToOffset(page, plan, offset); const filename = `${fileStem(surface.route)}-${surface.role}-w${width}-s${segment}-y${offset}.png`; const outputPath = path.join(OUTPUT_DIR, filename); await page.screenshot({ path: outputPath, fullPage: false, animations: "disabled" }); manifest.push({ route: surface.route, role: surface.role, source: surface.source, width, height: VIEWPORT_HEIGHT, scrollowner: plan.owner, offset, file: path.relative(REPO_ROOT, outputPath).split(path.sep).join("/"), timestamp: new Date().toISOString(), screenheading: heading, consoleerror: consoleErrors.slice(consoleStart), httperror: httpErrors.slice(httpStart), status: httpErrors.length > httpStart ? "http-error" : "captured", incomplete: plan.incomplete, scrollHeight: plan.scrollHeight, clientHeight: plan.clientHeight, }); await writeManifest(baseURL); } } } test.describe.configure({ mode: "serial" }); test.use({ baseURL: BASE_URL }); test("@single-run UI 감사 누락 표면을 실제 상태와 전체 세로 구간으로 보존한다", async ({ page }) => { test.setTimeout(1_800_000); await useRealApi(page); const baseURL = assertLocalAuditBaseUrl(); await writeManifest(baseURL); const consoleErrors: string[] = []; const httpErrors: string[] = []; page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); page.on("response", (response) => { if (response.status() >= 400) httpErrors.push(`${response.status()} ${response.url()}`); }); await signInAsLearner(page); for (const surface of [ { route: "/learn/practice", role: "learner", source: "real-api" }, { route: "/learn/history", role: "learner", source: "real-api" }, ] satisfies Surface[]) { await captureSurface(page, surface, baseURL, consoleErrors, httpErrors); } await signInAsTeacher(page); await captureSurface( page, { route: "/teach/supervision", role: "teacher", source: "real-api" }, baseURL, consoleErrors, httpErrors, ); await signInAsAdmin(page); for (const surface of [ { route: "/admin/continuous-improvement", role: "admin", source: "real-api" }, { route: "/admin/access", role: "admin", source: "real-api" }, { route: "/admin/tickets", role: "admin", source: "real-api" }, ] satisfies Surface[]) { await captureSurface(page, surface, baseURL, consoleErrors, httpErrors); } await loginFreshLearner(page, "ux-audit-onboarding"); await captureSurface( page, { route: "/onboarding", role: "fixture", source: "fixture" }, baseURL, consoleErrors, httpErrors, ); await loginOnboardedLearner(page, "ux-audit-pending"); await installPendingFixture(page); await captureSurface( page, { route: "/pending", role: "fixture", source: "fixture" }, baseURL, consoleErrors, httpErrors, ); await page.unroute("**/api/auth/me"); const incomplete = manifest.filter((entry) => entry.incomplete); expect(incomplete, `20개를 넘는 스크롤 구간은 미완료로 기록한다: ${JSON.stringify(incomplete)}`).toEqual([]); });