vignette/apps/web/e2e/ux-audit-surfaces.spec.ts

312 lines
10 KiB
TypeScript

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<string, unknown>;
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<ScrollPlan> {
return page.evaluate(({ maxSegments, stepRatio }) => {
const candidates: Array<{ owner: ScrollOwnerKind; selector: string | null; element: HTMLElement }> = [];
const main = document.querySelector<HTMLElement>("#vg-main-content");
const root = document.querySelector<HTMLElement>("#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<HTMLElement>(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([]);
});