첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,604 @@
import { expect, type Page, type Route, test } from "@playwright/test";
type MockOptions = {
sessionStatus?: number;
captureApprove?: (pendingRef: string | null) => void;
captureUserMe?: () => void;
captureVerify?: (path: string, body: Record<string, unknown>) => void;
};
async function seedTokenLogin(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem("baron_auth_token", "e30.e30.e30");
window.localStorage.setItem("baron_auth_provider", "ory");
window.localStorage.removeItem("baron_auth_cookie_mode");
window.localStorage.removeItem("baron_auth_pending_provider");
});
}
async function seedSessionTokenLogin(page: Page): Promise<void> {
await page.addInitScript(() => {
window.sessionStorage.setItem("baron_auth_token", "e30.e30.e30");
window.sessionStorage.setItem("baron_auth_provider", "ory");
window.sessionStorage.removeItem("baron_auth_cookie_mode");
window.sessionStorage.removeItem("baron_auth_pending_provider");
window.localStorage.removeItem("baron_auth_token");
window.localStorage.removeItem("baron_auth_provider");
window.localStorage.removeItem("baron_auth_cookie_mode");
window.localStorage.removeItem("baron_auth_pending_provider");
});
}
async function mockUserfrontApis(
page: Page,
options: MockOptions = {},
): Promise<void> {
const sessionStatus = options.sessionStatus ?? 200;
await page.context().route("**/api/v1/**", async (route: Route) => {
const requestUrl = new URL(route.request().url());
const path = requestUrl.pathname;
if (path.endsWith("/api/v1/user/me")) {
options.captureUserMe?.();
if (sessionStatus === 200) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "e2e-user",
email: "e2e@example.com",
name: "E2E User",
phone: "+821012341234",
department: "QA",
affiliationType: "employee",
companyCode: "BARON",
tenant: {
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
return;
}
await route.fulfill({
status: sessionStatus,
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
if (path.endsWith("/api/v1/auth/qr/approve")) {
if (route.request().method() === "POST") {
let pendingRef: string | null = null;
try {
const body = (route.request().postDataJSON() ?? {}) as {
pendingRef?: string;
};
pendingRef = body.pendingRef ?? null;
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve POST body:`, body);
} catch (e) {
console.log(
`[E2E-MOCK] /api/v1/auth/qr/approve POST body parse error:`,
e,
);
pendingRef = null;
}
options.captureApprove?.(pendingRef);
} else {
console.log(
`[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`,
);
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
if (
path.endsWith("/api/v1/auth/magic-link/verify") ||
path.endsWith("/api/v1/auth/login/code/verify") ||
path.endsWith("/api/v1/auth/login/code/verify-short")
) {
let body: Record<string, unknown> = {};
try {
body = (route.request().postDataJSON() ?? {}) as Record<
string,
unknown
>;
} catch {
body = {};
}
options.captureVerify?.(path, body);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "approved",
pendingRef: "e2e-approved",
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
});
});
}
function collectClientFailures(page: Page): string[] {
const failures: string[] = [];
page.on("pageerror", (error) => {
failures.push(error.message);
});
page.on("console", (message) => {
const text = message.text();
if (
message.type() === "error" ||
(/exception|verify_failed|verification failed|인증 실패/i.test(text) &&
!text.includes("Exception while loading service worker"))
) {
failures.push(text);
}
});
return failures;
}
async function expectPageToRemainBlank(page: Page): Promise<void> {
await expect
.poll(() => {
const url = page.url();
return url === '' || url === 'about:blank';
}, { timeout: 5_000 })
.toBe(true);
}
async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
await page.addInitScript(() => {
window.close = () => {
window.location.href = "/";
};
});
}
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole("button", { name: "Enable accessibility" });
const placeholder = page.locator("flt-semantics-placeholder").first();
await button.click({ force: true, timeout: 1_000 }).catch(async () => {
await placeholder.click({ force: true, timeout: 1_000 }).catch(async () => {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
});
});
});
await page.waitForTimeout(500);
}
test.describe("UserFront WASM auth routing", () => {
test.describe.configure({ mode: "default" });
test("비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다", async ({
page,
}) => {
await mockUserfrontApis(page, { sessionStatus: 401 });
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
});
test("로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다", async ({
page,
}) => {
await seedTokenLogin(page);
await mockUserfrontApis(page);
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/dashboard$/);
await page.reload();
await expect(page).toHaveURL(/\/ko\/dashboard$/);
});
test("sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다", async ({
page,
}) => {
await seedSessionTokenLogin(page);
await mockUserfrontApis(page);
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/dashboard$/);
});
test("비로그인 /ko/approve 는 signin(+notice)으로 이동한다", async ({
page,
}) => {
await mockUserfrontApis(page, { sessionStatus: 401 });
await page.goto("/ko/approve?ref=e2e-ref");
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
});
test("로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다", async ({
page,
}) => {
let approvedRef: string | null = null;
await seedTokenLogin(page);
await mockUserfrontApis(page, {
captureApprove: (pendingRef) => {
approvedRef = pendingRef;
},
});
await page.goto("/ko/approve?ref=e2e-approve-ref");
await expect(page).toHaveURL(/\/ko\/dashboard(?:\?.*)?$/, {
timeout: 10_000,
});
expect(approvedRef).toBe("e2e-approve-ref");
});
test('verifyOnly 승인 완료 화면의 상단 액션은 signin으로 복귀시킨다', async ({
page,
}) => {
let userMeCalls = 0;
const clientFailures = collectClientFailures(page);
const verifyRequests: Array<{
path: string;
body: Record<string, unknown>;
}> = [];
await mockUserfrontApis(page, {
sessionStatus: 401,
captureUserMe: () => {
userMeCalls += 1;
},
captureVerify: (path, body) => {
verifyRequests.push({ path, body });
},
});
await makeWindowCloseNavigateToRoot(page);
await page.goto("/ko/l/AB123456");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
expect(verifyRequests[0].path).toContain(
"/api/v1/auth/login/code/verify-short",
);
expect(verifyRequests[0].body).toMatchObject({
shortCode: "AB123456",
verifyOnly: true,
});
await page.locator("flt-glass-pane").click({
position: { x: 30, y: 28 },
force: true,
});
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(userMeCalls).toBe(0);
expect(
clientFailures.filter(
(failure) => !failure.includes('401 (Unauthorized)'),
),
).toEqual([]);
});
test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
let verifyCalls = 0;
const clientFailures = collectClientFailures(page);
await mockUserfrontApis(page, {
sessionStatus: 401,
captureUserMe: () => {
userMeCalls += 1;
},
captureVerify: () => {
verifyCalls += 1;
},
});
await makeWindowCloseNavigateToRoot(page);
await page.goto("/ko/l/AB123456");
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
await enableFlutterAccessibility(page);
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(
clientFailures.filter(
(failure) => !failure.includes("401 (Unauthorized)"),
),
).toEqual([]);
});
test('verifyOnly 원격 승인 완료는 로그인 창 이동 CTA와 안내 문구를 표시한다', async ({
page,
}) => {
let verifyCalls = 0;
const clientFailures = collectClientFailures(page);
await mockUserfrontApis(page, {
sessionStatus: 401,
captureVerify: () => {
verifyCalls += 1;
},
});
await makeWindowCloseNavigateToRoot(page);
await page.goto("/ko/l/AB123456");
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
await enableFlutterAccessibility(page);
await expect(page.getByText("로그인 승인 완료")).toBeVisible();
await expect(
page.getByText("요청하신 로그인이 완료되었습니다"),
).toBeVisible();
await expect(page.getByRole("button", { name: "창 닫기" })).toHaveCount(0);
await expect(
page.getByRole("button", { name: "로그인 창으로 이동하기" }),
).toBeVisible();
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(clientFailures).toEqual([]);
});
test("루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다", async ({
page,
}) => {
let userMeCalls = 0;
const verifyRequests: Array<{
path: string;
body: Record<string, unknown>;
}> = [];
const clientFailures = collectClientFailures(page);
await mockUserfrontApis(page, {
sessionStatus: 401,
captureUserMe: () => {
userMeCalls += 1;
},
captureVerify: (path, body) => {
verifyRequests.push({ path, body });
},
});
await page.goto(
"/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop",
);
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect.poll(() => page.url(), { timeout: 10_000 }).toContain(
'/ko/verify-complete',
);
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
expect(verifyRequests[0].body).toMatchObject({
loginId: "e2e@example.com",
code: "654321",
pendingRef: "pending-root",
verifyOnly: true,
});
expect(page.url()).not.toContain("loginId=");
expect(page.url()).not.toContain("code=");
expect(page.url()).not.toContain("pendingRef=");
expect(page.url()).not.toContain("utm=");
expect(clientFailures).toEqual([]);
});
test("로그인 페이지에 붙은 인증 payload도 전용 verify 라우트로 넘긴다", async ({
page,
}) => {
let userMeCalls = 0;
const verifyRequests: Array<{
path: string;
body: Record<string, unknown>;
}> = [];
const clientFailures = collectClientFailures(page);
await mockUserfrontApis(page, {
sessionStatus: 401,
captureUserMe: () => {
userMeCalls += 1;
},
captureVerify: (path, body) => {
verifyRequests.push({ path, body });
},
});
await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect.poll(() => page.url(), { timeout: 10_000 }).toContain(
'/ko/verify-complete',
);
expect(verifyRequests[0].body).toMatchObject({
loginId: "e2e@example.com",
code: "999999",
verifyOnly: true,
});
expect(page.url()).not.toContain("loginId=");
expect(page.url()).not.toContain("code=");
expect(clientFailures).toEqual([]);
});
test("verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name === "webkit-mobile-webapp",
"Mobile WebKit closes the opener page when this popup flow closes in headless mode.",
);
let userMeCalls = 0;
let verifyCalls = 0;
const clientFailures = collectClientFailures(page);
await mockUserfrontApis(page, {
sessionStatus: 401,
captureUserMe: () => {
userMeCalls += 1;
},
captureVerify: () => {
verifyCalls += 1;
},
});
const baseURL = testInfo.project.use.baseURL;
if (typeof baseURL !== "string") throw new Error("baseURL is required");
const popupURL = new URL("/ko/l/AB123456", baseURL).toString();
const parentURL = new URL("/version.json", baseURL).toString();
await page.goto(parentURL);
await expect(page).toHaveURL(parentURL);
const popupPromise = page.waitForEvent("popup");
await page.evaluate((url) => {
window.open(url, "_blank");
}, popupURL);
const popup = await popupPromise;
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
await expect(popup).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
if (!popup.isClosed()) {
const closePromise = popup.waitForEvent("close").catch(() => undefined);
try {
await enableFlutterAccessibility(popup);
await popup
.getByRole("button", { name: "로그인 창으로 이동하기" })
.click();
} catch (error) {
if (!popup.isClosed()) {
throw error;
}
}
await closePromise;
}
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(parentURL);
expect(clientFailures).toEqual([]);
});
test("verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
const clientFailures = collectClientFailures(page);
const verifyRequests: Array<{
path: string;
body: Record<string, unknown>;
}> = [];
await mockUserfrontApis(page, {
sessionStatus: 401,
captureUserMe: () => {
userMeCalls += 1;
},
captureVerify: (path, body) => {
verifyRequests.push({ path, body });
},
});
await makeWindowCloseNavigateToRoot(page);
await page.goto("/ko/verify/e2e-email-token");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify');
expect(verifyRequests[0].body).toMatchObject({
token: "e2e-email-token",
verifyOnly: true,
});
await enableFlutterAccessibility(page);
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(clientFailures).toEqual([]);
});
test("verifyOnly 승인 완료 버튼은 이메일 code link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
const clientFailures = collectClientFailures(page);
const verifyRequests: Array<{
path: string;
body: Record<string, unknown>;
}> = [];
await mockUserfrontApis(page, {
sessionStatus: 401,
captureUserMe: () => {
userMeCalls += 1;
},
captureVerify: (path, body) => {
verifyRequests.push({ path, body });
},
});
await makeWindowCloseNavigateToRoot(page);
await page.goto(
"/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email",
);
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
expect(verifyRequests[0].body).toMatchObject({
loginId: "e2e@example.com",
code: "654321",
pendingRef: "pending-email",
verifyOnly: true,
});
await enableFlutterAccessibility(page);
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(clientFailures).toEqual([]);
});
});

View File

@@ -0,0 +1,283 @@
import {
devices,
expect,
type Page,
type Request,
type Response,
test,
} from "@playwright/test";
type LoadMetrics = {
appOrigin: string;
durationMs: number;
transferredBytes: number;
requestedUrls: string[];
requestedPathCounts: Map<string, number>;
cacheControlByPath: Map<string, string>;
contentEncodingByPath: Map<string, string>;
};
async function mockPublicApis(page: Page): Promise<void> {
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname.endsWith("/api/v1/user/me")) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
});
});
}
async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
const appOrigin = new URL(
process.env.BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? "4173"}`,
).origin;
const requestedUrls: string[] = [];
const requestedPathCounts = new Map<string, number>();
const cacheControlByPath = new Map<string, string>();
const contentEncodingByPath = new Map<string, string>();
let transferredBytes = 0;
const onRequest = (request: Request) => {
const requestUrl = new URL(request.url());
requestedUrls.push(request.url());
if (requestUrl.protocol === "http:" || requestUrl.protocol === "https:") {
const resourceKey = `${requestUrl.origin}${requestUrl.pathname}`;
requestedPathCounts.set(
resourceKey,
(requestedPathCounts.get(resourceKey) ?? 0) + 1,
);
}
};
const onResponse = async (response: Response) => {
const url = new URL(response.url());
const cacheControl = response.headers()["cache-control"];
if (cacheControl) {
cacheControlByPath.set(url.pathname, cacheControl);
}
const contentEncoding = response.headers()["content-encoding"];
if (contentEncoding) {
contentEncodingByPath.set(url.pathname, contentEncoding);
}
const timing = response.request().timing();
if (timing.responseEnd >= 0) {
const sizes = await response
.request()
.sizes()
.catch(() => null);
transferredBytes += sizes?.responseBodySize ?? 0;
}
};
page.on("request", onRequest);
page.on("response", onResponse);
try {
const start = performance.now();
await page.goto("/ko/signin", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
const durationMs = Math.round(performance.now() - start);
return {
appOrigin,
durationMs,
transferredBytes,
requestedUrls,
requestedPathCounts,
cacheControlByPath,
contentEncodingByPath,
};
} finally {
page.off("request", onRequest);
page.off("response", onResponse);
}
}
function expectNoDuplicateStaticRequests(metrics: LoadMetrics): void {
const duplicates = [...metrics.requestedPathCounts.entries()].filter(
([resourceKey, count]) => {
const resourceUrl = new URL(resourceKey);
const path = resourceUrl.pathname;
return (
count > 1 &&
resourceUrl.origin === metrics.appOrigin &&
!path.startsWith("/api/") &&
!path.endsWith("/ko/signin") &&
!path.endsWith("/") &&
!path.endsWith("/main.dart.wasm") &&
!path.endsWith("/main.dart.mjs") &&
!path.endsWith("/skwasm.js") &&
!path.endsWith("/skwasm.wasm")
);
},
);
expect(duplicates).toEqual([]);
}
function resolvePerformanceBudget(projectName: string): {
coldMs: number;
warmMs: number;
} {
if (projectName.includes("webkit")) {
return { coldMs: 4000, warmMs: 4000 };
}
if (projectName.includes("firefox")) {
return { coldMs: 2600, warmMs: 2800 };
}
if (projectName.includes("mobile")) {
return { coldMs: 3000, warmMs: 2300 };
}
return { coldMs: 2300, warmMs: 1500 };
}
function resolveRootRedirectBudget(projectName: string): number {
if (projectName.includes("webkit")) {
return 700;
}
if (projectName.includes("firefox")) {
return 600;
}
return 300;
}
test.describe("UserFront login performance budget", () => {
test("mobile Chrome service worker install does not fetch unused CanvasKit variants", async ({
browser,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-mobile-webapp",
"service worker install race is covered once in the mobile Chromium project",
);
const context = await browser.newContext({
...devices["Pixel 7"],
locale: "ko-KR",
serviceWorkers: "allow",
});
const page = await context.newPage();
await mockPublicApis(page);
try {
const serviceWorkerResponse = await context.request.get(
new URL(
"/flutter_service_worker.js",
process.env.BASE_URL ??
`http://127.0.0.1:${process.env.PORT ?? "4173"}`,
).toString(),
);
const serviceWorkerBody = await serviceWorkerResponse.text();
expect(serviceWorkerBody).not.toContain('"/canvaskit/');
expect(serviceWorkerBody).not.toContain('"/main.dart.');
await page.goto("/ko/signin", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(3_000);
} finally {
await context.close();
}
});
test("warm login page load stays within the platform budget and reuses cached assets", async ({
page,
}, testInfo) => {
await mockPublicApis(page);
const budget = resolvePerformanceBudget(testInfo.project.name);
const cold = await measureSigninLoad(page);
const warm = await measureSigninLoad(page);
console.log(
`[userfront-perf] cold=${cold.durationMs}ms/${cold.transferredBytes}B warm=${warm.durationMs}ms/${warm.transferredBytes}B`,
);
expect(cold.durationMs).toBeLessThanOrEqual(budget.coldMs);
expect(warm.durationMs).toBeLessThanOrEqual(budget.warmMs);
expect(warm.transferredBytes).toBeLessThanOrEqual(1_000_000);
expectNoDuplicateStaticRequests(cold);
expectNoDuplicateStaticRequests(warm);
const cacheControlByPath = new Map([
...cold.cacheControlByPath,
...warm.cacheControlByPath,
]);
const appShellCache = cacheControlByPath.get("/ko/signin") ?? "";
expect(appShellCache).toContain("no-cache");
const serviceWorkerState = await page.evaluate(async () => {
if (!("serviceWorker" in navigator)) {
return {
available: false,
secure: window.isSecureContext,
scriptUrl: "",
};
}
const registrations = await navigator.serviceWorker.getRegistrations();
const registration = registrations[0];
return {
available: true,
secure: window.isSecureContext,
count: registrations.length,
controller: navigator.serviceWorker.controller?.scriptURL ?? "",
scriptUrl:
registration?.active?.scriptURL ??
registration?.waiting?.scriptURL ??
registration?.installing?.scriptURL ??
"",
};
});
if (
testInfo.project.name.includes("mobile") &&
serviceWorkerState.scriptUrl
) {
expect(new URL(serviceWorkerState.scriptUrl).pathname).toBe(
"/flutter_service_worker.js",
);
const serviceWorkerResponse = await page
.context()
.request.get(
new URL("/flutter_service_worker.js", page.url()).toString(),
);
expect(serviceWorkerResponse.headers()["cache-control"] ?? "").toContain(
"no-cache",
);
} else {
expect(serviceWorkerState.scriptUrl).toBe("");
}
expect(cold.durationMs).toBeGreaterThanOrEqual(0);
});
test("root redirects to localized signin before Flutter boots", async ({
page,
}, testInfo) => {
await mockPublicApis(page);
const requestedUrls: string[] = [];
page.on("request", (request) => {
requestedUrls.push(request.url());
});
const start = performance.now();
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
const durationMs = Math.round(performance.now() - start);
expect(durationMs).toBeLessThanOrEqual(
resolveRootRedirectBudget(testInfo.project.name),
);
const rootIndex = requestedUrls.findIndex(
(url) => new URL(url).pathname === "/",
);
const bootstrapIndex = requestedUrls.findIndex((url) =>
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
);
expect(rootIndex).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,70 @@
import { expect, type Page, type Route, test } from "@playwright/test";
async function mockUserfrontApisForRepro(
page: Page,
options: { sessionStatus: number } = { sessionStatus: 401 },
): Promise<void> {
await page.route("**/api/v1/**", async (route: Route) => {
const requestUrl = new URL(route.request().url());
const path = requestUrl.pathname;
if (path.endsWith("/api/v1/user/me")) {
await route.fulfill({
status: options.sessionStatus,
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (path.endsWith("/api/v1/client-log")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
// Default mock for other APIs
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
});
});
}
test.describe("Issue #345 Reproduction (Log-based Validation)", () => {
test("비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다", async ({
page,
}) => {
const requests: string[] = [];
page.on("request", (request) => {
if (request.isNavigationRequest()) {
requests.push(request.url());
}
});
await mockUserfrontApisForRepro(page, { sessionStatus: 401 });
const targetUrl = "/ko/signin?login_challenge=repro_challenge_12345";
await page.goto(targetUrl);
// WASM 앱 로딩 및 로직 실행 대기
await page.waitForTimeout(7000);
const currentUrl = page.url();
const signinNavigations = requests.filter((url) => url.includes("/signin"));
// [검증 1] URL 유지 확인
expect(currentUrl).toContain("login_challenge=repro_challenge_12345");
// [검증 2] 리다이렉트 루프 발생 여부 확인 (최초 진입 1회만 있어야 함)
expect(signinNavigations.length).toBeLessThanOrEqual(1);
console.log(
"✅ 루프가 해결되었으며, URL 유지와 네비게이션 수로 정상 동작을 확인했습니다.",
);
});
});

View File

@@ -0,0 +1,456 @@
import {
expect,
type Locator,
type Page,
type Route,
test,
} from "@playwright/test";
type RequestCapture = {
loginBody?: Record<string, unknown>;
resetBody?: Record<string, unknown>;
resetToken?: string | null;
clientLogs: string[];
};
const resetNewPasswordName =
/^(새 비밀번호|ui\.userfront\.reset\.new_password)$/;
const resetConfirmPasswordName =
/^(새 비밀번호 확인|ui\.userfront\.reset\.confirm_password)$/;
const resetSubmitButtonName = /^(비밀번호 변경|ui\.userfront\.reset\.submit)$/;
async function enableFlutterAccessibility(page: Page): Promise<void> {
const button = page.getByRole("button", { name: "Enable accessibility" });
if (await button.count()) {
await button.first().evaluate((node) => {
(node as HTMLElement).click();
});
await page.waitForTimeout(200);
return;
}
await page.waitForTimeout(300);
const placeholder = page.locator("flt-semantics-placeholder").first();
if (await placeholder.count()) {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
});
await page.waitForTimeout(800);
}
}
type ScreenCoords = {
signinPasswordTabX: number;
signinTabY: number;
signinLoginIdX: number;
signinLoginIdY: number;
signinPasswordX: number;
signinPasswordY: number;
signinSubmitX: number;
signinSubmitY: number;
resetNewPasswordX: number;
resetNewPasswordY: number;
resetConfirmPasswordX: number;
resetConfirmPasswordY: number;
resetSubmitX: number;
resetSubmitY: number;
};
const desktopCoords: ScreenCoords = {
signinPasswordTabX: 522,
signinTabY: 158,
signinLoginIdX: 640,
signinLoginIdY: 245,
signinPasswordX: 640,
signinPasswordY: 311,
signinSubmitX: 640,
signinSubmitY: 381,
resetNewPasswordX: 640,
resetNewPasswordY: 382,
resetConfirmPasswordX: 640,
resetConfirmPasswordY: 464,
resetSubmitX: 640,
resetSubmitY: 534,
};
const mobileCoords: ScreenCoords = {
signinPasswordTabX: 90,
signinTabY: 158,
signinLoginIdX: 206,
signinLoginIdY: 268,
signinPasswordX: 206,
signinPasswordY: 334,
signinSubmitX: 206,
signinSubmitY: 399,
resetNewPasswordX: 206,
resetNewPasswordY: 382,
resetConfirmPasswordX: 206,
resetConfirmPasswordY: 464,
resetSubmitX: 206,
resetSubmitY: 534,
};
function coordsFor(page: Page): ScreenCoords {
const viewport = page.viewportSize();
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
}
function isMobileProject(page: Page): boolean {
const viewport = page.viewportSize();
return (viewport?.width ?? 1280) <= 500;
}
async function clickPasswordTab(page: Page): Promise<void> {
if (isMobileProject(page)) {
return;
}
const coords = coordsFor(page);
await page.waitForTimeout(900);
const pane = page.locator("flt-glass-pane");
await pane.click({
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
force: true,
});
await page.waitForTimeout(120);
await pane.click({
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
force: true,
});
await page.waitForTimeout(200);
}
async function fillAt(
page: Page,
x: number,
y: number,
value: string,
): Promise<void> {
const pane = page.locator("flt-glass-pane");
await pane.click({ position: { x, y }, force: true });
await page.waitForTimeout(100);
await page.keyboard.press("Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(value);
}
async function typeIntoAccessibleField(
page: Page,
field: Locator,
value: string,
): Promise<void> {
await field.click({ force: true });
await page.waitForTimeout(100);
await page.keyboard.press("Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(value);
}
async function fillPasswordLoginForm(
page: Page,
loginId: string,
password: string,
): Promise<void> {
if (isMobileProject(page)) {
await enableFlutterAccessibility(page);
const inputs = page.getByRole("textbox");
await inputs.nth(0).fill(loginId);
await inputs.nth(1).fill(password);
return;
}
const coords = coordsFor(page);
await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId);
await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password);
}
async function submitPasswordLogin(page: Page): Promise<void> {
if (isMobileProject(page)) {
await enableFlutterAccessibility(page);
await page.getByRole("button", { name: "로그인" }).click({ force: true });
return;
}
await page.keyboard.press("Enter");
}
async function fillResetPasswordForm(
page: Page,
password: string,
): Promise<void> {
await enableFlutterAccessibility(page);
const newPasswordInput = page.getByRole("textbox", {
name: resetNewPasswordName,
});
const confirmPasswordInput = page.getByRole("textbox", {
name: resetConfirmPasswordName,
});
if (
(await newPasswordInput.count()) > 0 &&
(await confirmPasswordInput.count()) > 0
) {
await typeIntoAccessibleField(page, newPasswordInput, password);
await typeIntoAccessibleField(page, confirmPasswordInput, password);
return;
}
if (isMobileProject(page)) {
await page
.getByRole("textbox", { name: resetNewPasswordName })
.fill(password);
await page
.getByRole("textbox", { name: resetConfirmPasswordName })
.fill(password);
return;
}
const coords = coordsFor(page);
await fillAt(
page,
coords.resetNewPasswordX,
coords.resetNewPasswordY,
password,
);
await fillAt(
page,
coords.resetConfirmPasswordX,
coords.resetConfirmPasswordY,
password,
);
}
async function submitResetPassword(page: Page): Promise<void> {
await enableFlutterAccessibility(page);
const submitButton = page.getByRole("button", {
name: resetSubmitButtonName,
});
if ((await submitButton.count()) > 0) {
await submitButton.click({ force: true });
return;
}
if (isMobileProject(page)) {
return;
}
const coords = coordsFor(page);
await page.locator("flt-glass-pane").click({
position: { x: coords.resetSubmitX, y: coords.resetSubmitY },
force: true,
});
}
async function mockAuthApis(
page: Page,
capture: RequestCapture,
): Promise<void> {
await page.route("**/api/v1/**", async (route: Route) => {
const requestUrl = new URL(route.request().url());
const path = requestUrl.pathname;
if (path.endsWith("/api/v1/auth/password/login")) {
capture.loginBody = (route.request().postDataJSON() ?? {}) as Record<
string,
unknown
>;
const loginId = String(capture.loginBody.loginId ?? "");
const password = String(capture.loginBody.password ?? "");
if (loginId === "e2e@example.com" && password === "ValidPass1!") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
sessionJwt: "e30.e30.e30",
provider: "ory",
}),
});
return;
}
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "password_or_email_mismatch" }),
});
return;
}
if (path.endsWith("/api/v1/auth/password/policy")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
minLength: 12,
minCharacterTypes: 3,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
}),
});
return;
}
if (path.endsWith("/api/v1/auth/password/reset/complete")) {
capture.resetBody = (route.request().postDataJSON() ?? {}) as Record<
string,
unknown
>;
capture.resetToken = requestUrl.searchParams.get("token");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "ok" }),
});
return;
}
if (path.endsWith("/api/v1/client-log")) {
const payload = (route.request().postDataJSON() ?? {}) as {
message?: string;
};
if (payload.message != null) {
capture.clientLogs.push(payload.message);
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
if (path.endsWith("/api/v1/user/me")) {
const authHeader = route.request().headers().authorization ?? "";
if (!authHeader.startsWith("Bearer ")) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "e2e-user",
email: "e2e@example.com",
name: "E2E User",
phone: "+821012341234",
department: "QA",
affiliationType: "employee",
companyCode: "BARON",
tenant: {
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
return;
}
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
});
});
}
test.describe("UserFront WASM password login and reset", () => {
test.skip(({ isMobile }) => isMobile, "Desktop only (hardcoded coordinates)");
test("비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다", async ({
page,
}) => {
test.skip(
isMobileProject(page),
"Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.",
);
const capture: RequestCapture = { clientLogs: [] };
await mockAuthApis(page, capture);
await page.goto("/ko/signin");
await clickPasswordTab(page);
await fillPasswordLoginForm(page, "e2e@example.com", "ValidPass1!");
await submitPasswordLogin(page);
await expect(page).toHaveURL(/\/ko\/dashboard$/);
expect(capture.loginBody?.loginId).toBe("e2e@example.com");
expect(capture.loginBody?.password).toBe("ValidPass1!");
const storedToken = await page.evaluate(() =>
window.localStorage.getItem("baron_auth_token"),
);
expect(storedToken).toBe("e30.e30.e30");
});
test("비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다", async ({
page,
}) => {
test.skip(
isMobileProject(page),
"Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.",
);
const capture: RequestCapture = { clientLogs: [] };
await mockAuthApis(page, capture);
await page.goto("/ko/signin");
await clickPasswordTab(page);
await fillPasswordLoginForm(page, "e2e@example.com", "WrongPass1!");
await submitPasswordLogin(page);
await expect(page).toHaveURL(/\/ko\/signin$/);
await expect
.poll(
() =>
capture.clientLogs.some((message) =>
message.includes("password_or_email_mismatch"),
),
{ timeout: 10000 },
)
.toBe(true);
});
test("reset-password에서 변경 성공 시 signin으로 이동한다", async ({
page,
}) => {
const capture: RequestCapture = { clientLogs: [] };
await mockAuthApis(page, capture);
const policyLoaded = page.waitForResponse(
(response) =>
response.url().includes("/api/v1/auth/password/policy") &&
response.status() === 200,
);
await page.goto("/ko/reset-password?token=reset-token-e2e");
await policyLoaded;
await page.waitForTimeout(900);
await fillResetPasswordForm(page, "ValidPass1!A");
await submitResetPassword(page);
await expect
.poll(() => capture.resetBody?.newPassword as string | undefined, {
timeout: 10000,
})
.toBe("ValidPass1!A");
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/, { timeout: 10_000 });
expect(capture.resetToken).toBe("reset-token-e2e");
expect(capture.resetBody?.newPassword).toBe("ValidPass1!A");
});
});

View File

@@ -0,0 +1,504 @@
import { expect, type Page, type Route, test } from "@playwright/test";
type ProfileState = {
department: string;
getMeCount: number;
putBodies: Array<Record<string, unknown>>;
};
async function enableFlutterAccessibility(page: Page): Promise<void> {
const button = page.getByRole("button", { name: "Enable accessibility" });
if (await button.count()) {
await button.click({ force: true }).catch(async () => {
await page
.locator('flt-semantics-placeholder[aria-label="Enable accessibility"]')
.evaluate((element) => {
if (element instanceof HTMLElement) element.click();
});
});
await page.waitForTimeout(200);
}
}
type ProfileCoords = {
departmentEditX: number;
departmentEditY: number;
departmentInputX: number;
departmentInputY: number;
blurX: number;
blurY: number;
};
const desktopCoords: ProfileCoords = {
departmentEditX: 1170,
departmentEditY: 680,
departmentInputX: 110,
departmentInputY: 685,
blurX: 200,
blurY: 260,
};
const mobileCoords: ProfileCoords = {
departmentEditX: 350,
departmentEditY: 680,
departmentInputX: 110,
departmentInputY: 685,
blurX: 200,
blurY: 260,
};
function coordsFor(page: Page): ProfileCoords {
const viewport = page.viewportSize();
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
}
function isMobileProject(page: Page): boolean {
const viewport = page.viewportSize();
return (viewport?.width ?? 1280) <= 500;
}
async function seedTokenLogin(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem("baron_auth_token", "e30.e30.e30");
window.localStorage.setItem("baron_auth_provider", "ory");
window.localStorage.removeItem("baron_auth_cookie_mode");
window.localStorage.removeItem("baron_auth_pending_provider");
});
}
async function fillAt(
page: Page,
x: number,
y: number,
value: string,
): Promise<void> {
const pane = page.locator("flt-glass-pane");
await pane.click({ position: { x, y }, force: true });
await page.waitForTimeout(100);
await replaceFocusedText(page, value);
}
async function replaceFocusedText(page: Page, value: string): Promise<void> {
await page.keyboard.press("End");
for (let index = 0; index < 64; index += 1) {
await page.keyboard.press("Backspace");
}
if (value !== "") {
await page.keyboard.insertText(value);
}
await page.waitForTimeout(100);
}
type BoxCenter = {
x: number;
y: number;
};
async function resolveLocatorCenter(
locator: ReturnType<Page["locator"]>,
): Promise<BoxCenter | null> {
const handle = await locator
.elementHandle({ timeout: 1_000 })
.catch(() => null);
if (!handle) {
return null;
}
const box = await handle
.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
};
})
.catch(() => null);
await handle.dispose();
if (!box) {
return null;
}
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2,
};
}
async function clickGlassPaneAt(
page: Page,
center: BoxCenter | null,
): Promise<boolean> {
if (!center) {
return false;
}
await page.locator("flt-glass-pane").click({
position: center,
force: true,
});
await page.waitForTimeout(200);
return true;
}
async function departmentTextboxIsOpen(page: Page): Promise<boolean> {
return (await page.getByRole("textbox", { name: "소속" }).count()) > 0;
}
async function openDepartmentEditor(page: Page): Promise<void> {
const accessibleEditor = page
.getByRole("group", { name: "소속 QA" })
.getByRole("button", { name: "편집" });
const textbox = page.getByRole("textbox", { name: "소속" });
if ((await accessibleEditor.count()) > 0) {
const editorCenter = await resolveLocatorCenter(accessibleEditor);
await accessibleEditor
.evaluate(
(element) => {
if (element instanceof HTMLElement) {
element.click();
}
},
{ timeout: 1_000 },
)
.catch(() => undefined);
await page.waitForTimeout(200);
if (await departmentTextboxIsOpen(page)) {
return;
}
await clickGlassPaneAt(page, editorCenter);
if (await departmentTextboxIsOpen(page)) {
return;
}
await accessibleEditor
.click({ force: true, timeout: 1_000 })
.catch(() => undefined);
await page.waitForTimeout(200);
if (await departmentTextboxIsOpen(page)) {
return;
}
}
if (isMobileProject(page)) {
throw new Error("Department editor accessibility button was not found.");
}
const coords = coordsFor(page);
const viewport = page.viewportSize();
const editCandidates: BoxCenter[] = [
{ x: coords.departmentEditX, y: coords.departmentEditY },
{ x: (viewport?.width ?? 1280) - 110, y: coords.departmentEditY },
{ x: coords.departmentEditX - 24, y: coords.departmentEditY },
{ x: coords.departmentEditX + 24, y: coords.departmentEditY },
];
for (const candidate of editCandidates) {
await clickGlassPaneAt(page, candidate);
if (await departmentTextboxIsOpen(page)) {
return;
}
}
await expect(textbox).toHaveCount(1, { timeout: 1_000 });
}
async function blurDepartmentEditor(page: Page): Promise<void> {
const textbox = page.getByRole("textbox", { name: "소속" });
if ((await textbox.count()) > 0) {
await textbox.blur();
await page.waitForTimeout(250);
return;
}
if (isMobileProject(page)) {
throw new Error("Department textbox was not found.");
}
const coords = coordsFor(page);
await page.locator("flt-glass-pane").click({
position: { x: coords.blurX, y: coords.blurY },
force: true,
});
await page.waitForTimeout(250);
}
async function submitDepartmentEditor(page: Page): Promise<void> {
const textbox = page.getByRole("textbox", { name: "소속" });
if ((await textbox.count()) > 0) {
await textbox.press("Enter");
await page.waitForTimeout(250);
return;
}
if (isMobileProject(page)) {
throw new Error("Department textbox was not found.");
}
await page.keyboard.press("Enter");
await page.waitForTimeout(250);
}
async function fillDepartmentField(page: Page, value: string): Promise<void> {
const textbox = page.getByRole("textbox", { name: "소속" });
if (!isMobileProject(page)) {
if ((await textbox.count()) > 0) {
await textbox.click({ force: true });
await page.waitForTimeout(100);
}
const coords = coordsFor(page);
await fillAt(page, coords.departmentInputX, coords.departmentInputY, value);
return;
}
if ((await textbox.count()) > 0) {
await textbox.click({ force: true });
await page.waitForTimeout(100);
await replaceFocusedText(page, value);
return;
}
if (isMobileProject(page)) {
throw new Error("Department textbox was not found.");
}
const coords = coordsFor(page);
await fillAt(page, coords.departmentInputX, coords.departmentInputY, value);
}
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
await page.route("**/api/v1/**", async (route: Route) => {
const request = route.request();
const requestUrl = new URL(request.url());
const path = requestUrl.pathname;
const method = request.method().toUpperCase();
if (path.endsWith("/api/v1/user/me") && method === "GET") {
const authHeader = request.headers().authorization ?? "";
if (!authHeader.startsWith("Bearer ")) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
state.getMeCount += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "e2e-user",
email: "e2e@example.com",
name: "E2E User",
phone: "+821012341234",
department: state.department,
affiliationType: "employee",
companyCode: "BARON",
tenant: {
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
return;
}
if (path.endsWith("/api/v1/user/me") && method === "PUT") {
const body = (request.postDataJSON() ?? {}) as Record<string, unknown>;
state.putBodies.push(body);
const nextDepartment = String(body.department ?? "").trim();
if (nextDepartment !== "") {
state.department = nextDepartment;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "success",
updatedAt: "2026-02-24T00:00:00Z",
}),
});
return;
}
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
if (path.endsWith("/api/v1/client-log")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});
}
async function openProfilePage(page: Page): Promise<void> {
await page.goto("/ko/profile");
await expect(page).toHaveURL(/\/ko\/profile$/);
await enableFlutterAccessibility(page);
await page.waitForTimeout(1200);
}
async function waitForInitialProfileLoad(state: ProfileState): Promise<void> {
await expect.poll(() => state.getMeCount).toBeGreaterThan(0);
}
test.describe("UserFront WASM profile department editing", () => {
test.skip(({ isMobile }) => isMobile, "Desktop only (hardcoded coordinates)");
test.skip(
({ browserName }) => browserName === "webkit",
"WebKit headless does not consistently open Flutter profile edit controls; Chromium and Firefox cover this flow.",
);
test.afterEach(async ({ page }) => {
await page.unroute("**/api/v1/**");
});
test("소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다", async ({
page,
}) => {
const state: ProfileState = {
department: "QA",
getMeCount: 0,
putBodies: [],
};
await seedTokenLogin(page);
await mockProfileApis(page, state);
await openProfilePage(page);
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, "QA-Updated");
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
expect(state.putBodies[0]?.department).toBe("QA-Updated");
expect(state.department).toBe("QA-Updated");
const getCountBeforeReload = state.getMeCount;
await page.reload();
await expect(page).toHaveURL(/\/ko\/profile$/);
await expect
.poll(() => state.getMeCount)
.toBeGreaterThan(getCountBeforeReload);
});
test("소속 입력 후 즉시 새로고침해도 저장 요청이 중복 전송되지 않는다", async ({
page,
}) => {
const state: ProfileState = {
department: "QA",
getMeCount: 0,
putBodies: [],
};
await seedTokenLogin(page);
await mockProfileApis(page, state);
await openProfilePage(page);
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, "QA-Repro");
await page.reload();
await expect(page).toHaveURL(/\/ko\/profile$/);
expect(state.putBodies.length).toBeLessThanOrEqual(1);
if (state.putBodies.length > 0) {
expect(state.putBodies[0]?.department).toBe("QA-Repro");
expect(state.department).toBe("QA-Repro");
return;
}
expect(state.department).toBe("QA");
});
test("소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다", async ({
page,
}) => {
const state: ProfileState = {
department: "QA",
getMeCount: 0,
putBodies: [],
};
await seedTokenLogin(page);
await mockProfileApis(page, state);
await openProfilePage(page);
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, "QA");
await blurDepartmentEditor(page);
expect(state.putBodies).toHaveLength(0);
});
test("소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다", async ({
page,
}) => {
const state: ProfileState = {
department: "QA",
getMeCount: 0,
putBodies: [],
};
await seedTokenLogin(page);
await mockProfileApis(page, state);
await openProfilePage(page);
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, "");
await blurDepartmentEditor(page);
expect(state.putBodies).toHaveLength(0);
expect(state.department).toBe("QA");
});
test("소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다", async ({
page,
}) => {
const state: ProfileState = {
department: "QA",
getMeCount: 0,
putBodies: [],
};
await seedTokenLogin(page);
await mockProfileApis(page, state);
await openProfilePage(page);
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, "QA-1");
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
const getCountBeforeReload = state.getMeCount;
await page.reload();
await expect(page).toHaveURL(/\/ko\/profile$/);
await enableFlutterAccessibility(page);
await expect
.poll(() => state.getMeCount)
.toBeGreaterThan(getCountBeforeReload);
await page.waitForTimeout(1200);
await openDepartmentEditor(page);
await fillDepartmentField(page, "QA-2");
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(2);
expect(state.putBodies[0]?.department).toBe("QA-1");
expect(state.putBodies[1]?.department).toBe("QA-2");
expect(state.department).toBe("QA-2");
});
});

View File

@@ -0,0 +1,335 @@
import { expect, type Page, type Route, test } from "@playwright/test";
async function seedTokenLogin(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem("baron_auth_token", "e30.e30.e30");
window.localStorage.setItem("baron_auth_provider", "ory");
window.localStorage.removeItem("baron_auth_cookie_mode");
window.localStorage.removeItem("baron_auth_pending_provider");
});
}
async function mockInventoryApis(page: Page): Promise<void> {
await page.route("**/api/v1/**", async (route: Route) => {
const requestUrl = new URL(route.request().url());
const path = requestUrl.pathname;
const method = route.request().method().toUpperCase();
if (path.endsWith("/api/v1/user/me")) {
const authHeader = route.request().headers().authorization ?? "";
if (authHeader.startsWith("Bearer ")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "e2e-user",
email: "e2e@example.com",
name: "E2E User",
phone: "+821012341234",
department: "QA",
affiliationType: "employee",
companyCode: "BARON",
tenant: {
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
return;
}
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
if (path.endsWith("/api/v1/auth/password/policy")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
minLength: 12,
minCharacterTypes: 3,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
}),
});
return;
}
if (path.endsWith("/api/v1/auth/magic-link/verify")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "approved" }),
});
return;
}
if (path.endsWith("/api/v1/auth/login/code/verify")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "approved" }),
});
return;
}
if (path.endsWith("/api/v1/auth/login/code/verify-short")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "approved" }),
});
return;
}
if (path.endsWith("/api/v1/auth/consent") && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
client: {
client_name: "E2E Client",
client_id: "e2e-client",
},
requested_scope: ["openid"],
scope_details: {
openid: {
description: "OpenID",
mandatory: true,
},
},
}),
});
return;
}
if (path.endsWith("/api/v1/auth/qr/approve")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
if (path.endsWith("/api/v1/client-log")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
});
});
}
test.describe("UserFront WASM route inventory (unauth)", () => {
test.beforeEach(async ({ page }) => {
await mockInventoryApis(page);
});
test("route: /", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/(ko|en)\/signin(?:\?.*)?$/);
});
test("route: /ko", async ({ page }) => {
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
});
test("route: /ko/dashboard", async ({ page }) => {
await page.goto("/ko/dashboard");
await expect(page).toHaveURL(/\/ko\/signin$/);
});
test("route: /ko/profile", async ({ page }) => {
await page.goto("/ko/profile");
await expect(page).toHaveURL(/\/ko\/signin$/);
});
test("route: /ko/admin/users", async ({ page }) => {
await page.goto("/ko/admin/users");
await expect(page).toHaveURL(/\/ko\/signin$/);
});
test("route: /ko/scan", async ({ page }) => {
await page.goto("/ko/scan");
await expect(page).toHaveURL(/\/ko\/signin$/);
});
test("route: /ko/signin", async ({ page }) => {
await page.goto("/ko/signin");
await expect(page).toHaveURL(/\/ko\/signin$/);
});
test("route: /ko/login", async ({ page }) => {
await page.goto("/ko/login");
await expect(page).toHaveURL(/\/ko\/login$/);
});
test("route: /ko/signup", async ({ page }) => {
await page.goto("/ko/signup");
await expect(page).toHaveURL(/\/ko\/signup$/);
});
test("route: /ko/registration", async ({ page }) => {
await page.goto("/ko/registration");
await expect(page).toHaveURL(/\/ko\/registration$/);
});
test("route: /ko/verify", async ({ page }) => {
await page.goto("/ko/verify");
await expect(page).toHaveURL(/\/ko\/verify$/);
});
test("route: /ko/verify/:token", async ({ page }) => {
await page.goto("/ko/verify/e2e-token");
await expect(page).toHaveURL(/\/ko\/verify\/e2e-token$/);
});
test("route: /ko/verification", async ({ page }) => {
await page.goto("/ko/verification");
await expect(page).toHaveURL(/\/ko\/verification$/);
});
test("route: /ko/verify-complete", async ({ page }) => {
await page.goto("/ko/verify-complete");
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
});
test("route: /ko/l/:shortCode", async ({ page }) => {
await page.goto("/ko/l/AB123456");
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
});
test("route: /ko/forgot-password", async ({ page }) => {
await page.goto("/ko/forgot-password");
await expect(page).toHaveURL(/\/ko\/forgot-password$/);
});
test("route: /ko/recovery", async ({ page }) => {
await page.goto("/ko/recovery");
await expect(page).toHaveURL(/\/ko\/recovery$/);
});
test("route: /ko/reset-password", async ({ page }) => {
await page.goto("/ko/reset-password?token=e2e-reset-token");
await expect(page).toHaveURL(
/\/ko\/reset-password\?token=e2e-reset-token$/,
);
});
test("route: /ko/error", async ({ page }) => {
await page.goto("/ko/error?error=invalid_request");
await expect(page).toHaveURL(/\/ko\/error\?error=invalid_request$/);
});
test("route: /ko/settings", async ({ page }) => {
await page.goto("/ko/settings");
await expect(page).toHaveURL(/\/ko\/settings$/);
});
test("route: /ko/consent (missing challenge)", async ({ page }) => {
await page.goto("/ko/consent");
await expect(page).toHaveURL(/\/ko\/consent$/);
});
test("route: /ko/consent?consent_challenge=...", async ({ page }) => {
await page.goto("/ko/consent?consent_challenge=e2e-consent");
await expect(page).toHaveURL(
/\/ko\/consent\?consent_challenge=e2e-consent$/,
);
});
test("route: /ko/approve?ref=...", async ({ page }) => {
await page.goto("/ko/approve?ref=e2e-ref");
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
});
test("route: /ko/ql/:ref", async ({ page }) => {
await page.goto("/ko/ql/e2e-ref");
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
});
});
test.describe("UserFront WASM route inventory (authed)", () => {
test.beforeEach(async ({ page }) => {
await seedTokenLogin(page);
await mockInventoryApis(page);
});
test("route: /ko -> /ko/dashboard", async ({ page }) => {
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/dashboard$/);
});
test("route: /ko/dashboard", async ({ page }) => {
await page.goto("/ko/dashboard");
await expect(page).toHaveURL(/\/ko\/dashboard$/);
});
test("route: /ko/profile", async ({ page }) => {
await page.goto("/ko/profile");
await expect(page).toHaveURL(/\/ko\/profile$/);
});
test("route: /ko/admin/users", async ({ page }) => {
await page.goto("/ko/admin/users");
await expect(page).toHaveURL(/\/ko\/admin\/users$/);
});
test("route: /ko/scan", async ({ page }) => {
await page.goto("/ko/scan");
await expect(page).toHaveURL(/\/ko\/scan$/);
});
test("route: /ko/approve?ref=... -> /ko/dashboard", async ({
page,
}, testInfo) => {
await page.goto("/ko/approve?ref=e2e-ref");
await expect(page).toHaveURL(/\/ko\/dashboard$/, {
timeout: testInfo.project.name === "webkit-desktop" ? 15_000 : 5_000,
});
});
test("route: /ko/ql/:ref -> /ko/dashboard", async ({ page }, testInfo) => {
await page.goto("/ko/ql/e2e-ref");
await expect(page).toHaveURL(/\/ko\/dashboard$/, {
timeout: testInfo.project.name === "webkit-desktop" ? 15_000 : 5_000,
});
});
});

View File

@@ -0,0 +1,436 @@
import { readFileSync, writeFileSync } from "node:fs";
import { inflateSync } from "node:zlib";
import {
type BrowserContext,
expect,
type Page,
type TestInfo,
test,
} from "@playwright/test";
const lightweightTestFont = readFileSync(
new URL("../fixtures/fonts/NotoSansKR-TestSubset.woff2", import.meta.url),
);
type SigninCase = {
path: "/ko/signin" | "/en/signin";
theme: "light" | "dark";
};
const signinCases: SigninCase[] = [
{ path: "/ko/signin", theme: "light" },
{ path: "/ko/signin", theme: "dark" },
{ path: "/en/signin", theme: "light" },
{ path: "/en/signin", theme: "dark" },
];
async function mockPublicApis(context: BrowserContext): Promise<void> {
await context.route(/\/api\/v1\//, async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname.endsWith("/api/v1/user/me")) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (requestUrl.pathname.endsWith("/api/v1/auth/tenant-info")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});
}
async function routeLightweightTestFonts(
context: BrowserContext,
): Promise<void> {
await context.route("https://fonts.gstatic.com/**", async (route) => {
await route.fulfill({
status: 200,
contentType: "font/woff2",
body: lightweightTestFont,
headers: {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=31536000, immutable",
},
});
});
}
async function expectFlutterCanvasRendered(
page: Page,
timeoutMs = 10_000,
): Promise<void> {
await expect(page.locator("#baron-bootstrap-shell")).toBeHidden({
timeout: timeoutMs,
});
await expect
.poll(
async () => {
const screenshot = await captureFlutterCanvasPng(page);
return screenshot === null
? false
: screenshotHasSigninPaint(screenshot);
},
{
timeout: timeoutMs,
},
)
.toBe(true);
}
async function expectBootstrapShellVisible(page: Page): Promise<void> {
const shell = page.locator("#baron-bootstrap-shell");
await expect(shell).toBeVisible({ timeout: 1_000 });
await expect(shell).toContainText(/Baron SW Portal/);
}
async function expectSigninSurfaceWithinBudget(
page: Page,
testInfo: TestInfo,
entry: SigninCase,
): Promise<void> {
await seedAuthState(page, entry);
await page.goto(entry.path, { waitUntil: "domcontentloaded" });
const slug = `${entry.path.slice(1).replace("/", "-")}-${entry.theme}`;
let paintedAtMs: number | null = null;
let previousElapsedMs = 0;
for (const elapsedMs of [500, 1000]) {
await page.waitForTimeout(elapsedMs - previousElapsedMs);
previousElapsedMs = elapsedMs;
const screenshot = await captureFlutterCanvasPng(
page,
testInfo.outputPath(
`${testInfo.project.name}-${slug}-${elapsedMs}ms.png`,
),
);
if (
paintedAtMs === null &&
screenshot !== null &&
screenshotHasSigninPaint(screenshot)
) {
paintedAtMs = elapsedMs;
}
}
expect(paintedAtMs).not.toBeNull();
expect(paintedAtMs ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(1_000);
console.log(
`[userfront-e2e] ${testInfo.project.name} ${entry.path} ${entry.theme} signin surface painted at ${paintedAtMs}ms`,
);
}
async function captureFlutterCanvasPng(
page: Page,
path?: string,
): Promise<Buffer | null> {
const dataUrl = await page.evaluate(() => {
const canvas = Array.from(document.querySelectorAll("canvas"))
.filter((candidate) => candidate.width > 0 && candidate.height > 0)
.sort((left, right) => {
return right.width * right.height - left.width * left.height;
})[0];
if (!canvas) {
return null;
}
try {
return canvas.toDataURL("image/png");
} catch {
return null;
}
});
if (dataUrl?.startsWith("data:image/png;base64,")) {
const screenshot = Buffer.from(
dataUrl.slice("data:image/png;base64,".length),
"base64",
);
if (path) {
writeFileSync(path, screenshot);
}
return screenshot;
}
try {
return await page.screenshot({
path,
fullPage: true,
timeout: 5_000,
});
} catch {
return null;
}
}
function screenshotHasSigninPaint(buffer: Buffer): boolean {
const image = decodePng(buffer);
let sampled = 0;
let nonWhite = 0;
let dark = 0;
let buttonBlue = 0;
for (let y = 0; y < image.height; y += 8) {
for (let x = 0; x < image.width; x += 8) {
const offset = (y * image.width + x) * 4;
const red = image.pixels[offset];
const green = image.pixels[offset + 1];
const blue = image.pixels[offset + 2];
const alpha = image.pixels[offset + 3];
if (alpha < 16) {
continue;
}
sampled += 1;
if (red < 245 || green < 245 || blue < 245) {
nonWhite += 1;
}
if (red < 60 && green < 80 && blue < 110) {
dark += 1;
}
if (red < 80 && green < 120 && blue > 130) {
buttonBlue += 1;
}
}
}
return (
sampled > 0 && nonWhite / sampled > 0.02 && dark > 12 && buttonBlue > 12
);
}
function decodePng(buffer: Buffer): {
width: number;
height: number;
pixels: Uint8Array;
} {
const signature = buffer.subarray(0, 8).toString("hex");
if (signature !== "89504e470d0a1a0a") {
throw new Error("invalid png signature");
}
let offset = 8;
let width = 0;
let height = 0;
let colorType = 0;
const idat: Buffer[] = [];
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.subarray(offset + 4, offset + 8).toString("ascii");
const data = buffer.subarray(offset + 8, offset + 8 + length);
offset += 12 + length;
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
colorType = data[9];
} else if (type === "IDAT") {
idat.push(data);
} else if (type === "IEND") {
break;
}
}
if (!width || !height || ![2, 6].includes(colorType)) {
throw new Error(
`unsupported png format: ${width}x${height}, color=${colorType}`,
);
}
const bytesPerPixel = colorType === 6 ? 4 : 3;
const stride = width * bytesPerPixel;
const inflated = inflateSync(Buffer.concat(idat));
const raw = new Uint8Array(height * stride);
let sourceOffset = 0;
let targetOffset = 0;
for (let y = 0; y < height; y += 1) {
const filter = inflated[sourceOffset];
sourceOffset += 1;
for (let x = 0; x < stride; x += 1) {
const value = inflated[sourceOffset + x];
const left =
x >= bytesPerPixel ? raw[targetOffset + x - bytesPerPixel] : 0;
const up = y > 0 ? raw[targetOffset + x - stride] : 0;
const upLeft =
y > 0 && x >= bytesPerPixel
? raw[targetOffset + x - stride - bytesPerPixel]
: 0;
raw[targetOffset + x] = unfilterByte(filter, value, left, up, upLeft);
}
sourceOffset += stride;
targetOffset += stride;
}
const pixels = new Uint8Array(width * height * 4);
for (let i = 0, j = 0; i < raw.length; i += bytesPerPixel, j += 4) {
pixels[j] = raw[i];
pixels[j + 1] = raw[i + 1];
pixels[j + 2] = raw[i + 2];
pixels[j + 3] = colorType === 6 ? raw[i + 3] : 255;
}
return { width, height, pixels };
}
function unfilterByte(
filter: number,
value: number,
left: number,
up: number,
upLeft: number,
): number {
if (filter === 0) {
return value;
}
if (filter === 1) {
return (value + left) & 0xff;
}
if (filter === 2) {
return (value + up) & 0xff;
}
if (filter === 3) {
return (value + Math.floor((left + up) / 2)) & 0xff;
}
if (filter === 4) {
return (value + paeth(left, up, upLeft)) & 0xff;
}
throw new Error(`unsupported png filter: ${filter}`);
}
function paeth(left: number, up: number, upLeft: number): number {
const estimate = left + up - upLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upLeftDistance = Math.abs(estimate - upLeft);
if (leftDistance <= upDistance && leftDistance <= upLeftDistance) {
return left;
}
if (upDistance <= upLeftDistance) {
return up;
}
return upLeft;
}
async function seedAuthState(page: Page, entry: SigninCase): Promise<void> {
const localeCode = entry.path.slice(1, 3);
await page.addInitScript(
({ themeValue, localeValue }) => {
window.localStorage.setItem("userfront_auth_theme", themeValue);
window.localStorage.setItem("flutter.userfront_auth_theme", themeValue);
window.localStorage.setItem("locale", localeValue);
window.localStorage.setItem("flutter.locale", localeValue);
},
{ themeValue: entry.theme, localeValue: localeCode },
);
}
test.describe("UserFront signin runtime matrix", () => {
test.beforeEach(async ({ context }) => {
await mockPublicApis(context);
await routeLightweightTestFonts(context);
});
test("first paint exposes bootstrap shell before Flutter renders", async ({
page,
}, testInfo) => {
await page.goto("/ko/signin", { waitUntil: "domcontentloaded" });
await expectBootstrapShellVisible(page);
await page.screenshot({
path: testInfo.outputPath("mobile-first-paint-ko.png"),
fullPage: true,
});
});
for (const entry of signinCases) {
test(`${entry.path} ${entry.theme} paints sign-in surface within 1 second with 0.5s captures`, async ({
page,
}, testInfo) => {
await expectSigninSurfaceWithinBudget(page, testInfo, entry);
});
}
for (const entry of signinCases) {
test(`${entry.path} renders in ${entry.theme} theme`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name === "webkit-desktop" &&
entry.path === "/en/signin",
"WebKit headless keeps /en/signin canvas blank after load; Chromium covers English rendering.",
);
await seedAuthState(page, entry);
await page.goto(entry.path, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${entry.path}(?:\\?.*)?$`));
await expectFlutterCanvasRendered(page);
});
}
test("signin uses configured BACKEND_URL for public API requests", async ({
page,
}) => {
const expectedBackendOrigin = process.env.EXPECTED_BACKEND_ORIGIN;
test.skip(!expectedBackendOrigin, "set EXPECTED_BACKEND_ORIGIN");
const requestedApiOrigins = new Set<string>();
page.on("request", (request) => {
const requestUrl = new URL(request.url());
if (requestUrl.pathname.startsWith("/api/v1/")) {
requestedApiOrigins.add(requestUrl.origin);
}
});
for (const entry of signinCases) {
await seedAuthState(page, entry);
await page.goto(entry.path);
await expectFlutterCanvasRendered(page);
await expect
.poll(() => [...requestedApiOrigins], { timeout: 30_000 })
.toContain(expectedBackendOrigin);
expect(requestedApiOrigins).not.toContain("https://sso.example.test");
}
});
test("Korean signin renders with test-only lightweight web font", async ({
context,
page,
}, testInfo) => {
if (testInfo.project.name === "webkit-desktop") {
await routeLightweightTestFonts(context);
}
const requestedUrls: string[] = [];
page.on("request", (request) => {
requestedUrls.push(request.url());
});
await seedAuthState(page, { path: "/ko/signin", theme: "light" });
await page.goto("/ko/signin", { waitUntil: "domcontentloaded" });
await expectFlutterCanvasRendered(page, 10_000);
await page.screenshot({
path: testInfo.outputPath(
`${testInfo.project.name}-ko-signin-korean-font.png`,
),
fullPage: true,
});
expect(requestedUrls).toContainEqual(
expect.stringContaining("https://fonts.gstatic.com/"),
);
expect(requestedUrls).not.toContainEqual(
expect.stringContaining("/assets/assets/fonts/NotoSansKR-Regular.ttf"),
);
});
});

View File

@@ -0,0 +1,202 @@
import { type BrowserContext, expect, type Page, test } from "@playwright/test";
const USERFRONT_BASE_URL =
process.env.USERFRONT_BASE_URL ?? "https://sso.example.test";
const ADMINFRONT_URL = process.env.ADMINFRONT_URL ?? "http://localhost:5173";
const LOGIN_ID = process.env.E2E_LOGIN_ID ?? "";
const PASSWORD = process.env.E2E_PASSWORD ?? "";
type SessionApiResponse = {
items?: Array<{
session_id?: string;
client_id?: string;
app_name?: string;
is_current?: boolean;
user_agent?: string;
ip_address?: string;
}>;
};
function ensureCredentials(): void {
if (!LOGIN_ID || !PASSWORD) {
test.skip(true, "E2E credentials are required");
}
}
async function clickPasswordTab(page: Page): Promise<void> {
await page.waitForTimeout(900);
const pane = page.locator("flt-glass-pane");
await pane.click({
position: { x: 522, y: 158 },
force: true,
});
await page.waitForTimeout(120);
await pane.click({
position: { x: 522, y: 158 },
force: true,
});
await page.waitForTimeout(200);
}
async function fillAt(
page: Page,
x: number,
y: number,
value: string,
): Promise<void> {
const pane = page.locator("flt-glass-pane");
await pane.click({ position: { x, y }, force: true });
await page.waitForTimeout(100);
await page.keyboard.press("Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(value);
}
async function loginViaUserFront(page: Page): Promise<void> {
await page.waitForURL(/\/ko\/(signin|login)/, { timeout: 30_000 });
const loginIdInput = page.getByPlaceholder(
/이메일 또는 휴대폰 번호|email|phone/i,
);
const passwordInput = page.getByPlaceholder(/비밀번호|password/i);
const submitButton = page.getByRole("button", { name: /로그인|Login/i });
if ((await loginIdInput.count()) >= 1 && (await passwordInput.count()) >= 1) {
await loginIdInput.first().fill(LOGIN_ID);
await passwordInput.first().fill(PASSWORD);
await submitButton.click();
return;
}
await clickPasswordTab(page);
await fillAt(page, 640, 245, LOGIN_ID);
await fillAt(page, 640, 311, PASSWORD);
await page.locator("flt-glass-pane").click({
position: { x: 640, y: 381 },
force: true,
});
}
async function ensureConsentIfNeeded(page: Page): Promise<void> {
if (!/\/ko\/consent/.test(page.url())) {
return;
}
const allowButton = page
.getByRole("button")
.filter({ hasText: /허용|동의|Accept|Allow/i })
.first();
if (await allowButton.count()) {
await allowButton.click({ force: true });
}
}
async function captureUserSessionsOnReload(
page: Page,
): Promise<SessionApiResponse> {
const responsePromise = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().includes("/api/v1/user/sessions"),
{ timeout: 30_000 },
);
await page.reload({ waitUntil: "domcontentloaded" });
const response = await responsePromise;
return (await response.json()) as SessionApiResponse;
}
async function loginUserFront(context: BrowserContext): Promise<Page> {
const page = await context.newPage();
await page.goto(`${USERFRONT_BASE_URL}/ko/signin`, {
waitUntil: "domcontentloaded",
});
await loginViaUserFront(page);
await expect(page).toHaveURL(/\/ko\/dashboard/, { timeout: 60_000 });
return page;
}
async function loginAdminFront(context: BrowserContext): Promise<Page> {
const page = await context.newPage();
await page.goto(ADMINFRONT_URL, { waitUntil: "domcontentloaded" });
const ssoButton = page.getByRole("button", {
name: /SSO 계정으로 로그인|SSO/i,
});
if (await ssoButton.count()) {
await ssoButton.click({ force: true });
await page.waitForTimeout(1500);
}
if (/\/login$/.test(page.url())) {
const authorizeUrl = await page.evaluate(() => {
const origin = window.location.origin;
const authority = `${USERFRONT_BASE_URL}/oidc`;
const params = new URLSearchParams({
client_id: "adminfront",
redirect_uri: `${origin}/auth/callback`,
response_type: "code",
scope: "openid offline_access profile email",
state: `pw-${Date.now()}`,
nonce: `pw-${Date.now()}`,
code_challenge: "test-code-challenge-test-code-challenge-test",
code_challenge_method: "plain",
});
return `${authority}/oauth2/auth?${params.toString()}`;
});
await page.goto(authorizeUrl, { waitUntil: "domcontentloaded" });
}
await loginViaUserFront(page);
await ensureConsentIfNeeded(page);
await page.waitForURL(
/localhost:5173|\/auth\/callback|\/dashboard|\/tenants/,
{
timeout: 60_000,
},
);
return page;
}
test.describe("cross-browser session debug", () => {
test("userfront session card should map adminfront session metadata across contexts", async ({
browser,
}, testInfo) => {
ensureCredentials();
const userfrontContext = await browser.newContext({ locale: "ko-KR" });
const adminfrontContext = await browser.newContext({ locale: "ko-KR" });
const userfrontPage = await loginUserFront(userfrontContext);
const adminfrontPage = await loginAdminFront(adminfrontContext);
const sessionsPayload = await captureUserSessionsOnReload(userfrontPage);
const items = sessionsPayload.items ?? [];
const adminfrontItems = items.filter((item) =>
(item.client_id ?? "").toLowerCase().includes("adminfront"),
);
const unknownCards = await userfrontPage
.locator("text=세션 정보")
.allTextContents();
const adminFrontCards = await userfrontPage
.locator("text=AdminFront")
.allTextContents();
await testInfo.attach("user-sessions.json", {
body: JSON.stringify(sessionsPayload, null, 2),
contentType: "application/json",
});
await testInfo.attach("card-summary.json", {
body: JSON.stringify(
{
unknownCards,
adminFrontCards,
currentUrl: userfrontPage.url(),
adminfrontUrl: adminfrontPage.url(),
},
null,
2,
),
contentType: "application/json",
});
expect(adminfrontItems.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,470 @@
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import { inflateSync } from 'node:zlib';
type ThemeCase = {
name: 'light' | 'dark';
};
const themeCases: ThemeCase[] = [
{ name: 'light' },
{ name: 'dark' },
];
type Rgb = {
r: number;
g: number;
b: number;
};
async function mockSignupApis(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const requestUrl = new URL(request.url());
const path = requestUrl.pathname;
const method = request.method().toUpperCase();
if (path.endsWith('/api/v1/user/me')) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
});
return;
}
if (path.endsWith('/api/v1/auth/password/policy')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
minLength: 12,
minCharacterTypes: 3,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
}),
});
return;
}
if (path.endsWith('/api/v1/auth/signup/check-email') && method === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ available: true }),
});
return;
}
if (
(path.endsWith('/api/v1/auth/signup/send-email-code') ||
path.endsWith('/api/v1/auth/signup/send-sms-code')) &&
method === 'POST'
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
return;
}
if (path.endsWith('/api/v1/auth/signup/verify-code') && method === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, isAffiliate: false }),
});
return;
}
if (path.endsWith('/api/v1/auth/signup') && method === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
return;
}
if (path.endsWith('/api/v1/auth/tenant-info')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
return;
}
if (path.endsWith('/api/v1/client-log')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
}
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole('button', { name: 'Enable accessibility' });
const placeholder = page.locator('flt-semantics-placeholder').first();
await button.click({ force: true, timeout: 1_000 }).catch(async () => {
await placeholder.click({ force: true, timeout: 1_000 }).catch(async () => {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
});
});
});
await page.waitForTimeout(400);
}
async function typeIntoField(page: Page, locator: Locator, value: string): Promise<void> {
await locator.scrollIntoViewIfNeeded();
await page.waitForTimeout(100);
await locator.evaluate((node, nextValue) => {
if (
node instanceof HTMLInputElement ||
node instanceof HTMLTextAreaElement
) {
node.focus();
node.value = '';
node.dispatchEvent(new Event('input', { bubbles: true }));
node.value = nextValue;
node.dispatchEvent(new Event('input', { bubbles: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
}
}, value).catch(() => {});
const box = await locator.boundingBox();
if (!box) {
throw new Error('Field locator is not visible for typing.');
}
await page.locator('flt-glass-pane').click({
position: {
x: box.x + box.width / 2,
y: box.y + box.height / 2,
},
force: true,
});
await page.waitForTimeout(100);
await page.keyboard.press('Control+A');
await page.keyboard.press('Backspace');
await page.keyboard.type(value);
await page.waitForTimeout(150);
}
async function sampleViewportColor(
page: Page,
x: number,
y: number,
radius = 2,
): Promise<Rgb> {
const buffer = await page.screenshot();
const image = decodePng(buffer);
const clampedX = Math.max(0, Math.min(image.width - 1, Math.round(x)));
const clampedY = Math.max(0, Math.min(image.height - 1, Math.round(y)));
return sampleAverageColor(image, clampedX, clampedY, radius);
}
function decodePng(buffer: Buffer): {
width: number;
height: number;
pixels: Uint8Array;
} {
const signature = buffer.subarray(0, 8).toString('hex');
if (signature !== '89504e470d0a1a0a') {
throw new Error('Invalid PNG signature');
}
let offset = 8;
let width = 0;
let height = 0;
let colorType = 0;
const idatChunks: Buffer[] = [];
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii');
const data = buffer.subarray(offset + 8, offset + 8 + length);
offset += 12 + length;
if (type === 'IHDR') {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
colorType = data[9];
} else if (type === 'IDAT') {
idatChunks.push(data);
} else if (type === 'IEND') {
break;
}
}
if (!width || !height || ![2, 6].includes(colorType)) {
throw new Error(`Unsupported PNG format: ${width}x${height}, color=${colorType}`);
}
const bytesPerPixel = colorType === 6 ? 4 : 3;
const stride = width * bytesPerPixel;
const inflated = inflateSync(Buffer.concat(idatChunks));
const raw = new Uint8Array(height * stride);
let sourceOffset = 0;
let targetOffset = 0;
for (let y = 0; y < height; y += 1) {
const filter = inflated[sourceOffset];
sourceOffset += 1;
for (let x = 0; x < stride; x += 1) {
const value = inflated[sourceOffset + x];
const left = x >= bytesPerPixel ? raw[targetOffset + x - bytesPerPixel] : 0;
const up = y > 0 ? raw[targetOffset + x - stride] : 0;
const upLeft =
y > 0 && x >= bytesPerPixel
? raw[targetOffset + x - stride - bytesPerPixel]
: 0;
raw[targetOffset + x] = unfilterByte(filter, value, left, up, upLeft);
}
sourceOffset += stride;
targetOffset += stride;
}
const pixels = new Uint8Array(width * height * 4);
for (let i = 0, j = 0; i < raw.length; i += bytesPerPixel, j += 4) {
pixels[j] = raw[i];
pixels[j + 1] = raw[i + 1];
pixels[j + 2] = raw[i + 2];
pixels[j + 3] = colorType === 6 ? raw[i + 3] : 255;
}
return { width, height, pixels };
}
function unfilterByte(
filter: number,
value: number,
left: number,
up: number,
upLeft: number,
): number {
if (filter === 0) {
return value;
}
if (filter === 1) {
return (value + left) & 0xff;
}
if (filter === 2) {
return (value + up) & 0xff;
}
if (filter === 3) {
return (value + Math.floor((left + up) / 2)) & 0xff;
}
if (filter === 4) {
return (value + paeth(left, up, upLeft)) & 0xff;
}
throw new Error(`Unsupported PNG filter: ${filter}`);
}
function paeth(left: number, up: number, upLeft: number): number {
const estimate = left + up - upLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upLeftDistance = Math.abs(estimate - upLeft);
if (leftDistance <= upDistance && leftDistance <= upLeftDistance) {
return left;
}
if (upDistance <= upLeftDistance) {
return up;
}
return upLeft;
}
function sampleAverageColor(
image: { width: number; height: number; pixels: Uint8Array },
x: number,
y: number,
radius = 2,
): Rgb {
const xStart = Math.max(0, Math.min(image.width - 1, x - radius));
const xEnd = Math.max(0, Math.min(image.width - 1, x + radius));
const yStart = Math.max(0, Math.min(image.height - 1, y - radius));
const yEnd = Math.max(0, Math.min(image.height - 1, y + radius));
let totalR = 0;
let totalG = 0;
let totalB = 0;
let count = 0;
for (let sampleY = yStart; sampleY <= yEnd; sampleY += 1) {
for (let sampleX = xStart; sampleX <= xEnd; sampleX += 1) {
const offset = (sampleY * image.width + sampleX) * 4;
const alpha = image.pixels[offset + 3];
if (alpha < 16) {
continue;
}
totalR += image.pixels[offset];
totalG += image.pixels[offset + 1];
totalB += image.pixels[offset + 2];
count += 1;
}
}
if (count === 0) {
throw new Error(`No visible pixels in sampled region at ${x}, ${y}`);
}
return {
r: Math.round(totalR / count),
g: Math.round(totalG / count),
b: Math.round(totalB / count),
};
}
function brightness(rgb: Rgb): number {
return (rgb.r + rgb.g + rgb.b) / 3;
}
async function sampleLocatorColor(page: Page, locator: Locator, radius = 2): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Target locator is not visible for color sampling.');
}
return sampleViewportColor(page, box.x + box.width / 2, box.y + box.height / 2, radius);
}
async function sampleCheckboxColor(page: Page, locator: Locator): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Checkbox locator is not visible for color sampling.');
}
const x = box.x + Math.min(18, Math.max(12, box.width * 0.08));
const y = box.y + box.height / 2;
return sampleViewportColor(page, x, y, 0);
}
async function sampleButtonColor(page: Page, locator: Locator): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Button locator is not visible for color sampling.');
}
const x = box.x + box.width * 0.2;
const y = box.y + box.height / 2;
return sampleViewportColor(page, x, y, 1);
}
async function sampleButtonBackground(page: Page, locator: Locator): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Button locator is not visible for background sampling.');
}
const x = box.x + box.width / 2;
const y = Math.max(0, box.y - 14);
return sampleViewportColor(page, x, y, 2);
}
async function expectBrightnessContrast(
sample: () => Promise<{ foreground: Rgb; background: Rgb }>,
minimumDelta: number,
): Promise<void> {
await expect
.poll(async () => {
const { foreground, background } = await sample();
return Math.abs(brightness(foreground) - brightness(background));
}, { timeout: 10_000 })
.toBeGreaterThanOrEqual(minimumDelta);
}
async function expectButtonContrast(page: Page, locator: Locator): Promise<void> {
await expectBrightnessContrast(async () => {
return {
foreground: await sampleButtonColor(page, locator),
background: await sampleButtonBackground(page, locator),
};
}, 45);
}
async function sampleCheckboxBackground(page: Page, locator: Locator): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Checkbox locator is not visible for background sampling.');
}
const x = box.x + Math.min(42, Math.max(30, box.width * 0.18));
const y = box.y + box.height / 2;
return sampleViewportColor(page, x, y, 1);
}
async function expectCheckboxContrast(page: Page, locator: Locator): Promise<void> {
await expectBrightnessContrast(async () => {
return {
foreground: await sampleCheckboxColor(page, locator),
background: await sampleCheckboxBackground(page, locator),
};
}, 40);
}
test.describe('UserFront signup theme visibility', () => {
for (const theme of themeCases) {
test(`signup keeps ${theme.name} theme colors visible across steps`, async ({
page,
}) => {
await mockSignupApis(page);
if (theme.name === 'dark') {
await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
await enableFlutterAccessibility(page);
const themeToggle = page.getByRole('button', {
name: /Light|Dark|테마 전환|Theme toggle/i,
});
await themeToggle.click({ force: true });
await page.waitForTimeout(500);
}
await page.goto('/ko/signup', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
await enableFlutterAccessibility(page);
const allAgreementCheckbox = page.getByRole('checkbox', {
name: /모두 동의합니다|Agree to all/i,
});
await expect(allAgreementCheckbox).toBeVisible();
await allAgreementCheckbox.click({ force: true });
await expect(allAgreementCheckbox).toBeChecked();
const nextButton = page.getByRole('button', { name: /다음 단계|Next/i });
await expect(nextButton).toBeVisible();
await expect(nextButton).toBeEnabled();
await nextButton.click({ force: true });
await expect(
page.getByText(/본인 확인을 위해|Verify your email and phone number/i),
).toBeVisible();
const emailInput = page.getByRole('textbox', {
name: /이메일 주소|Email address/i,
});
const phoneInput = page.getByRole('textbox', {
name: /휴대폰 번호|Phone number/i,
});
const requestButtons = page
.getByRole('button')
.filter({ hasText: /인증요청|재발송|Send code|Resend/i });
await expect(emailInput).toBeVisible();
await expect(phoneInput).toBeVisible();
await expect(requestButtons.nth(0)).toBeVisible();
await expect(requestButtons.nth(1)).toBeVisible();
await expect(nextButton).toBeVisible();
});
}
});