312 lines
10 KiB
TypeScript
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([]);
|
|
});
|