mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
test: add ui tests for applications (#4407)
* test: add ui tests for applications * refactor(test): simplify application ui tests
This commit is contained in:
parent
4f5881304e
commit
2b39964fd2
4 changed files with 461 additions and 1 deletions
|
@ -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[];
|
123
packages/integration-tests/src/tests/ui/applications/helpers.ts
Normal file
123
packages/integration-tests/src/tests/ui/applications/helpers.ts
Normal file
|
@ -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`,
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue