모바일 메뉴와 주요 화면 UX 개선 및 시각 검수
This commit is contained in:
parent
89b5093ec7
commit
e8b770e4d9
50 changed files with 3189 additions and 457 deletions
312
apps/web/e2e/ux-audit-surfaces.spec.ts
Normal file
312
apps/web/e2e/ux-audit-surfaces.spec.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
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([]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue