forked from baron/baron-sso
ci: add code check badges and coverage reports
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { expect, type Page, type Route, test } from "@playwright/test";
|
||||
|
||||
type MockOptions = {
|
||||
sessionStatus?: number;
|
||||
@@ -9,23 +9,23 @@ type MockOptions = {
|
||||
|
||||
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');
|
||||
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');
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,29 +35,29 @@ async function mockUserfrontApis(
|
||||
): Promise<void> {
|
||||
const sessionStatus = options.sessionStatus ?? 200;
|
||||
|
||||
await page.context().route('**/api/v1/**', async (route: Route) => {
|
||||
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')) {
|
||||
if (path.endsWith("/api/v1/user/me")) {
|
||||
options.captureUserMe?.();
|
||||
if (sessionStatus === 200) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: 'e2e-user',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
phone: '+821012341234',
|
||||
department: 'QA',
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
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',
|
||||
id: "tenant-1",
|
||||
name: "Baron",
|
||||
slug: "baron",
|
||||
description: "E2E tenant",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -66,32 +66,32 @@ async function mockUserfrontApis(
|
||||
|
||||
await route.fulfill({
|
||||
status: sessionStatus,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/rp/linked')) {
|
||||
if (path.endsWith("/api/v1/user/rp/linked")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/audit/auth/timeline')) {
|
||||
if (path.endsWith("/api/v1/audit/auth/timeline")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], next_cursor: '' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_cursor: "" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/qr/approve')) {
|
||||
if (route.request().method() == 'POST') {
|
||||
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 {
|
||||
@@ -100,44 +100,55 @@ async function mockUserfrontApis(
|
||||
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);
|
||||
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`);
|
||||
console.log(
|
||||
`[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`,
|
||||
);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
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')
|
||||
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>;
|
||||
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' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "approved",
|
||||
pendingRef: "e2e-approved",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
@@ -145,15 +156,15 @@ async function mockUserfrontApis(
|
||||
|
||||
function collectClientFailures(page: Page): string[] {
|
||||
const failures: string[] = [];
|
||||
page.on('pageerror', (error) => {
|
||||
page.on("pageerror", (error) => {
|
||||
failures.push(error.message);
|
||||
});
|
||||
page.on('console', (message) => {
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (
|
||||
message.type() === 'error' ||
|
||||
message.type() === "error" ||
|
||||
(/exception|verify_failed|verification failed|인증 실패/i.test(text) &&
|
||||
!text.includes('Exception while loading service worker'))
|
||||
!text.includes("Exception while loading service worker"))
|
||||
) {
|
||||
failures.push(text);
|
||||
}
|
||||
@@ -164,14 +175,14 @@ function collectClientFailures(page: Page): string[] {
|
||||
async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.close = () => {
|
||||
window.location.href = '/';
|
||||
window.location.href = "/";
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(300);
|
||||
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||
const button = page.getByRole("button", { name: "Enable accessibility" });
|
||||
if (await button.count()) {
|
||||
await button.first().evaluate((node) => {
|
||||
(node as HTMLElement).click();
|
||||
@@ -179,7 +190,7 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(200);
|
||||
return;
|
||||
}
|
||||
const placeholder = page.locator('flt-semantics-placeholder').first();
|
||||
const placeholder = page.locator("flt-semantics-placeholder").first();
|
||||
if (await placeholder.count()) {
|
||||
await placeholder.evaluate((node) => {
|
||||
(node as HTMLElement).click();
|
||||
@@ -188,47 +199,51 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('UserFront WASM auth routing', () => {
|
||||
test('비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다', async ({ page }) => {
|
||||
test.describe("UserFront WASM auth routing", () => {
|
||||
test("비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockUserfrontApis(page, { sessionStatus: 401 });
|
||||
|
||||
await page.goto('/ko');
|
||||
await page.goto("/ko");
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test('로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다', async ({
|
||||
test("로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedTokenLogin(page);
|
||||
await mockUserfrontApis(page);
|
||||
|
||||
await page.goto('/ko');
|
||||
await page.goto("/ko");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test('sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다', async ({
|
||||
test("sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedSessionTokenLogin(page);
|
||||
await mockUserfrontApis(page);
|
||||
|
||||
await page.goto('/ko');
|
||||
await page.goto("/ko");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test('비로그인 /ko/approve 는 signin(+notice)으로 이동한다', async ({ page }) => {
|
||||
test("비로그인 /ko/approve 는 signin(+notice)으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockUserfrontApis(page, { sessionStatus: 401 });
|
||||
|
||||
await page.goto('/ko/approve?ref=e2e-ref');
|
||||
await page.goto("/ko/approve?ref=e2e-ref");
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
|
||||
});
|
||||
|
||||
test('로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다', async ({
|
||||
test("로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let approvedRef: string | null = null;
|
||||
@@ -240,15 +255,15 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/ko/approve?ref=e2e-approve-ref');
|
||||
await page.goto("/ko/approve?ref=e2e-approve-ref");
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard(?:\?.*)?$/, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(approvedRef).toBe('e2e-approve-ref');
|
||||
expect(approvedRef).toBe("e2e-approve-ref");
|
||||
});
|
||||
|
||||
test('verifyOnly 승인 완료 화면의 상단 액션은 signin으로 이동시키지 않는다', async ({
|
||||
test("verifyOnly 승인 완료 화면의 상단 액션은 signin으로 이동시키지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
@@ -269,20 +284,20 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto('/ko/l/AB123456');
|
||||
await page.goto("/ko/l/AB123456");
|
||||
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||
expect(userMeCalls).toBe(0);
|
||||
expect(verifyRequests[0].path).toContain(
|
||||
'/api/v1/auth/login/code/verify-short',
|
||||
"/api/v1/auth/login/code/verify-short",
|
||||
);
|
||||
expect(verifyRequests[0].body).toMatchObject({
|
||||
shortCode: 'AB123456',
|
||||
shortCode: "AB123456",
|
||||
verifyOnly: true,
|
||||
});
|
||||
|
||||
await page.locator('flt-glass-pane').click({
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: { x: 30, y: 28 },
|
||||
force: true,
|
||||
});
|
||||
@@ -293,7 +308,7 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
|
||||
test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
@@ -311,25 +326,25 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto('/ko/l/AB123456');
|
||||
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();
|
||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||
|
||||
expect(userMeCalls).toBe(0);
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
expect(
|
||||
clientFailures.filter(
|
||||
(failure) => !failure.includes('401 (Unauthorized)'),
|
||||
(failure) => !failure.includes("401 (Unauthorized)"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다', async ({
|
||||
test("verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let verifyCalls = 0;
|
||||
@@ -343,26 +358,26 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto('/ko/l/AB123456');
|
||||
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('요청하신 로그인이 완료되었습니다'),
|
||||
page.getByText("요청하신 로그인이 완료되었습니다"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '창 닫기' })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "창 닫기" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: '로그인 창으로 이동하기' }),
|
||||
page.getByRole("button", { name: "로그인 창으로 이동하기" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
|
||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다', async ({
|
||||
test("루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
@@ -383,26 +398,26 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
'/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop',
|
||||
"/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop",
|
||||
);
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||
expect(userMeCalls).toBe(0);
|
||||
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
|
||||
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',
|
||||
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(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 ({
|
||||
test("로그인 페이지에 붙은 인증 payload도 전용 verify 라우트로 넘긴다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
@@ -422,26 +437,26 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/ko/signin?loginId=e2e%40example.com&code=999999');
|
||||
await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999");
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||
expect(userMeCalls).toBe(0);
|
||||
expect(verifyRequests[0].body).toMatchObject({
|
||||
loginId: 'e2e@example.com',
|
||||
code: '999999',
|
||||
loginId: "e2e@example.com",
|
||||
code: "999999",
|
||||
verifyOnly: true,
|
||||
});
|
||||
expect(page.url()).not.toContain('loginId=');
|
||||
expect(page.url()).not.toContain('code=');
|
||||
expect(page.url()).not.toContain("loginId=");
|
||||
expect(page.url()).not.toContain("code=");
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다', async ({
|
||||
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.',
|
||||
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;
|
||||
@@ -458,16 +473,16 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
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');
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.evaluate((url) => {
|
||||
window.open(url, '_blank');
|
||||
window.open(url, "_blank");
|
||||
}, popupURL);
|
||||
const popup = await popupPromise;
|
||||
|
||||
@@ -477,10 +492,10 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
|
||||
if (!popup.isClosed()) {
|
||||
await enableFlutterAccessibility(popup);
|
||||
const closePromise = popup.waitForEvent('close').catch(() => undefined);
|
||||
const closePromise = popup.waitForEvent("close").catch(() => undefined);
|
||||
try {
|
||||
await popup
|
||||
.getByRole('button', { name: '로그인 창으로 이동하기' })
|
||||
.getByRole("button", { name: "로그인 창으로 이동하기" })
|
||||
.click();
|
||||
} catch (error) {
|
||||
if (!popup.isClosed()) {
|
||||
@@ -495,7 +510,7 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
|
||||
test("verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
@@ -516,26 +531,26 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto('/ko/verify/e2e-email-token');
|
||||
await page.goto("/ko/verify/e2e-email-token");
|
||||
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||
expect(userMeCalls).toBe(0);
|
||||
expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify');
|
||||
expect(verifyRequests[0].path).toContain("/api/v1/auth/magic-link/verify");
|
||||
expect(verifyRequests[0].body).toMatchObject({
|
||||
token: 'e2e-email-token',
|
||||
token: "e2e-email-token",
|
||||
verifyOnly: true,
|
||||
});
|
||||
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
|
||||
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 ({
|
||||
test("verifyOnly 승인 완료 버튼은 이메일 code link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
@@ -557,22 +572,22 @@ test.describe('UserFront WASM auth routing', () => {
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto(
|
||||
'/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email',
|
||||
"/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email",
|
||||
);
|
||||
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||
expect(userMeCalls).toBe(0);
|
||||
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
|
||||
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',
|
||||
loginId: "e2e@example.com",
|
||||
code: "654321",
|
||||
pendingRef: "pending-email",
|
||||
verifyOnly: true,
|
||||
});
|
||||
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
|
||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||
|
||||
expect(userMeCalls).toBe(0);
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
devices,
|
||||
expect,
|
||||
test,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
} from '@playwright/test';
|
||||
test,
|
||||
} from "@playwright/test";
|
||||
|
||||
type LoadMetrics = {
|
||||
appOrigin: string;
|
||||
@@ -18,20 +18,20 @@ type LoadMetrics = {
|
||||
};
|
||||
|
||||
async function mockPublicApis(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
if (requestUrl.pathname.endsWith('/api/v1/user/me')) {
|
||||
if (requestUrl.pathname.endsWith("/api/v1/user/me")) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ async function mockPublicApis(page: Page): Promise<void> {
|
||||
|
||||
async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
|
||||
const appOrigin = new URL(
|
||||
process.env.BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? '4173'}`,
|
||||
process.env.BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? "4173"}`,
|
||||
).origin;
|
||||
const requestedUrls: string[] = [];
|
||||
const requestedPathCounts = new Map<string, number>();
|
||||
@@ -50,7 +50,7 @@ async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
|
||||
const onRequest = (request: Request) => {
|
||||
const requestUrl = new URL(request.url());
|
||||
requestedUrls.push(request.url());
|
||||
if (requestUrl.protocol === 'http:' || requestUrl.protocol === 'https:') {
|
||||
if (requestUrl.protocol === "http:" || requestUrl.protocol === "https:") {
|
||||
const resourceKey = `${requestUrl.origin}${requestUrl.pathname}`;
|
||||
requestedPathCounts.set(
|
||||
resourceKey,
|
||||
@@ -61,28 +61,31 @@ async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
|
||||
|
||||
const onResponse = async (response: Response) => {
|
||||
const url = new URL(response.url());
|
||||
const cacheControl = response.headers()['cache-control'];
|
||||
const cacheControl = response.headers()["cache-control"];
|
||||
if (cacheControl) {
|
||||
cacheControlByPath.set(url.pathname, cacheControl);
|
||||
}
|
||||
const contentEncoding = response.headers()['content-encoding'];
|
||||
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);
|
||||
const sizes = await response
|
||||
.request()
|
||||
.sizes()
|
||||
.catch(() => null);
|
||||
transferredBytes += sizes?.responseBodySize ?? 0;
|
||||
}
|
||||
};
|
||||
|
||||
page.on('request', onRequest);
|
||||
page.on('response', onResponse);
|
||||
page.on("request", onRequest);
|
||||
page.on("response", onResponse);
|
||||
|
||||
try {
|
||||
const start = performance.now();
|
||||
await page.goto('/ko/signin', { waitUntil: 'networkidle' });
|
||||
await page.goto("/ko/signin", { waitUntil: "networkidle" });
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
|
||||
@@ -96,8 +99,8 @@ async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
|
||||
contentEncodingByPath,
|
||||
};
|
||||
} finally {
|
||||
page.off('request', onRequest);
|
||||
page.off('response', onResponse);
|
||||
page.off("request", onRequest);
|
||||
page.off("response", onResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,13 +112,13 @@ function expectNoDuplicateStaticRequests(metrics: LoadMetrics): void {
|
||||
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')
|
||||
!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")
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -126,41 +129,41 @@ function resolvePerformanceBudget(projectName: string): {
|
||||
coldMs: number;
|
||||
warmMs: number;
|
||||
} {
|
||||
if (projectName.includes('webkit')) {
|
||||
if (projectName.includes("webkit")) {
|
||||
return { coldMs: 4000, warmMs: 4000 };
|
||||
}
|
||||
if (projectName.includes('firefox')) {
|
||||
if (projectName.includes("firefox")) {
|
||||
return { coldMs: 2600, warmMs: 2800 };
|
||||
}
|
||||
if (projectName.includes('mobile')) {
|
||||
if (projectName.includes("mobile")) {
|
||||
return { coldMs: 3000, warmMs: 2300 };
|
||||
}
|
||||
return { coldMs: 2300, warmMs: 1500 };
|
||||
}
|
||||
|
||||
function resolveRootRedirectBudget(projectName: string): number {
|
||||
if (projectName.includes('webkit')) {
|
||||
if (projectName.includes("webkit")) {
|
||||
return 700;
|
||||
}
|
||||
if (projectName.includes('firefox')) {
|
||||
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 ({
|
||||
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',
|
||||
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',
|
||||
...devices["Pixel 7"],
|
||||
locale: "ko-KR",
|
||||
serviceWorkers: "allow",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await mockPublicApis(page);
|
||||
@@ -168,22 +171,23 @@ test.describe('UserFront login performance budget', () => {
|
||||
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'}`,
|
||||
"/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.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 ({
|
||||
test("warm login page load stays within the platform budget and reuses cached assets", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockPublicApis(page);
|
||||
@@ -209,14 +213,14 @@ test.describe('UserFront login performance budget', () => {
|
||||
...warm.contentEncodingByPath,
|
||||
]);
|
||||
|
||||
const appShellCache = cacheControlByPath.get('/ko/signin') ?? '';
|
||||
expect(appShellCache).toContain('no-cache');
|
||||
const appShellCache = cacheControlByPath.get("/ko/signin") ?? "";
|
||||
expect(appShellCache).toContain("no-cache");
|
||||
const serviceWorkerState = await page.evaluate(async () => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return {
|
||||
available: false,
|
||||
secure: window.isSecureContext,
|
||||
scriptUrl: '',
|
||||
scriptUrl: "",
|
||||
};
|
||||
}
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
@@ -225,43 +229,48 @@ test.describe('UserFront login performance budget', () => {
|
||||
available: true,
|
||||
secure: window.isSecureContext,
|
||||
count: registrations.length,
|
||||
controller: navigator.serviceWorker.controller?.scriptURL ?? '',
|
||||
controller: navigator.serviceWorker.controller?.scriptURL ?? "",
|
||||
scriptUrl:
|
||||
registration?.active?.scriptURL ??
|
||||
registration?.waiting?.scriptURL ??
|
||||
registration?.installing?.scriptURL ??
|
||||
'',
|
||||
"",
|
||||
};
|
||||
});
|
||||
if (testInfo.project.name.includes('mobile') && serviceWorkerState.scriptUrl) {
|
||||
if (
|
||||
testInfo.project.name.includes("mobile") &&
|
||||
serviceWorkerState.scriptUrl
|
||||
) {
|
||||
expect(new URL(serviceWorkerState.scriptUrl).pathname).toBe(
|
||||
'/flutter_service_worker.js',
|
||||
"/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',
|
||||
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(serviceWorkerState.scriptUrl).toBe("");
|
||||
}
|
||||
|
||||
expect(cold.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('root redirects to localized signin before Flutter boots', async ({
|
||||
test("root redirects to localized signin before Flutter boots", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockPublicApis(page);
|
||||
|
||||
const requestedUrls: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
page.on("request", (request) => {
|
||||
requestedUrls.push(request.url());
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
|
||||
@@ -269,10 +278,10 @@ test.describe('UserFront login performance budget', () => {
|
||||
resolveRootRedirectBudget(testInfo.project.name),
|
||||
);
|
||||
const rootIndex = requestedUrls.findIndex(
|
||||
(url) => new URL(url).pathname === '/',
|
||||
(url) => new URL(url).pathname === "/",
|
||||
);
|
||||
const bootstrapIndex = requestedUrls.findIndex((url) =>
|
||||
new URL(url).pathname.endsWith('/flutter_bootstrap.js'),
|
||||
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
|
||||
);
|
||||
expect(rootIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(bootstrapIndex).toBeGreaterThan(rootIndex);
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
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) => {
|
||||
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')) {
|
||||
if (path.endsWith("/api/v1/user/me")) {
|
||||
await route.fulfill({
|
||||
status: options.sessionStatus,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/client-log')) {
|
||||
if (path.endsWith("/api/v1/client-log")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
@@ -29,23 +29,25 @@ async function mockUserfrontApisForRepro(
|
||||
// Default mock for other APIs
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Issue #345 Reproduction (Log-based Validation)', () => {
|
||||
test('비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다', async ({ page }) => {
|
||||
test.describe("Issue #345 Reproduction (Log-based Validation)", () => {
|
||||
test("비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const logs: string[] = [];
|
||||
page.on('console', msg => {
|
||||
page.on("console", (msg) => {
|
||||
const text = msg.text();
|
||||
logs.push(text);
|
||||
console.log(`[Browser] ${text}`);
|
||||
});
|
||||
|
||||
const requests: string[] = [];
|
||||
page.on('request', request => {
|
||||
page.on("request", (request) => {
|
||||
if (request.isNavigationRequest()) {
|
||||
requests.push(request.url());
|
||||
}
|
||||
@@ -53,29 +55,31 @@ test.describe('Issue #345 Reproduction (Log-based Validation)', () => {
|
||||
|
||||
await mockUserfrontApisForRepro(page, { sessionStatus: 401 });
|
||||
|
||||
const targetUrl = '/ko/signin?login_challenge=repro_challenge_12345';
|
||||
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'));
|
||||
const signinNavigations = requests.filter((url) => url.includes("/signin"));
|
||||
|
||||
// [검증 1] URL 유지 확인
|
||||
expect(currentUrl).toContain('login_challenge=repro_challenge_12345');
|
||||
|
||||
expect(currentUrl).toContain("login_challenge=repro_challenge_12345");
|
||||
|
||||
// [검증 2] 리다이렉트 루프 발생 여부 확인 (최초 진입 1회만 있어야 함)
|
||||
expect(signinNavigations.length).toBeLessThanOrEqual(1);
|
||||
|
||||
// [검증 3] 핵심 로직 로그 확인 (성공의 결정적 증거)
|
||||
// 이전에는 여기서 Exception이 발생했으나, 이제는 아래 로그가 찍혀야 함
|
||||
const hasSuccessLog = logs.some(log =>
|
||||
log.includes('[Auth] OIDC auto-accept: No active session (status: 401)')
|
||||
const hasSuccessLog = logs.some((log) =>
|
||||
log.includes("[Auth] OIDC auto-accept: No active session (status: 401)"),
|
||||
);
|
||||
|
||||
|
||||
expect(hasSuccessLog).toBe(true);
|
||||
|
||||
console.log('✅ 루프가 해결되었으며, 로그 검증을 통해 정상 동작을 확인했습니다.');
|
||||
|
||||
console.log(
|
||||
"✅ 루프가 해결되었으며, 로그 검증을 통해 정상 동작을 확인했습니다.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Route,
|
||||
test,
|
||||
} from "@playwright/test";
|
||||
|
||||
type RequestCapture = {
|
||||
loginBody?: Record<string, unknown>;
|
||||
@@ -7,13 +13,14 @@ type RequestCapture = {
|
||||
clientLogs: string[];
|
||||
};
|
||||
|
||||
const resetNewPasswordName = /^(새 비밀번호|ui\.userfront\.reset\.new_password)$/;
|
||||
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' });
|
||||
const button = page.getByRole("button", { name: "Enable accessibility" });
|
||||
if (await button.count()) {
|
||||
await button.first().evaluate((node) => {
|
||||
(node as HTMLElement).click();
|
||||
@@ -22,7 +29,7 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
const placeholder = page.locator('flt-semantics-placeholder').first();
|
||||
const placeholder = page.locator("flt-semantics-placeholder").first();
|
||||
if (await placeholder.count()) {
|
||||
await placeholder.evaluate((node) => {
|
||||
(node as HTMLElement).click();
|
||||
@@ -98,7 +105,7 @@ async function clickPasswordTab(page: Page): Promise<void> {
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.waitForTimeout(900);
|
||||
const pane = page.locator('flt-glass-pane');
|
||||
const pane = page.locator("flt-glass-pane");
|
||||
await pane.click({
|
||||
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||
force: true,
|
||||
@@ -111,12 +118,17 @@ async function clickPasswordTab(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function fillAt(page: Page, x: number, y: number, value: string): Promise<void> {
|
||||
const pane = page.locator('flt-glass-pane');
|
||||
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.press("Control+A");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(value);
|
||||
}
|
||||
|
||||
@@ -127,8 +139,8 @@ async function typeIntoAccessibleField(
|
||||
): 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.press("Control+A");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(value);
|
||||
}
|
||||
|
||||
@@ -139,7 +151,7 @@ async function fillPasswordLoginForm(
|
||||
): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await enableFlutterAccessibility(page);
|
||||
const inputs = page.getByRole('textbox');
|
||||
const inputs = page.getByRole("textbox");
|
||||
await inputs.nth(0).fill(loginId);
|
||||
await inputs.nth(1).fill(password);
|
||||
return;
|
||||
@@ -152,32 +164,47 @@ async function fillPasswordLoginForm(
|
||||
async function submitPasswordLogin(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.getByRole('button', { name: '로그인' }).click({ force: true });
|
||||
await page.getByRole("button", { name: "로그인" }).click({ force: true });
|
||||
return;
|
||||
}
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
async function fillResetPasswordForm(page: Page, password: string): Promise<void> {
|
||||
async function fillResetPasswordForm(
|
||||
page: Page,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
await enableFlutterAccessibility(page);
|
||||
const newPasswordInput = page.getByRole('textbox', {
|
||||
const newPasswordInput = page.getByRole("textbox", {
|
||||
name: resetNewPasswordName,
|
||||
});
|
||||
const confirmPasswordInput = page.getByRole('textbox', {
|
||||
const confirmPasswordInput = page.getByRole("textbox", {
|
||||
name: resetConfirmPasswordName,
|
||||
});
|
||||
if ((await newPasswordInput.count()) > 0 && (await confirmPasswordInput.count()) > 0) {
|
||||
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);
|
||||
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.resetNewPasswordX,
|
||||
coords.resetNewPasswordY,
|
||||
password,
|
||||
);
|
||||
await fillAt(
|
||||
page,
|
||||
coords.resetConfirmPasswordX,
|
||||
@@ -188,7 +215,9 @@ async function fillResetPasswordForm(page: Page, password: string): Promise<void
|
||||
|
||||
async function submitResetPassword(page: Page): Promise<void> {
|
||||
await enableFlutterAccessibility(page);
|
||||
const submitButton = page.getByRole('button', { name: resetSubmitButtonName });
|
||||
const submitButton = page.getByRole("button", {
|
||||
name: resetSubmitButtonName,
|
||||
});
|
||||
if ((await submitButton.count()) > 0) {
|
||||
await submitButton.click({ force: true });
|
||||
return;
|
||||
@@ -197,32 +226,35 @@ async function submitResetPassword(page: Page): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
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) => {
|
||||
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')) {
|
||||
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!') {
|
||||
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',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionJwt: 'e30.e30.e30',
|
||||
provider: 'ory',
|
||||
sessionJwt: "e30.e30.e30",
|
||||
provider: "ory",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
@@ -230,16 +262,16 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
|
||||
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'password_or_email_mismatch' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "password_or_email_mismatch" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/password/policy')) {
|
||||
if (path.endsWith("/api/v1/auth/password/policy")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
minLength: 12,
|
||||
minCharacterTypes: 3,
|
||||
@@ -252,21 +284,21 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/password/reset/complete')) {
|
||||
if (path.endsWith("/api/v1/auth/password/reset/complete")) {
|
||||
capture.resetBody = (route.request().postDataJSON() ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
capture.resetToken = requestUrl.searchParams.get('token');
|
||||
capture.resetToken = requestUrl.searchParams.get("token");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "ok" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/client-log')) {
|
||||
if (path.endsWith("/api/v1/client-log")) {
|
||||
const payload = (route.request().postDataJSON() ?? {}) as {
|
||||
message?: string;
|
||||
};
|
||||
@@ -275,108 +307,112 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
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 ')) {
|
||||
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' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: 'e2e-user',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
phone: '+821012341234',
|
||||
department: 'QA',
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
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',
|
||||
id: "tenant-1",
|
||||
name: "Baron",
|
||||
slug: "baron",
|
||||
description: "E2E tenant",
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/rp/linked')) {
|
||||
if (path.endsWith("/api/v1/user/rp/linked")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/audit/auth/timeline')) {
|
||||
if (path.endsWith("/api/v1/audit/auth/timeline")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], next_cursor: '' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_cursor: "" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
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.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.',
|
||||
"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 page.goto("/ko/signin");
|
||||
await clickPasswordTab(page);
|
||||
await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!');
|
||||
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!');
|
||||
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'),
|
||||
window.localStorage.getItem("baron_auth_token"),
|
||||
);
|
||||
expect(storedToken).toBe('e30.e30.e30');
|
||||
expect(storedToken).toBe("e30.e30.e30");
|
||||
});
|
||||
|
||||
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
|
||||
test("비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
isMobileProject(page),
|
||||
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
||||
"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 page.goto("/ko/signin");
|
||||
await clickPasswordTab(page);
|
||||
await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!');
|
||||
await fillPasswordLoginForm(page, "e2e@example.com", "WrongPass1!");
|
||||
await submitPasswordLogin(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
@@ -384,36 +420,37 @@ test.describe('UserFront WASM password login and reset', () => {
|
||||
.poll(
|
||||
() =>
|
||||
capture.clientLogs.some((message) =>
|
||||
message.includes('password_or_email_mismatch'),
|
||||
message.includes("password_or_email_mismatch"),
|
||||
),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('reset-password에서 변경 성공 시 signin으로 이동한다', async ({ page }) => {
|
||||
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.url().includes("/api/v1/auth/password/policy") &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await page.goto('/ko/reset-password?token=reset-token-e2e');
|
||||
await page.goto("/ko/reset-password?token=reset-token-e2e");
|
||||
await policyLoaded;
|
||||
await page.waitForTimeout(900);
|
||||
await fillResetPasswordForm(page, 'ValidPass1!A');
|
||||
await fillResetPasswordForm(page, "ValidPass1!A");
|
||||
await submitResetPassword(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => capture.resetBody?.newPassword as string | undefined,
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe('ValidPass1!A');
|
||||
.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');
|
||||
expect(capture.resetToken).toBe("reset-token-e2e");
|
||||
expect(capture.resetBody?.newPassword).toBe("ValidPass1!A");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { expect, type Page, type Route, test } from "@playwright/test";
|
||||
|
||||
type ProfileState = {
|
||||
department: string;
|
||||
@@ -7,7 +7,7 @@ type ProfileState = {
|
||||
};
|
||||
|
||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||
const button = page.getByRole("button", { name: "Enable accessibility" });
|
||||
if (await button.count()) {
|
||||
await button.click({ force: true }).catch(async () => {
|
||||
await page
|
||||
@@ -59,26 +59,31 @@ function isMobileProject(page: Page): boolean {
|
||||
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
await page.keyboard.press("End");
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
if (value !== '') {
|
||||
if (value !== "") {
|
||||
await page.keyboard.insertText(value);
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
@@ -89,8 +94,12 @@ type BoxCenter = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
async function resolveLocatorCenter(locator: ReturnType<Page['locator']>): Promise<BoxCenter | null> {
|
||||
const handle = await locator.elementHandle({ timeout: 1_000 }).catch(() => null);
|
||||
async function resolveLocatorCenter(
|
||||
locator: ReturnType<Page["locator"]>,
|
||||
): Promise<BoxCenter | null> {
|
||||
const handle = await locator
|
||||
.elementHandle({ timeout: 1_000 })
|
||||
.catch(() => null);
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
@@ -115,11 +124,14 @@ async function resolveLocatorCenter(locator: ReturnType<Page['locator']>): Promi
|
||||
};
|
||||
}
|
||||
|
||||
async function clickGlassPaneAt(page: Page, center: BoxCenter | null): Promise<boolean> {
|
||||
async function clickGlassPaneAt(
|
||||
page: Page,
|
||||
center: BoxCenter | null,
|
||||
): Promise<boolean> {
|
||||
if (!center) {
|
||||
return false;
|
||||
}
|
||||
await page.locator('flt-glass-pane').click({
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: center,
|
||||
force: true,
|
||||
});
|
||||
@@ -128,22 +140,25 @@ async function clickGlassPaneAt(page: Page, center: BoxCenter | null): Promise<b
|
||||
}
|
||||
|
||||
async function departmentTextboxIsOpen(page: Page): Promise<boolean> {
|
||||
return (await page.getByRole('textbox', { name: '소속' }).count()) > 0;
|
||||
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: '소속' });
|
||||
.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 })
|
||||
.evaluate(
|
||||
(element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.click();
|
||||
}
|
||||
},
|
||||
{ timeout: 1_000 },
|
||||
)
|
||||
.catch(() => undefined);
|
||||
await page.waitForTimeout(200);
|
||||
if (await departmentTextboxIsOpen(page)) {
|
||||
@@ -153,14 +168,16 @@ async function openDepartmentEditor(page: Page): Promise<void> {
|
||||
if (await departmentTextboxIsOpen(page)) {
|
||||
return;
|
||||
}
|
||||
await accessibleEditor.click({ force: true, timeout: 1_000 }).catch(() => undefined);
|
||||
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.');
|
||||
throw new Error("Department editor accessibility button was not found.");
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
const viewport = page.viewportSize();
|
||||
@@ -180,17 +197,17 @@ async function openDepartmentEditor(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||
const textbox = page.getByRole('textbox', { name: '소속' });
|
||||
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.');
|
||||
throw new Error("Department textbox was not found.");
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: { x: coords.blurX, y: coords.blurY },
|
||||
force: true,
|
||||
});
|
||||
@@ -198,21 +215,21 @@ async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function submitDepartmentEditor(page: Page): Promise<void> {
|
||||
const textbox = page.getByRole('textbox', { name: '소속' });
|
||||
const textbox = page.getByRole("textbox", { name: "소속" });
|
||||
if ((await textbox.count()) > 0) {
|
||||
await textbox.press('Enter');
|
||||
await textbox.press("Enter");
|
||||
await page.waitForTimeout(250);
|
||||
return;
|
||||
}
|
||||
if (isMobileProject(page)) {
|
||||
throw new Error('Department textbox was not found.');
|
||||
throw new Error("Department textbox was not found.");
|
||||
}
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function fillDepartmentField(page: Page, value: string): Promise<void> {
|
||||
const textbox = page.getByRole('textbox', { name: '소속' });
|
||||
const textbox = page.getByRole("textbox", { name: "소속" });
|
||||
if (!isMobileProject(page)) {
|
||||
if ((await textbox.count()) > 0) {
|
||||
await textbox.click({ force: true });
|
||||
@@ -230,92 +247,92 @@ async function fillDepartmentField(page: Page, value: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (isMobileProject(page)) {
|
||||
throw new Error('Department textbox was not found.');
|
||||
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) => {
|
||||
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 ')) {
|
||||
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' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.getMeCount += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: 'e2e-user',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
phone: '+821012341234',
|
||||
id: "e2e-user",
|
||||
email: "e2e@example.com",
|
||||
name: "E2E User",
|
||||
phone: "+821012341234",
|
||||
department: state.department,
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
affiliationType: "employee",
|
||||
companyCode: "BARON",
|
||||
tenant: {
|
||||
id: 'tenant-1',
|
||||
name: 'Baron',
|
||||
slug: 'baron',
|
||||
description: 'E2E tenant',
|
||||
id: "tenant-1",
|
||||
name: "Baron",
|
||||
slug: "baron",
|
||||
description: "E2E tenant",
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/me') && method === 'PUT') {
|
||||
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 !== '') {
|
||||
const nextDepartment = String(body.department ?? "").trim();
|
||||
if (nextDepartment !== "") {
|
||||
state.department = nextDepartment;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: 'success',
|
||||
updatedAt: '2026-02-24T00:00:00Z',
|
||||
status: "success",
|
||||
updatedAt: "2026-02-24T00:00:00Z",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/rp/linked')) {
|
||||
if (path.endsWith("/api/v1/user/rp/linked")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/audit/auth/timeline')) {
|
||||
if (path.endsWith("/api/v1/audit/auth/timeline")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], next_cursor: '' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_cursor: "" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/client-log')) {
|
||||
if (path.endsWith("/api/v1/client-log")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
@@ -323,14 +340,14 @@ async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProfilePage(page: Page): Promise<void> {
|
||||
await page.goto('/ko/profile');
|
||||
await page.goto("/ko/profile");
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.waitForTimeout(1200);
|
||||
@@ -340,22 +357,22 @@ 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.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.',
|
||||
({ 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/**');
|
||||
await page.unroute("**/api/v1/**");
|
||||
});
|
||||
|
||||
test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
|
||||
test("소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
@@ -365,24 +382,26 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, 'QA-Updated');
|
||||
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');
|
||||
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);
|
||||
await expect
|
||||
.poll(() => state.getMeCount)
|
||||
.toBeGreaterThan(getCountBeforeReload);
|
||||
});
|
||||
|
||||
test('소속 입력 후 즉시 새로고침해도 저장 요청이 중복 전송되지 않는다', async ({
|
||||
test("소속 입력 후 즉시 새로고침해도 저장 요청이 중복 전송되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
@@ -392,24 +411,24 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, 'QA-Repro');
|
||||
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');
|
||||
expect(state.putBodies[0]?.department).toBe("QA-Repro");
|
||||
expect(state.department).toBe("QA-Repro");
|
||||
return;
|
||||
}
|
||||
expect(state.department).toBe('QA');
|
||||
expect(state.department).toBe("QA");
|
||||
});
|
||||
|
||||
test('소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다', async ({
|
||||
test("소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
@@ -419,15 +438,17 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, 'QA');
|
||||
await fillDepartmentField(page, "QA");
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다', async ({ page }) => {
|
||||
test("소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
@@ -437,16 +458,18 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, '');
|
||||
await fillDepartmentField(page, "");
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
expect(state.department).toBe('QA');
|
||||
expect(state.department).toBe("QA");
|
||||
});
|
||||
|
||||
test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => {
|
||||
test("소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
@@ -456,7 +479,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, 'QA-1');
|
||||
await fillDepartmentField(page, "QA-1");
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
|
||||
@@ -464,16 +487,18 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
await enableFlutterAccessibility(page);
|
||||
await expect.poll(() => state.getMeCount).toBeGreaterThan(getCountBeforeReload);
|
||||
await expect
|
||||
.poll(() => state.getMeCount)
|
||||
.toBeGreaterThan(getCountBeforeReload);
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, 'QA-2');
|
||||
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');
|
||||
expect(state.putBodies[0]?.department).toBe("QA-1");
|
||||
expect(state.putBodies[1]?.department).toBe("QA-2");
|
||||
expect(state.department).toBe("QA-2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
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');
|
||||
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) => {
|
||||
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 ')) {
|
||||
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',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: 'e2e-user',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
phone: '+821012341234',
|
||||
department: 'QA',
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
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',
|
||||
id: "tenant-1",
|
||||
name: "Baron",
|
||||
slug: "baron",
|
||||
description: "E2E tenant",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -42,34 +42,34 @@ async function mockInventoryApis(page: Page): Promise<void> {
|
||||
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/rp/linked')) {
|
||||
if (path.endsWith("/api/v1/user/rp/linked")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/audit/auth/timeline')) {
|
||||
if (path.endsWith("/api/v1/audit/auth/timeline")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], next_cursor: '' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_cursor: "" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/password/policy')) {
|
||||
if (path.endsWith("/api/v1/auth/password/policy")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
minLength: 12,
|
||||
minCharacterTypes: 3,
|
||||
@@ -82,46 +82,46 @@ async function mockInventoryApis(page: Page): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/magic-link/verify')) {
|
||||
if (path.endsWith("/api/v1/auth/magic-link/verify")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/login/code/verify')) {
|
||||
if (path.endsWith("/api/v1/auth/login/code/verify")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/login/code/verify-short')) {
|
||||
if (path.endsWith("/api/v1/auth/login/code/verify-short")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/consent') && method === 'GET') {
|
||||
if (path.endsWith("/api/v1/auth/consent") && method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
client: {
|
||||
client_name: 'E2E Client',
|
||||
client_id: 'e2e-client',
|
||||
client_name: "E2E Client",
|
||||
client_id: "e2e-client",
|
||||
},
|
||||
requested_scope: ['openid'],
|
||||
requested_scope: ["openid"],
|
||||
scope_details: {
|
||||
openid: {
|
||||
description: 'OpenID',
|
||||
description: "OpenID",
|
||||
mandatory: true,
|
||||
},
|
||||
},
|
||||
@@ -130,19 +130,19 @@ async function mockInventoryApis(page: Page): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/qr/approve')) {
|
||||
if (path.endsWith("/api/v1/auth/qr/approve")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/client-log')) {
|
||||
if (path.endsWith("/api/v1/client-log")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
@@ -150,182 +150,186 @@ async function mockInventoryApis(page: Page): Promise<void> {
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('UserFront WASM route inventory (unauth)', () => {
|
||||
test.describe("UserFront WASM route inventory (unauth)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockInventoryApis(page);
|
||||
});
|
||||
|
||||
test('route: /', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
test("route: /", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/(ko|en)\/signin(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test('route: /ko', async ({ page }) => {
|
||||
await page.goto('/ko');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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/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');
|
||||
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');
|
||||
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');
|
||||
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/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');
|
||||
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');
|
||||
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.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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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 ({
|
||||
test("route: /ko/approve?ref=... -> /ko/dashboard", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.goto('/ko/approve?ref=e2e-ref');
|
||||
await page.goto("/ko/approve?ref=e2e-ref");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/, {
|
||||
timeout: testInfo.project.name === 'webkit-desktop' ? 15_000 : 5_000,
|
||||
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');
|
||||
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,
|
||||
timeout: testInfo.project.name === "webkit-desktop" ? 15_000 : 5_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type BrowserContext,
|
||||
expect,
|
||||
type Page,
|
||||
type TestInfo,
|
||||
} from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
test,
|
||||
} from "@playwright/test";
|
||||
|
||||
const lightweightTestFont = readFileSync(
|
||||
new URL('../fixtures/fonts/NotoSansKR-TestSubset.woff2', import.meta.url),
|
||||
new URL("../fixtures/fonts/NotoSansKR-TestSubset.woff2", import.meta.url),
|
||||
);
|
||||
|
||||
type SigninCase = {
|
||||
path: '/ko/signin' | '/en/signin';
|
||||
theme: 'light' | 'dark';
|
||||
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' },
|
||||
{ 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')) {
|
||||
if (requestUrl.pathname.endsWith("/api/v1/user/me")) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname.endsWith('/api/v1/auth/tenant-info')) {
|
||||
if (requestUrl.pathname.endsWith("/api/v1/auth/tenant-info")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return;
|
||||
@@ -47,21 +47,23 @@ async function mockPublicApis(context: BrowserContext): Promise<void> {
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function routeLightweightTestFonts(context: BrowserContext): Promise<void> {
|
||||
await context.route('https://fonts.gstatic.com/**', async (route) => {
|
||||
async function routeLightweightTestFonts(
|
||||
context: BrowserContext,
|
||||
): Promise<void> {
|
||||
await context.route("https://fonts.gstatic.com/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'font/woff2',
|
||||
contentType: "font/woff2",
|
||||
body: lightweightTestFont,
|
||||
headers: {
|
||||
'access-control-allow-origin': '*',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -71,21 +73,26 @@ async function expectFlutterCanvasRendered(
|
||||
page: Page,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<void> {
|
||||
await expect(page.locator('#baron-bootstrap-shell')).toBeHidden({
|
||||
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,
|
||||
})
|
||||
.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');
|
||||
const shell = page.locator("#baron-bootstrap-shell");
|
||||
await expect(shell).toBeVisible({ timeout: 1_000 });
|
||||
await expect(shell).toContainText(/Baron SW Portal/);
|
||||
}
|
||||
@@ -96,9 +103,9 @@ async function expectSigninSurfaceWithinBudget(
|
||||
entry: SigninCase,
|
||||
): Promise<void> {
|
||||
await seedAuthState(page, entry);
|
||||
await page.goto(entry.path, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(entry.path, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const slug = `${entry.path.slice(1).replace('/', '-')}-${entry.theme}`;
|
||||
const slug = `${entry.path.slice(1).replace("/", "-")}-${entry.theme}`;
|
||||
let paintedAtMs: number | null = null;
|
||||
let previousElapsedMs = 0;
|
||||
for (const elapsedMs of [500, 1000]) {
|
||||
@@ -106,7 +113,9 @@ async function expectSigninSurfaceWithinBudget(
|
||||
previousElapsedMs = elapsedMs;
|
||||
const screenshot = await captureFlutterCanvasPng(
|
||||
page,
|
||||
testInfo.outputPath(`${testInfo.project.name}-${slug}-${elapsedMs}ms.png`),
|
||||
testInfo.outputPath(
|
||||
`${testInfo.project.name}-${slug}-${elapsedMs}ms.png`,
|
||||
),
|
||||
);
|
||||
if (
|
||||
paintedAtMs === null &&
|
||||
@@ -129,7 +138,7 @@ async function captureFlutterCanvasPng(
|
||||
path?: string,
|
||||
): Promise<Buffer | null> {
|
||||
const dataUrl = await page.evaluate(() => {
|
||||
const canvas = Array.from(document.querySelectorAll('canvas'))
|
||||
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;
|
||||
@@ -138,16 +147,16 @@ async function captureFlutterCanvasPng(
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return canvas.toDataURL('image/png');
|
||||
return canvas.toDataURL("image/png");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if (dataUrl?.startsWith('data:image/png;base64,')) {
|
||||
if (dataUrl?.startsWith("data:image/png;base64,")) {
|
||||
const screenshot = Buffer.from(
|
||||
dataUrl.slice('data:image/png;base64,'.length),
|
||||
'base64',
|
||||
dataUrl.slice("data:image/png;base64,".length),
|
||||
"base64",
|
||||
);
|
||||
if (path) {
|
||||
writeFileSync(path, screenshot);
|
||||
@@ -197,7 +206,9 @@ function screenshotHasSigninPaint(buffer: Buffer): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
return sampled > 0 && nonWhite / sampled > 0.02 && dark > 12 && buttonBlue > 12;
|
||||
return (
|
||||
sampled > 0 && nonWhite / sampled > 0.02 && dark > 12 && buttonBlue > 12
|
||||
);
|
||||
}
|
||||
|
||||
function decodePng(buffer: Buffer): {
|
||||
@@ -205,9 +216,9 @@ function decodePng(buffer: Buffer): {
|
||||
height: number;
|
||||
pixels: Uint8Array;
|
||||
} {
|
||||
const signature = buffer.subarray(0, 8).toString('hex');
|
||||
if (signature !== '89504e470d0a1a0a') {
|
||||
throw new Error('invalid png signature');
|
||||
const signature = buffer.subarray(0, 8).toString("hex");
|
||||
if (signature !== "89504e470d0a1a0a") {
|
||||
throw new Error("invalid png signature");
|
||||
}
|
||||
|
||||
let offset = 8;
|
||||
@@ -218,23 +229,25 @@ function decodePng(buffer: Buffer): {
|
||||
|
||||
while (offset < buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii');
|
||||
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') {
|
||||
if (type === "IHDR") {
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
colorType = data[9];
|
||||
} else if (type === 'IDAT') {
|
||||
} else if (type === "IDAT") {
|
||||
idat.push(data);
|
||||
} else if (type === 'IEND') {
|
||||
} else if (type === "IEND") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!width || !height || ![2, 6].includes(colorType)) {
|
||||
throw new Error(`unsupported png format: ${width}x${height}, color=${colorType}`);
|
||||
throw new Error(
|
||||
`unsupported png format: ${width}x${height}, color=${colorType}`,
|
||||
);
|
||||
}
|
||||
|
||||
const bytesPerPixel = colorType === 6 ? 4 : 3;
|
||||
@@ -249,7 +262,8 @@ function decodePng(buffer: Buffer): {
|
||||
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 left =
|
||||
x >= bytesPerPixel ? raw[targetOffset + x - bytesPerPixel] : 0;
|
||||
const up = y > 0 ? raw[targetOffset + x - stride] : 0;
|
||||
const upLeft =
|
||||
y > 0 && x >= bytesPerPixel
|
||||
@@ -313,27 +327,30 @@ function paeth(left: number, up: number, upLeft: number): number {
|
||||
|
||||
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 });
|
||||
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.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 ({
|
||||
test("first paint exposes bootstrap shell before Flutter renders", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' });
|
||||
await page.goto("/ko/signin", { waitUntil: "domcontentloaded" });
|
||||
await expectBootstrapShellVisible(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('mobile-first-paint-ko.png'),
|
||||
path: testInfo.outputPath("mobile-first-paint-ko.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
@@ -351,26 +368,27 @@ test.describe('UserFront signin runtime matrix', () => {
|
||||
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.',
|
||||
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 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 ({
|
||||
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');
|
||||
test.skip(!expectedBackendOrigin, "set EXPECTED_BACKEND_ORIGIN");
|
||||
|
||||
const requestedApiOrigins = new Set<string>();
|
||||
page.on('request', (request) => {
|
||||
page.on("request", (request) => {
|
||||
const requestUrl = new URL(request.url());
|
||||
if (requestUrl.pathname.startsWith('/api/v1/')) {
|
||||
if (requestUrl.pathname.startsWith("/api/v1/")) {
|
||||
requestedApiOrigins.add(requestUrl.origin);
|
||||
}
|
||||
});
|
||||
@@ -382,35 +400,37 @@ test.describe('UserFront signin runtime matrix', () => {
|
||||
await expect
|
||||
.poll(() => [...requestedApiOrigins], { timeout: 30_000 })
|
||||
.toContain(expectedBackendOrigin);
|
||||
expect(requestedApiOrigins).not.toContain('https://sso.example.test');
|
||||
expect(requestedApiOrigins).not.toContain("https://sso.example.test");
|
||||
}
|
||||
});
|
||||
|
||||
test('Korean signin renders with test-only lightweight web font', async ({
|
||||
test("Korean signin renders with test-only lightweight web font", async ({
|
||||
context,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
if (testInfo.project.name === 'webkit-desktop') {
|
||||
if (testInfo.project.name === "webkit-desktop") {
|
||||
await routeLightweightTestFonts(context);
|
||||
}
|
||||
const requestedUrls: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
page.on("request", (request) => {
|
||||
requestedUrls.push(request.url());
|
||||
});
|
||||
|
||||
await seedAuthState(page, { path: '/ko/signin', theme: 'light' });
|
||||
await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' });
|
||||
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`),
|
||||
path: testInfo.outputPath(
|
||||
`${testInfo.project.name}-ko-signin-korean-font.png`,
|
||||
),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
expect(requestedUrls).toContainEqual(
|
||||
expect.stringContaining('https://fonts.gstatic.com/'),
|
||||
expect.stringContaining("https://fonts.gstatic.com/"),
|
||||
);
|
||||
expect(requestedUrls).not.toContainEqual(
|
||||
expect.stringContaining('/assets/assets/fonts/NotoSansKR-Regular.ttf'),
|
||||
expect.stringContaining("/assets/assets/fonts/NotoSansKR-Regular.ttf"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { expect, test, type BrowserContext, type Page } from '@playwright/test';
|
||||
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 ?? '';
|
||||
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<{
|
||||
@@ -18,20 +19,20 @@ type SessionApiResponse = {
|
||||
|
||||
function ensureCredentials(): void {
|
||||
if (!LOGIN_ID || !PASSWORD) {
|
||||
test.skip(true, 'E2E credentials are required');
|
||||
test.skip(true, "E2E credentials are required");
|
||||
}
|
||||
}
|
||||
|
||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(300);
|
||||
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||
const button = page.getByRole("button", { name: "Enable accessibility" });
|
||||
if (await button.count()) {
|
||||
try {
|
||||
await button.click({ force: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const placeholder = page.locator('flt-semantics-placeholder');
|
||||
const placeholder = page.locator("flt-semantics-placeholder");
|
||||
if (await placeholder.count()) {
|
||||
await placeholder.first().click({ force: true });
|
||||
}
|
||||
@@ -41,7 +42,7 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
|
||||
async function clickPasswordTab(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(900);
|
||||
const pane = page.locator('flt-glass-pane');
|
||||
const pane = page.locator("flt-glass-pane");
|
||||
await pane.click({
|
||||
position: { x: 522, y: 158 },
|
||||
force: true,
|
||||
@@ -54,20 +55,27 @@ async function clickPasswordTab(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function fillAt(page: Page, x: number, y: number, value: string): Promise<void> {
|
||||
const pane = page.locator('flt-glass-pane');
|
||||
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.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 loginIdInput = page.getByPlaceholder(
|
||||
/이메일 또는 휴대폰 번호|email|phone/i,
|
||||
);
|
||||
const passwordInput = page.getByPlaceholder(/비밀번호|password/i);
|
||||
const submitButton = page.getByRole('button', { name: /로그인|Login/i });
|
||||
const submitButton = page.getByRole("button", { name: /로그인|Login/i });
|
||||
|
||||
if ((await loginIdInput.count()) >= 1 && (await passwordInput.count()) >= 1) {
|
||||
await loginIdInput.first().fill(LOGIN_ID);
|
||||
@@ -79,7 +87,7 @@ async function loginViaUserFront(page: Page): Promise<void> {
|
||||
await clickPasswordTab(page);
|
||||
await fillAt(page, 640, 245, LOGIN_ID);
|
||||
await fillAt(page, 640, 311, PASSWORD);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: { x: 640, y: 381 },
|
||||
force: true,
|
||||
});
|
||||
@@ -91,7 +99,7 @@ async function ensureConsentIfNeeded(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
const allowButton = page
|
||||
.getByRole('button')
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /허용|동의|Accept|Allow/i })
|
||||
.first();
|
||||
|
||||
@@ -100,15 +108,17 @@ async function ensureConsentIfNeeded(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function captureUserSessionsOnReload(page: Page): Promise<SessionApiResponse> {
|
||||
async function captureUserSessionsOnReload(
|
||||
page: Page,
|
||||
): Promise<SessionApiResponse> {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'GET' &&
|
||||
response.url().includes('/api/v1/user/sessions'),
|
||||
response.request().method() === "GET" &&
|
||||
response.url().includes("/api/v1/user/sessions"),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
const response = await responsePromise;
|
||||
return (await response.json()) as SessionApiResponse;
|
||||
}
|
||||
@@ -116,7 +126,7 @@ async function captureUserSessionsOnReload(page: Page): Promise<SessionApiRespon
|
||||
async function loginUserFront(context: BrowserContext): Promise<Page> {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${USERFRONT_BASE_URL}/ko/signin`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await loginViaUserFront(page);
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard/, { timeout: 60_000 });
|
||||
@@ -125,8 +135,10 @@ async function loginUserFront(context: BrowserContext): Promise<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 });
|
||||
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);
|
||||
@@ -136,35 +148,38 @@ async function loginAdminFront(context: BrowserContext): Promise<Page> {
|
||||
const origin = window.location.origin;
|
||||
const authority = `${USERFRONT_BASE_URL}/oidc`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: 'adminfront',
|
||||
client_id: "adminfront",
|
||||
redirect_uri: `${origin}/auth/callback`,
|
||||
response_type: 'code',
|
||||
scope: 'openid offline_access profile email',
|
||||
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',
|
||||
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 page.goto(authorizeUrl, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
await loginViaUserFront(page);
|
||||
await ensureConsentIfNeeded(page);
|
||||
await page.waitForURL(/localhost:5173|\/auth\/callback|\/dashboard|\/tenants/, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
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 ({
|
||||
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 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);
|
||||
@@ -172,16 +187,20 @@ test.describe('cross-browser session debug', () => {
|
||||
const sessionsPayload = await captureUserSessionsOnReload(userfrontPage);
|
||||
const items = sessionsPayload.items ?? [];
|
||||
const adminfrontItems = items.filter((item) =>
|
||||
(item.client_id ?? '').toLowerCase().includes('adminfront'),
|
||||
(item.client_id ?? "").toLowerCase().includes("adminfront"),
|
||||
);
|
||||
const unknownCards = await userfrontPage.locator('text=세션 정보').allTextContents();
|
||||
const adminFrontCards = await userfrontPage.locator('text=AdminFront').allTextContents();
|
||||
const unknownCards = await userfrontPage
|
||||
.locator("text=세션 정보")
|
||||
.allTextContents();
|
||||
const adminFrontCards = await userfrontPage
|
||||
.locator("text=AdminFront")
|
||||
.allTextContents();
|
||||
|
||||
await testInfo.attach('user-sessions.json', {
|
||||
await testInfo.attach("user-sessions.json", {
|
||||
body: JSON.stringify(sessionsPayload, null, 2),
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach('card-summary.json', {
|
||||
await testInfo.attach("card-summary.json", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
unknownCards,
|
||||
@@ -192,7 +211,7 @@ test.describe('cross-browser session debug', () => {
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: 'application/json',
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
expect(adminfrontItems.length).toBeGreaterThan(0);
|
||||
|
||||
Reference in New Issue
Block a user