diff --git a/packages/console/src/containers/ErrorBoundary/index.tsx b/packages/console/src/containers/ErrorBoundary/index.tsx index 81fc8bc72..45f671887 100644 --- a/packages/console/src/containers/ErrorBoundary/index.tsx +++ b/packages/console/src/containers/ErrorBoundary/index.tsx @@ -9,6 +9,7 @@ import { withTranslation } from 'react-i18next'; import AppError from '@/components/AppError'; import SessionExpired from '@/components/SessionExpired'; +import { isInCallback } from '@/utils/url'; type Props = { children: ReactNode; @@ -59,8 +60,7 @@ class ErrorBoundary extends Component { if (error) { // Different strategies for handling errors in callback pages since the callback errors // are likely unexpected and unrecoverable. - const { pathname } = window.location; - if (['/callback', '-callback'].some((path) => pathname.endsWith(path))) { + if (isInCallback()) { if (error instanceof LogtoError && error.data instanceof OidcError) { return ( { - navigate('/' + conditionalString(isCloud && getUserTenantId()), { replace: true }); + /** + * The first level callback check is due to the usage of `basename` + * for tenant-specific routes, e.g., `/:tenantId/applications`. + * Once we merge all the routes into one router, we can remove this check. + */ + navigate(isInFirstLevelCallback() ? `/${getUserTenantId()}` : '/', { + replace: true, + }); }); return ; diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx b/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx index a92f7aad3..a9908faa6 100644 --- a/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx @@ -27,7 +27,8 @@ const tenantProfileToForm = (tenant?: TenantInfo): TenantSettingsForm => { function TenantBasicSettings() { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const api = useCloudApi(); - const { currentTenant, currentTenantId, updateTenant, removeTenant } = useContext(TenantsContext); + const { currentTenant, currentTenantId, updateTenant, removeTenant, navigateTenant } = + useContext(TenantsContext); const [error, setError] = useState(); const [isDeletionModalOpen, setIsDeletionModalOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -89,6 +90,7 @@ function TenantBasicSettings() { await api.delete(`/api/tenants/:tenantId`, { params: { tenantId: currentTenantId } }); setIsDeletionModalOpen(false); removeTenant(currentTenantId); + navigateTenant(''); } catch (error: unknown) { setError( error instanceof Error diff --git a/packages/console/src/utils/url.ts b/packages/console/src/utils/url.ts index 3f789691d..131362ea7 100644 --- a/packages/console/src/utils/url.ts +++ b/packages/console/src/utils/url.ts @@ -2,3 +2,11 @@ export const buildUrl = (path: string, searchParameters: Record) `${path}?${new URLSearchParams(searchParameters).toString()}`; export const formatSearchKeyword = (keyword: string) => `%${keyword}%`; + +/** If the current pathname is `/callback` or ends with `-callback`, we consider it as a callback page. */ +export const isInCallback = () => + ['/callback', '-callback'].some((path) => window.location.pathname.endsWith(path)); + +/** If the current pathname is a callback page and the pathname only has one level. */ +export const isInFirstLevelCallback = () => + window.location.pathname.split('/').length === 1 && isInCallback(); diff --git a/packages/integration-tests/src/tests/ui-cloud/operations.ts b/packages/integration-tests/src/tests/ui-cloud/operations.ts new file mode 100644 index 000000000..4437d9736 --- /dev/null +++ b/packages/integration-tests/src/tests/ui-cloud/operations.ts @@ -0,0 +1,94 @@ +import { type Page } from 'puppeteer'; + +export const onboardingWelcome = async (page: Page) => { + // Select the project type option + await page.click('div[role=radio]:has(input[name=project][value=personal])'); + + // Select the deployment type option + await page.click('div[role=radio]:has(input[name=deploymentType][value=open-source])'); + + // Click the next button + await page.click('div[class$=actions] button:first-child'); +}; + +export const onboardingUserSurvey = async (page: Page) => { + // Wait for the sie config to load + await page.waitForTimeout(1000); + + // Select the first reason option + await page.click('div[role=button][class$=item]'); + + // Click the next button + await expect(page).toClick('div[class$=actions] button', { text: 'Next' }); +}; + +export const onboardingSieConfig = async (page: Page) => { + // Wait for the sie config to load + await page.waitForTimeout(1000); + + // Select username as the identifier + await page.click('div[role=radio]:has(input[name=identifier][value=username])'); + + // Click the finish button + await page.click('div[class$=continueActions] button:last-child'); +}; + +export const onboardingFinish = async (page: Page) => { + // Wait for the sie config to load + await page.waitForTimeout(1000); + + // Click the enter ac button + await page.click('div[class$=content] >button'); + + // Wait for the admin console to load + await page.waitForNavigation({ waitUntil: 'networkidle0' }); +}; + +export const openTenantDropdown = async (page: Page) => { + // Click 'current tenant card' locates in topbar + const currentTenantCard = await page.waitForSelector( + 'div[class$=topbar] > div[class$=currentTenantCard][role=button]:has(div[class$=name])' + ); + await currentTenantCard?.click(); +}; + +export const openCreateTenantModal = async (page: Page) => { + const createTenantButton = await page.waitForSelector( + 'div[class$=ReactModalPortal] div[class$=dropdownContainer] div[class$=dropdown] button[class$=createTenantButton]:has(div)' + ); + await createTenantButton?.click(); +}; + +export const fillAndCreateTenant = async (page: Page, tenantName: string) => { + // Create tenant with name 'new-tenant' and tag 'production' + await page.waitForTimeout(500); + await page.waitForSelector( + 'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]' + ); + await page.waitForSelector( + 'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])' + ); + await page.type( + 'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]', + tenantName + ); + await page.click( + 'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])' + ); + + // Click create button + await page.waitForTimeout(500); + await page.click( + 'div[class$=ReactModalPortal] div[class*=card][class$=medium] div[class$=footer] button[type=submit]' + ); +}; + +export const createNewTenant = async (page: Page, tenantName: string) => { + await page.waitForTimeout(500); + await openTenantDropdown(page); + + await page.waitForTimeout(500); + await openCreateTenantModal(page); + + await fillAndCreateTenant(page, tenantName); +}; diff --git a/packages/integration-tests/src/tests/ui-cloud/smoke.test.ts b/packages/integration-tests/src/tests/ui-cloud/smoke.test.ts index 2b68f740a..f29870f34 100644 --- a/packages/integration-tests/src/tests/ui-cloud/smoke.test.ts +++ b/packages/integration-tests/src/tests/ui-cloud/smoke.test.ts @@ -5,6 +5,17 @@ import { setDefaultOptions } from 'expect-puppeteer'; import { logtoCloudUrl as logtoCloudUrlString, logtoConsoleUrl } from '#src/constants.js'; import { generatePassword } from '#src/utils.js'; +import { + onboardingWelcome, + onboardingUserSurvey, + onboardingSieConfig, + onboardingFinish, + createNewTenant, + fillAndCreateTenant, + openTenantDropdown, + openCreateTenantModal, +} from './operations.js'; + await page.setViewport({ width: 1280, height: 720 }); setDefaultOptions({ timeout: 5000 }); @@ -51,16 +62,7 @@ describe('smoke testing for cloud', () => { }); it('can complete the onboarding welcome process and enter the user survey page', async () => { - // Select the project type option - await expect(page).toClick('div[role=radio]:has(input[name=project][value=personal])'); - - // Select the deployment type option - await expect(page).toClick( - 'div[role=radio]:has(input[name=deploymentType][value=open-source])' - ); - - // Click the next button - await expect(page).toClick('div[class$=actions] button:first-child'); + await onboardingWelcome(page); // Wait for the next page to load await expect(page).toMatchElement('div[class$=content] div[class$=title]', { @@ -71,11 +73,7 @@ describe('smoke testing for cloud', () => { }); it('can complete the onboarding user survey process and enter the sie page', async () => { - // Select the first reason option - await expect(page).toClick('div[role=button][class$=item]'); - - // Click the next button - await expect(page).toClick('div[class$=actions] button', { text: 'Next' }); + await onboardingUserSurvey(page); // Wait for the next page to load await expect(page).toMatchElement('div[class$=config] div[class$=title]', { @@ -86,14 +84,7 @@ describe('smoke testing for cloud', () => { }); it('can complete the sie configuration process and enter the congrats page', async () => { - // Wait for the sie config to load - await page.waitForTimeout(1000); - - // Select username as the identifier - await expect(page).toClick('div[role=radio]:has(input[name=identifier][value=username])'); - - // Click the finish button - await expect(page).toClick('div[class$=continueActions] button:last-child'); + await onboardingSieConfig(page); // Wait for the next page to load await expect(page).toMatchElement('div[class$=content] div[class$=title]', { @@ -104,11 +95,7 @@ describe('smoke testing for cloud', () => { }); it('can complete the onboarding process and enter the admin console', async () => { - // Click the enter ac button - await expect(page).toClick('div[class$=content] >button'); - - // Wait for the admin console to load - await page.waitForNavigation({ waitUntil: 'networkidle0' }); + await onboardingFinish(page); const mainContent = await page.waitForSelector('div[class$=main]:has(div[class$=title])'); await expect(mainContent).toMatchElement('div[class$=title]', { text: 'Something to explore to help you succeed', @@ -118,41 +105,8 @@ describe('smoke testing for cloud', () => { }); it('can create a new tenant using tenant dropdown', async () => { - // Click 'current tenant card' locates in topbar - const currentTenantCard = await page.waitForSelector( - 'div[class$=topbar] > div[class$=currentTenantCard][role=button]:has(div[class$=name])' - ); - await expect(currentTenantCard).toMatchElement('div[class$=name]', { text: 'My Project' }); - await currentTenantCard.click(); - - await page.waitForTimeout(500); - const createTenantButton = await page.waitForSelector( - 'div[class$=ReactModalPortal] div[class$=dropdownContainer] > div[class$=dropdown] > button[class$=createTenantButton]:has(div)' - ); - await expect(createTenantButton).toMatchElement('div', { text: 'Create tenant' }); - await createTenantButton.click(); - - // Create tenant with name 'new-tenant' and tag 'production' - await page.waitForTimeout(500); - await page.waitForSelector( - 'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]' - ); - await page.waitForSelector( - 'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])' - ); - await expect(page).toFill( - 'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]', - createTenantName - ); - await expect(page).toClick( - 'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])' - ); - - // Click create button - await page.waitForTimeout(500); - await expect(page).toClick( - 'div[class$=ReactModalPortal] div[class*=card][class$=medium] div[class$=footer] button[type=submit]' - ); + await page.waitForTimeout(2000); + await createNewTenant(page, createTenantName); expect(new URL(page.url()).pathname.endsWith(`/get-started`)).toBeTruthy(); }); @@ -170,7 +124,7 @@ describe('smoke testing for cloud', () => { it('can sign out of admin console', async () => { const userInfoButton = await page.waitForSelector('div[class$=topbar] > div[class$=container]'); - await userInfoButton.click(); + await userInfoButton?.click(); // Try awaiting for 500ms before clicking sign-out button await page.waitForTimeout(500); @@ -178,7 +132,7 @@ describe('smoke testing for cloud', () => { const signOutButton = await page.waitForSelector( 'div[class$=ReactModalPortal] div[class$=dropdownContainer] div[class$=dropdownItem]:last-child' ); - await signOutButton.click(); + await signOutButton?.click(); await page.waitForNavigation({ waitUntil: 'networkidle0' }); @@ -208,4 +162,84 @@ describe('smoke testing for cloud', () => { expect(page.url().startsWith(logtoCloudUrl.origin)).toBeTruthy(); expect(page.url().endsWith('/onboarding/welcome')).toBeTruthy(); }); + + it('can complete onboarding process with new account', async () => { + await onboardingWelcome(page); + await onboardingUserSurvey(page); + await onboardingSieConfig(page); + await onboardingFinish(page); + + await page.waitForTimeout(1000); + expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy(); + }); + + it('go to tenant settings and delete current tenant', async () => { + await page.waitForTimeout(2000); + const tenantSettingButton = await page.waitForSelector( + 'div[class$=content] > div[class$=sidebar] a[class$=row][href$=tenant-settings] > div[class$=title]' + ); + await tenantSettingButton?.click(); + + const deleteButton = await page.waitForSelector( + 'div[class$=main] form[class$=container] div[class$=deletionButtonContainer] button[class$=medium][type=button]' + ); + await deleteButton?.click(); + + const textInput = await page.waitForSelector( + 'div[class$=ReactModalPortal] div[class$=container] input[type=text]' + ); + await textInput?.type('My Project'); + + const deleteConfirmButton = await page.waitForSelector( + 'div[class$=ReactModalPortal] div[class$=footer] > button:last-child' + ); + await deleteConfirmButton?.click(); + + await page.waitForTimeout(2000); + const placeholderTitle = await page.waitForSelector( + 'div[class$=pageContainer] div[class$=placeholder]:has(div[class$=title])' + ); + await expect(placeholderTitle).toMatchElement('div[class$=title]', { + text: 'You haven’t created a tenant yet', + }); + }); + + it('can create tenant from landing page', async () => { + const createTenantButton = await page.waitForSelector( + 'div[class$=pageContainer] div[class$=placeholder] button[class$=button][type=button]' + ); + await createTenantButton?.click(); + + await fillAndCreateTenant(page, 'tenant1'); + await page.waitForTimeout(5000); + expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy(); + }); + + it('can create two more tenant for new account', async () => { + await createNewTenant(page, 'tenant2'); + await page.waitForTimeout(5000); + await createNewTenant(page, 'tenant3'); + await page.waitForTimeout(5000); + expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy(); + }); + + it('can not open create tenant modal when reach the limit', async () => { + await page.waitForTimeout(2000); + await openTenantDropdown(page); + await openCreateTenantModal(page); + + await page.waitForTimeout(500); + const pageTitle = await page.waitForSelector( + 'div[class$=main] > div[class$=container] > div[class$=header]:has(div[class$=title])' + ); + await expect(pageTitle).toMatchElement('div[class$=title]', { + text: 'Something to explore to help you succeed', + }); + + const createTenantModalTitleSelector = + 'div[class$=ReactModalPortal] div[class$=iconAndTitle] > div[class*=container][class$=large]:has(div[class*=title][class$=titleEllipsis])'; + await expect( + page.waitForSelector(createTenantModalTitleSelector, { timeout: 3000 }) + ).rejects.toThrow(); // Throws error if selector is not found. + }, 20_000); }); diff --git a/packages/integration-tests/tsconfig.json b/packages/integration-tests/tsconfig.json index 77c159dfa..18b6083cd 100644 --- a/packages/integration-tests/tsconfig.json +++ b/packages/integration-tests/tsconfig.json @@ -9,7 +9,8 @@ "#src/*": [ "src/*" ] - } + }, + "types": ["jest", "jest-puppeteer"], }, "include": ["src"] }