diff --git a/packages/integration-tests/src/tests/ui/applications/constants.ts b/packages/integration-tests/src/tests/ui/applications/constants.ts new file mode 100644 index 000000000..dc4c8720e --- /dev/null +++ b/packages/integration-tests/src/tests/ui/applications/constants.ts @@ -0,0 +1,60 @@ +import { ApplicationType } from '@logto/schemas'; + +export type ApplicationCase = { + framework: string; + name: string; + description: string; + guideFilename: string; + sample: { + repo: string; + path: string; + }; + redirectUri: string; + postSignOutRedirectUri: string; +}; + +export const initialApp: ApplicationCase = { + framework: 'Next.js', + name: 'Next.js App', + description: 'This is a Next.js app', + guideFilename: 'web-next', + sample: { + repo: 'js', + path: 'packages/next-sample', + }, + redirectUri: 'https://my.test.app/sign-in', + postSignOutRedirectUri: 'https://my.test.app/sign-out', +}; + +export const testApp: ApplicationCase = { + framework: 'Go', + name: 'Go App', + description: 'This is a Go app', + guideFilename: 'web-go', + sample: { + repo: 'go', + path: 'gin-sample', + }, + redirectUri: 'https://my.test.app/sign-in', + postSignOutRedirectUri: 'https://my.test.app/sign-out', +}; + +export const frameworkGroupLabels = [ + 'Popular and for you', + 'Traditional web app', + 'Single page app', + 'Native', + 'Machine-to-machine', +] as const; + +export type ApplicationMetadata = { + type: ApplicationType; + name: string; + description: string; +}; + +export const applicationTypesMetadata = Object.entries(ApplicationType).map(([key, value]) => ({ + type: value, + name: `${key} app`, + description: `This is a ${key} app`, +})) satisfies ApplicationMetadata[]; diff --git a/packages/integration-tests/src/tests/ui/applications/helpers.ts b/packages/integration-tests/src/tests/ui/applications/helpers.ts new file mode 100644 index 000000000..cea8cebd0 --- /dev/null +++ b/packages/integration-tests/src/tests/ui/applications/helpers.ts @@ -0,0 +1,123 @@ +import { appendPath } from '@silverhand/essentials'; +import { type Page } from 'puppeteer'; + +import { + expectModalWithTitle, + expectToClickDetailsPageOption, + expectToClickModalAction, + expectToOpenNewPage, + waitForToast, +} from '#src/ui-helpers/index.js'; +import { expectNavigation } from '#src/utils.js'; + +import { frameworkGroupLabels, type ApplicationCase } from './constants.js'; + +export const expectFrameworksInGroup = async (page: Page, groupSelector: string) => { + /* eslint-disable no-await-in-loop */ + for (const groupLabel of frameworkGroupLabels) { + const frameGroup = await expect(page).toMatchElement(groupSelector, { + text: groupLabel, + }); + + const frameworks = await frameGroup.$$('div[class$=grid] div[class*=card]'); + expect(frameworks.length).toBeGreaterThan(0); + } + /* eslint-enable no-await-in-loop */ +}; + +export const expectToClickFramework = async (page: Page, framework: string) => { + const frameworkCard = await expect(page).toMatchElement( + 'div[class*=card]:has(div[class$=header] div[class$=name])', + { + text: framework, + } + ); + + await expect(frameworkCard).toClick('button span', { text: 'Start Building' }); +}; + +export const expectFrameworkExists = async (page: Page, framework: string) => { + await expect(page).toMatchElement('div[class*=card]:has(div[class$=header] div[class$=name])', { + text: framework, + }); +}; + +export const expectToProceedCreationFrom = async ( + page: Page, + { name, description }: ApplicationCase +) => { + // Expect the creation form to be open + await expectModalWithTitle(page, 'Create Application'); + + await expect(page).toFillForm('.ReactModalPortal form', { + name, + description, + }); + await expectToClickModalAction(page, 'Create Application'); + + await waitForToast(page, { text: 'Application created successfully.' }); +}; + +export const expectToProceedSdkGuide = async ( + page: Page, + { guideFilename, sample, redirectUri, postSignOutRedirectUri }: ApplicationCase, + skipFillForm = false +) => { + await expectModalWithTitle(page, 'Start with SDK and guides'); + + expect(page.url()).toContain(`/guide/${guideFilename}`); + + await expect(page).toClick('.ReactModalPortal aside[class$=sample] a span', { + text: 'Check out sample', + }); + + await expectToOpenNewPage( + browser, + appendPath(new URL('https://github.com/logto-io'), sample.repo, 'tree/HEAD', sample.path).href + ); + + if (!skipFillForm) { + const redirectUriFieldWrapper = await expect(page).toMatchElement( + 'div[class$=wrapper]:has(>div[class$=field]>div[class$=headline]>div[class$=title])', + { text: 'Redirect URI' } + ); + + await expect(redirectUriFieldWrapper).toFill('input', redirectUri); + + await expect(redirectUriFieldWrapper).toClick('button span', { text: 'Save' }); + + await waitForToast(page, { text: 'Saved' }); + + const postSignOutRedirectUriWrapper = await expect(page).toMatchElement( + 'div[class$=wrapper]:has(>div[class$=field]>div[class$=headline]>div[class$=title])', + { text: 'Post Sign-out Redirect URI' } + ); + + await expect(postSignOutRedirectUriWrapper).toFill('input', postSignOutRedirectUri); + + await expect(postSignOutRedirectUriWrapper).toClick('button span', { text: 'Save' }); + + await waitForToast(page, { text: 'Saved' }); + } + + // Finish guide + await expect(page).toClick('.ReactModalPortal nav[class$=actionBar] button span', { + text: 'Finish and done', + }); +}; + +export const expectToProceedAppDeletion = async (page: Page, appName: string) => { + // Delete the application + await expectToClickDetailsPageOption(page, 'Delete'); + + // Confirm deletion + await expectModalWithTitle(page, 'Reminder'); + + await expect(page).toFill('.ReactModalPortal div[class$=deleteConfirm] input', appName); + + await expectNavigation(expectToClickModalAction(page, 'Delete')); + + await waitForToast(page, { + text: `Application ${appName} has been successfully deleted`, + }); +}; diff --git a/packages/integration-tests/src/tests/ui/applications/index.test.ts b/packages/integration-tests/src/tests/ui/applications/index.test.ts new file mode 100644 index 000000000..24cbff1cf --- /dev/null +++ b/packages/integration-tests/src/tests/ui/applications/index.test.ts @@ -0,0 +1,268 @@ +import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js'; +import { + expectConfirmModalAndAct, + expectModalWithTitle, + expectToClickModalAction, + expectToDiscardChanges, + expectToOpenNewPage, + expectToSaveChanges, + expectUnsavedChangesAlert, + goToAdminConsole, + waitForToast, +} from '#src/ui-helpers/index.js'; +import { expectNavigation, appendPathname } from '#src/utils.js'; + +import { + type ApplicationMetadata, + applicationTypesMetadata, + initialApp, + testApp, +} from './constants.js'; +import { + expectFrameworkExists, + expectToClickFramework, + expectToProceedCreationFrom, + expectToProceedSdkGuide, + expectToProceedAppDeletion, + expectFrameworksInGroup, +} from './helpers.js'; + +await page.setViewport({ width: 1920, height: 1080 }); + +describe('applications', () => { + const logtoConsoleUrl = new URL(logtoConsoleUrlString); + + beforeAll(async () => { + await goToAdminConsole(); + }); + + it('navigate to applications page', async () => { + await expectNavigation( + page.goto(appendPathname('/console/applications', logtoConsoleUrl).href) + ); + + expect(page.url()).toBe(new URL('/console/applications', logtoConsoleUrl).href); + }); + + it('the table placeholder should be rendered correctly', async () => { + await expect(page).toMatchElement( + 'div[class$=guideLibraryContainer] div[class$=titleEllipsis]', + { text: 'Select a framework or tutorial', timeout: 2000 } + ); + + await expectFrameworksInGroup(page, 'div[class$=guideGroup]:has(>label)'); + }); + + it('create the initial application from the table placeholder', async () => { + await expectToClickFramework(page, initialApp.framework); + + await expectToProceedCreationFrom(page, initialApp); + + await expectToProceedSdkGuide(page, initialApp, true); + + // Details page + await expect(page).toMatchElement('div[class$=main] div[class$=header] div[class$=name]', { + text: initialApp.name, + }); + + // Back to application list page + await expectNavigation( + expect(page).toClick('div[class$=main] a[class$=backLink]', { + text: 'Back to Applications', + }) + ); + + expect(page.url()).toBe(new URL('/console/applications', logtoConsoleUrl).href); + + /** + * Note: + * Reload the page to refresh new application data by the SWR, + * since test operations is so quick and the SWR is not updated + */ + await page.reload(); + + await expect(page).toMatchElement('table div[class$=item] a[class$=title]', { + text: initialApp.name, + }); + }); + + it('can open the logto github repo issue page when click on the framework not found button', async () => { + await expect(page).toClick('div[class$=main] div[class$=headline] button span', { + text: 'Create Application', + }); + + await expectModalWithTitle(page, 'Start with SDK and guides'); + + // Click request sdk button + await expect(page).toClick( + '.ReactModalPortal div[class$=header] button[class$=requestSdkButton]' + ); + + await expectToOpenNewPage(browser, 'https://github.com/logto-io/logto/issues'); + + // Return to the application list page + await expectNavigation( + expect(page).toClick('.ReactModalPortal div[class$=header] button:has(svg[class$=closeIcon])') + ); + + expect(page.url()).toBe(new URL('/console/applications', logtoConsoleUrl).href); + }); + + it('can create an application by framework from the app creation modal and modify its data', async () => { + await expect(page).toClick('div[class$=main] div[class$=headline] button span', { + text: 'Create Application', + }); + + await expectModalWithTitle(page, 'Start with SDK and guides'); + + await expectFrameworksInGroup(page, '.ReactModalPortal div[class$=guideGroup]:has(>label)'); + + // Expect the framework contains on the page + await expectFrameworkExists(page, testApp.framework); + + // Filter + await expect(page).toFill('div[class$=searchInput] input', testApp.framework); + + // Expect the framework exists after filtering + await expectFrameworkExists(page, testApp.framework); + + await expectToClickFramework(page, testApp.framework); + + await expectToProceedCreationFrom(page, testApp); + + await expectToProceedSdkGuide(page, testApp); + + // Expect on the details page + await expect(page).toMatchElement('div[class$=main] div[class$=header] div[class$=name]', { + text: testApp.name, + }); + + // Check guide + await expect(page).toClick('div[class$=header] button span', { text: 'Check Guide' }); + + // Wait for the guide drawer to be ready + await page.waitForTimeout(500); + + // Close guide + await expect(page).toClick( + '.ReactModalPortal div[class$=drawerContainer] div[class$=header] button:last-of-type' + ); + + // Wait for the guide drawer to disappear + await page.waitForSelector('.ReactModalPortal div[class$=drawerContainer]', { hidden: true }); + + // Update application data + await expect(page).toFillForm('form', { + description: `(New): ${testApp.description}`, + }); + + await expectUnsavedChangesAlert(page); + + await expectToSaveChanges(page); + await waitForToast(page, { text: 'Saved' }); + + const redirectUriFiled = await expect(page).toMatchElement( + 'div[class$=field]:has(>div[class$=headline]>div[class$=title]', + { text: 'Redirect URIs' } + ); + + // Add and remove redirect uri + await expect(redirectUriFiled).toClick('div[class$=multilineInput]>button>span', { + text: 'Add Another', + }); + + // Wait for the new redirect uri field + await page.waitForSelector( + 'div:has(>div[class$=deletableInput]):last-of-type button:has(svg[class$=minusIcon])' + ); + + await expect(redirectUriFiled).toFill( + 'div:has(>div[class$=deletableInput]):last-of-type input', + `${testApp.redirectUri}/v2` + ); + + await expectToSaveChanges(page); + + await waitForToast(page, { text: 'Saved' }); + + // Click delete button + await expect(redirectUriFiled).toClick( + 'div:has(>div[class$=deletableInput]):last-of-type button:has(svg[class$=minusIcon])' + ); + + await expectConfirmModalAndAct(page, { title: 'Reminder', actionText: 'Delete' }); + + await expectToSaveChanges(page); + + await waitForToast(page, { text: 'Saved' }); + + // Wait for the redirect uri field to be updated + await page.waitForTimeout(500); + + // Remove Redirect Uri + await expect(page).toFill(`input[value="${testApp.redirectUri}"]`, ''); + + await expectToSaveChanges(page); + + // Expect error + await expect(page).toMatchElement( + 'div[class$=field] div[class$=multilineInput] div[class$=error]', + { + text: 'You must enter at least one redirect URI', + } + ); + + await expectToDiscardChanges(page); + + await expectToProceedAppDeletion(page, testApp.name); + + expect(page.url()).toBe(new URL('/console/applications', logtoConsoleUrl).href); + }); + + it.each(applicationTypesMetadata)( + 'can create and modify a(n) $type application without framework', + async (app: ApplicationMetadata) => { + await expect(page).toClick('div[class$=main] div[class$=headline] button span', { + text: 'Create Application', + }); + + await expect(page).toClick('.ReactModalPortal nav[class$=actionBar] button span', { + text: 'Create app without framework', + }); + + await expectModalWithTitle(page, 'Create Application'); + + await expect(page).toClick(`div[class*=radio][role=radio]:has(input[value=${app.type}])`); + + await expect(page).toFillForm('.ReactModalPortal form', { + name: app.name, + description: app.description, + }); + await expectToClickModalAction(page, 'Create Application'); + + await waitForToast(page, { text: 'Application created successfully.' }); + + await expect(page).toMatchElement('div[class$=main] div[class$=header] div[class$=name]', { + text: app.name, + }); + + await expectToProceedAppDeletion(page, app.name); + + expect(page.url()).toBe(new URL('/console/applications', logtoConsoleUrl).href); + } + ); + + it('delete the initial application', async () => { + await expect(page).toClick('table tbody tr td div[class$=item] a[class$=title]', { + text: initialApp.name, + }); + + await expect(page).toMatchElement('div[class$=main] div[class$=header] div[class$=name]', { + text: initialApp.name, + }); + + await expectToProceedAppDeletion(page, initialApp.name); + + expect(page.url()).toBe(new URL('/console/applications', logtoConsoleUrl).href); + }); +}); diff --git a/packages/integration-tests/src/ui-helpers/index.ts b/packages/integration-tests/src/ui-helpers/index.ts index 877bd8197..5bf657e74 100644 --- a/packages/integration-tests/src/ui-helpers/index.ts +++ b/packages/integration-tests/src/ui-helpers/index.ts @@ -1,4 +1,4 @@ -import { type Page } from 'puppeteer'; +import { type Browser, type Page } from 'puppeteer'; import { consolePassword, @@ -120,3 +120,12 @@ export const expectToClickNavTab = async (page: Page, tab: string) => { text: tab, }); }; + +export const expectToOpenNewPage = async (browser: Browser, url: string) => { + const target = await browser.waitForTarget((target) => target.url() === url); + + const newPage = await target.page(); + expect(newPage).toBeTruthy(); + + await newPage?.close(); +};