1
0
forked from baron/baron-sso
Files
baron-sso/userfront-e2e/tests/profile-department.spec.ts
2026-05-29 08:19:34 +09:00

480 lines
14 KiB
TypeScript

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