Fix prelaunch navigation and log scale regressions

This commit is contained in:
2026-05-12 00:28:38 +08:00
parent 7c2f073cbf
commit 77d096cdc9
11 changed files with 670 additions and 259 deletions

View File

@@ -138,6 +138,14 @@ const CDP_CONNECT_TIMEOUT_MS = Number(process.env.E2E_CDP_CONNECT_TIMEOUT_MS ??
const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim()
const REFRESH_TOKEN_COOKIE_NAME = 'ums_refresh_token'
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
const SIDEBAR_GROUP_TEST_IDS = new Map([
[TEXT.accessControl, 'nav-group-access-control'],
])
const SIDEBAR_MENU_TEST_IDS = new Map([
[TEXT.dashboard, 'nav-dashboard'],
[TEXT.users, 'nav-users'],
[TEXT.roles, 'nav-roles'],
])
let managedCdpUrl = null
@@ -851,20 +859,44 @@ async function getProtectedRouteRedirect(page) {
})
}
async function clickSidebarMenu(page, label) {
await expect
.poll(async () => await page.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item').count())
.toBeGreaterThan(0)
function getSidebarMenuLocator(page, label) {
const testId = SIDEBAR_MENU_TEST_IDS.get(label)
if (testId) {
return page.locator(`.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`)
}
const menuItems = page
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item')
return page
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item')
.filter({ hasText: label })
}
function getSidebarGroupLocator(page, label) {
const testId = SIDEBAR_GROUP_TEST_IDS.get(label)
if (testId) {
return page.locator(
`.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`,
)
}
return page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title')
.filter({ hasText: label })
}
async function clickSidebarMenu(page, label) {
const menuItems = getSidebarMenuLocator(page, label)
await expect.poll(async () => await menuItems.count()).toBeGreaterThan(0)
const count = await menuItems.count()
for (let index = 0; index < count; index += 1) {
const menuItem = menuItems.nth(index)
if (await menuItem.isVisible()) {
await forceClick(menuItem)
try {
await menuItem.scrollIntoViewIfNeeded()
await menuItem.click({ force: true, timeout: 5_000 })
} catch {
await forceClick(menuItem)
}
return
}
}
@@ -878,30 +910,21 @@ async function openMobileNavigationIfNeeded(page) {
return false
}
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
const mobileMenuButton = page.getByTestId('mobile-nav-trigger')
if (!(await mobileMenuButton.isVisible().catch(() => false))) {
return false
}
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('.ant-drawer.ant-drawer-open .ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
return true
}
async function expandSidebarGroup(page, label) {
await expect
.poll(async () => {
return await page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.count()
})
.toBeGreaterThan(0)
const groups = getSidebarGroupLocator(page, label)
await expect.poll(async () => await groups.count()).toBeGreaterThan(0)
const findVisibleGroup = async () => {
const groups = page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.filter({ hasText: label })
const count = await groups.count()
for (let index = 0; index < count; index += 1) {
const group = groups.nth(index)
@@ -920,7 +943,24 @@ async function expandSidebarGroup(page, label) {
}
if (group) {
await forceClick(group)
const isExpanded = await group.evaluate((element) => {
return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false
})
if (!isExpanded) {
try {
await group.scrollIntoViewIfNeeded()
await group.click({ force: true, timeout: 5_000 })
} catch {
await forceClick(group)
}
await expect.poll(async () => {
return await group.evaluate((element) => {
return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false
})
}).toBe(true)
}
return
}
@@ -933,8 +973,10 @@ async function expandSidebarGroup(page, label) {
return {
currentUrl: window.location.href,
innerWidth: window.innerWidth,
submenuTitles: visibleText('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title'),
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item'),
submenuTitles: visibleText(
'.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title',
),
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item'),
}
})
@@ -1230,17 +1272,17 @@ async function loginFromLoginPage(page) {
async function createUserFromUsersPage(page, username, password = 'Batch123!@#') {
const email = `${username}@example.com`
const createUserButton = page.getByRole('button', { name: TEXT.createUser }).first()
const createUserModal = page.locator('.ant-modal').last()
const createUserRow = page.locator('tbody tr').filter({ hasText: username }).first()
logDebug(`createUserFromUsersPage: open modal for ${username}`)
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0, { timeout: 20 * 1000 })
await forceClick(createUserButton)
await expect(page.locator('.ant-modal-title')).toContainText(TEXT.createUser)
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
logDebug(`createUserFromUsersPage: modal visible for ${username}`)
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
})
logDebug(`createUserFromUsersPage: fill username for ${username}`)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(),
@@ -1256,13 +1298,83 @@ async function createUserFromUsersPage(page, username, password = 'Batch123!@#')
createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(),
email,
)
logDebug(`createUserFromUsersPage: submit modal for ${username}`)
await forceClick(createUserModal.locator('.ant-btn-primary').last())
const submitButton = createUserModal.getByRole('button', { name: TEXT.createUser }).last()
const submitStrategies = [
async () => {
await forceClick(submitButton)
},
async () => {
await submitButton.evaluate((element) => {
if (!(element instanceof HTMLButtonElement) && !(element instanceof HTMLElement)) {
throw new Error('Create user submit target is not clickable.')
}
element.click()
})
},
async () => {
await forceClick(submitButton)
},
]
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, `create user ${username}`)
logDebug(`createUserFromUsersPage: response ok for ${username}`)
await expect(page.locator('tbody tr').filter({ hasText: username }).first()).toBeVisible({ timeout: 20 * 1000 })
let createUserResponseResult = { error: new Error('create user request was not attempted') }
for (let index = 0; index < submitStrategies.length; index += 1) {
logDebug(`createUserFromUsersPage: submit modal for ${username} attempt ${index + 1}`)
const responsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
}, { timeout: 8 * 1000 })
await submitStrategies[index]()
createUserResponseResult = await responsePromise
if (createUserResponseResult.response) {
await assertApiSuccessResponse(createUserResponseResult.response, `create user ${username}`)
logDebug(`createUserFromUsersPage: response ok for ${username}`)
break
}
const rowVisibleAfterSubmit = await createUserRow.isVisible().catch(() => false)
if (rowVisibleAfterSubmit) {
logDebug(`createUserFromUsersPage: row became visible without captured response for ${username}`)
break
}
logDebug(`createUserFromUsersPage: submit attempt ${index + 1} did not complete for ${username}`)
}
try {
await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 })
} catch (rowError) {
if (!createUserResponseResult.error) {
throw rowError
}
const diagnostics = await page.evaluate(() => {
const visibleText = (selector) => Array.from(document.querySelectorAll(selector))
.filter((element) => element instanceof HTMLElement && element.offsetParent !== null)
.map((element) => (element.textContent ?? '').trim())
.filter(Boolean)
return {
currentUrl: window.location.href,
modalText: visibleText('.ant-modal'),
formErrors: visibleText('.ant-form-item-explain-error'),
toastMessages: visibleText('.ant-message .ant-message-notice-content'),
primaryButtons: visibleText('.ant-modal .ant-btn-primary'),
}
})
throw new Error(
`create user ${username} did not complete. responseError=${formatError(createUserResponseResult.error)} diagnostics=${JSON.stringify(diagnostics)}`,
)
}
if (createUserResponseResult.error) {
logDebug(`createUserFromUsersPage: row visible without captured response for ${username}`)
}
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 })
logDebug(`createUserFromUsersPage: row visible for ${username}`)
return { email, password, username }
@@ -1867,15 +1979,16 @@ async function verifyDesktopAndMobileNavigation(page) {
.toBe(true)
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
await expect
.poll(async () => await page.locator('.ant-layout-header .ant-btn').count())
.poll(async () => await page.getByTestId('mobile-nav-trigger').count())
.toBeGreaterThan(0)
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
const mobileMenuButton = page.getByTestId('mobile-nav-trigger')
await expect(mobileMenuButton).toBeVisible()
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
const mobileDashboardItem = page.locator('.ant-drawer .ant-menu-item').filter({ hasText: TEXT.dashboard }).first()
const openDrawer = page.locator('.ant-drawer.ant-drawer-open')
await expect(openDrawer.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
const mobileDashboardItem = openDrawer.getByTestId('nav-dashboard').first()
await expect(mobileDashboardItem).toBeVisible()
await forceClick(mobileDashboardItem)
await expect(page).toHaveURL(/\/dashboard$/)
@@ -1887,8 +2000,7 @@ async function verifyUserManagementCRUD(page) {
logDebug('verifyUserManagementCRUD: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.accessControl)
await clickSidebarMenu(page, TEXT.users)
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
const testUsername = `e2e_crud_${Date.now()}`
@@ -1917,12 +2029,14 @@ async function verifyUserManagementCRUD(page) {
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, 'create user CRUD')
await page.goto(appUrl('/users'))
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
const userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
let userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await forceClick(userRow.getByRole('button', { name: TEXT.edit }))
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.editUser }).last()
await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
const editDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.editUser }).last()
await expect(editDrawerTitle).toBeVisible({ timeout: 10 * 1000 })
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').last()
const editResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'PUT'
@@ -1931,10 +2045,13 @@ async function verifyUserManagementCRUD(page) {
const editResponse = await resolveWaitForResponse(editResponsePromise)
await assertApiSuccessResponse(editResponse, 'edit user CRUD')
await page.goto(appUrl('/users'))
userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await expect(userRow).toBeVisible({ timeout: 20 * 1000 })
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
const detailDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.userDetail }).last()
await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
await expect(detailDrawer).toContainText(testUsername)
const detailDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.userDetail }).last()
await expect(detailDrawerTitle).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('.ant-drawer')).toContainText(testUsername)
await page.goto(appUrl('/users'))
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), testUsername)
@@ -2193,12 +2310,12 @@ async function verifyUserManagementBatch(page) {
await selectUserRow(page, batchUserB)
await forceClick(page.getByRole('button', { name: TEXT.batchDelete }))
const batchDeletePopover = page.locator('.ant-popconfirm').last()
await expect(batchDeletePopover).toBeVisible({ timeout: 10 * 1000 })
const batchDeleteModal = page.locator('.ant-modal').last()
await expect(batchDeleteModal).toBeVisible({ timeout: 10 * 1000 })
const batchDeleteResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users/batch') && response.request().method() === 'DELETE'
})
await forceClick(batchDeletePopover.locator('.ant-btn-primary').last())
await forceClick(batchDeleteModal.locator('.ant-btn-primary').last())
const batchDeleteResponse = await resolveWaitForResponse(batchDeleteResponsePromise)
await assertApiSuccessResponse(batchDeleteResponse, 'batch delete users')
@@ -2439,7 +2556,10 @@ async function verifyProfileAndSecurity(page) {
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.enableTOTP }).first())
const setupModal = page.locator('.ant-modal').last()
const setupModalRoot = page.locator('.ant-modal-root').filter({
has: page.getByRole('button', { name: TEXT.confirmEnableTOTP }),
}).last()
const setupModal = setupModalRoot.locator('.ant-modal').first()
await expect(setupModal).toBeVisible({ timeout: 10 * 1000 })
await expect(setupModal.locator('img[alt="TOTP QR Code"]')).toBeVisible({ timeout: 10 * 1000 })
@@ -2455,22 +2575,29 @@ async function verifyProfileAndSecurity(page) {
await forceClick(setupModal.getByRole('button', { name: TEXT.confirmEnableTOTP }).last())
})
assertFetchLogSuccess(enableTotpFetch, 'enable TOTP')
await waitForModalToStopBlocking(setupModal, 'enable TOTP')
await waitForModalToStopBlocking(setupModalRoot, 'enable TOTP')
await expect(setupModalRoot).toBeHidden({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.disableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP enabled')
await forceClick(page.getByRole('button', { name: TEXT.disableTOTP }).first())
const disableModal = page.locator('.ant-modal').last()
const disableModalRoot = page.locator('.ant-modal-root').filter({
has: page.getByRole('button', { name: TEXT.confirmDisableTOTP }),
}).last()
const disableModal = disableModalRoot.locator('.ant-modal').first()
await expect(disableModal).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: submit TOTP disable')
await forceFillInput(disableModal.locator('input').first(), recoveryCodes[0])
const disableCodeInput = disableModal.locator('input').first()
await expect(disableCodeInput).toBeVisible({ timeout: 10 * 1000 })
await forceFillInput(disableCodeInput, recoveryCodes[0])
const disableTotpFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/auth\/2fa\/disable$/) && entry.method === 'POST'
}, async () => {
await forceClick(disableModal.getByRole('button', { name: TEXT.confirmDisableTOTP }).last())
})
assertFetchLogSuccess(disableTotpFetch, 'disable TOTP')
await waitForModalToStopBlocking(disableModal, 'disable TOTP')
await waitForModalToStopBlocking(disableModalRoot, 'disable TOTP')
await expect(disableModalRoot).toBeHidden({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP disabled')