0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

test: add ui tests for passwordless connectors (#4331)

This commit is contained in:
Xiao Yijun 2023-08-16 15:41:59 +08:00 committed by GitHub
parent d0f91d5d37
commit 8354fa87ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 580 additions and 80 deletions

View file

@ -0,0 +1,92 @@
import { ConnectorType } from '@logto/connector-kit';
import { type Page } from 'puppeteer';
import { expectConfirmModalAndAct, waitForToaster } from '#src/ui-helpers/index.js';
import {
passwordlessConnectorTestCases,
type PasswordlessConnectorCase,
} from './passwordless-connector-test-cases.js';
/**
* Finds the next connector of the same type adjacent to the current connector, which will be selected
* as the new connector when changing the current connector.
*/
export const findNextCompatibleConnector = (currentConnector: PasswordlessConnectorCase) => {
const sameTypeConnectors = passwordlessConnectorTestCases.filter(
(connector) => connector.isEmailConnector === currentConnector.isEmailConnector
);
const currentIndex = sameTypeConnectors.findIndex(
(connector) => connector.factoryId === currentConnector.factoryId
);
if (currentIndex === -1) {
return;
}
return sameTypeConnectors[(currentIndex + 1) % sameTypeConnectors.length];
};
type SelectConnectorOption = {
groupFactoryId?: string;
factoryId: string;
connectorType: ConnectorType;
};
export const expectToSelectConnector = async (
page: Page,
{ groupFactoryId, factoryId, connectorType }: SelectConnectorOption
) => {
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=header] div[class$=titleEllipsis]',
{
text:
connectorType === ConnectorType.Email
? 'Set up email connector'
: connectorType === ConnectorType.Sms
? 'Set up SMS connector'
: 'Add Social Connector',
}
);
if (groupFactoryId) {
// Platform selector
await page.click(
`.ReactModalPortal div[role=radio]:has(input[name=group][value=${groupFactoryId}])`
);
await page.waitForSelector('.ReactModalPortal div[class$=platforms] div[class$=radioGroup]');
await page.click(
`.ReactModalPortal div[class$=platforms] div[role=radio]:has(input[name=connector][value=${factoryId}])`
);
} else {
await page.click(
`.ReactModalPortal div[role=radio]:has(input[name=group][value=${factoryId}])`
);
}
await expect(page).toClick('.ReactModalPortal div[class$=footer] button:not(disabled) span', {
text: 'Next',
});
};
export const waitForConnectorCreationGuide = async (page: Page, connectorName: string) => {
await expect(page).toMatchElement('.ReactModalPortal div[class$=titleEllipsis] span', {
text: connectorName,
});
await expect(page).toMatchElement('.ReactModalPortal div[class$=subtitle] span', {
text: 'A step by step guide to configure your connector',
});
};
export const expectToConfirmConnectorDeletion = async (page: Page) => {
await expectConfirmModalAndAct(page, {
title: 'Reminder',
actionText: 'Delete',
});
await waitForToaster(page, { text: 'The connector has been successfully deleted' });
};

View file

@ -0,0 +1,234 @@
export type PasswordlessConnectorCase = {
factoryId: string;
isEmailConnector: boolean;
name: string;
initialFormData: Record<string, string>;
updateFormData: Record<string, string>;
errorFormData: Record<string, string>;
};
const awsSesMail: PasswordlessConnectorCase = {
factoryId: 'aws-ses-mail',
isEmailConnector: true,
name: 'AWS Direct Mail',
initialFormData: {
'formConfig.accessKeyId': 'access-key-id',
'formConfig.accessKeySecret': 'access-key-config',
'formConfig.region': 'region',
'formConfig.emailAddress': 'email-address',
'formConfig.emailAddressIdentityArn': 'email-address-identity-arn',
'formConfig.feedbackForwardingEmailAddress': 'feedback-forwarding-email-address',
'formConfig.feedbackForwardingEmailAddressIdentityArn':
'feedback-forwarding-email-address-identity-arn',
'formConfig.configurationSetName': 'configuration-set-name',
},
updateFormData: {
'formConfig.accessKeyId': 'new-access-key-id',
'formConfig.accessKeySecret': 'new-access-key-config',
'formConfig.region': 'new-region',
'formConfig.emailAddress': 'new-email-address',
'formConfig.emailAddressIdentityArn': 'new-email-address-identity-arn',
'formConfig.feedbackForwardingEmailAddress': 'new-feedback-forwarding-email-address',
'formConfig.feedbackForwardingEmailAddressIdentityArn':
'new-feedback-forwarding-email-address-identity-arn',
'formConfig.configurationSetName': 'new-configuration-set-name',
},
errorFormData: {
'formConfig.accessKeyId': '',
'formConfig.accessKeySecret': '',
'formConfig.region': '',
},
};
const sendGrid: PasswordlessConnectorCase = {
factoryId: 'sendgrid-email-service',
isEmailConnector: true,
name: 'SendGrid Email',
initialFormData: {
'formConfig.apiKey': 'api-key',
'formConfig.fromEmail': 'foo@example.com',
'formConfig.fromName': 'Logto',
},
updateFormData: {
'formConfig.apiKey': 'new-api-key',
'formConfig.fromEmail': 'new-foo@example.com',
'formConfig.fromName': 'new-Logto',
},
errorFormData: {
'formConfig.apiKey': '',
'formConfig.fromEmail': '',
},
};
const aliyunDirectMail: PasswordlessConnectorCase = {
factoryId: 'aliyun-direct-mail',
isEmailConnector: true,
name: 'Aliyun Direct Mail',
initialFormData: {
'formConfig.accessKeyId': 'access-key-id',
'formConfig.accessKeySecret': 'access-key-config',
'formConfig.accountName': 'account-name',
'formConfig.fromAlias': 'from-alias',
},
updateFormData: {
'formConfig.accessKeyId': 'new-access-key-id',
'formConfig.accessKeySecret': 'new-access-key-config',
'formConfig.accountName': 'new-account-name',
'formConfig.fromAlias': 'new-from-alias',
},
errorFormData: {
'formConfig.accessKeyId': '',
'formConfig.accessKeySecret': '',
'formConfig.accountName': '',
},
};
const mailgun: PasswordlessConnectorCase = {
factoryId: 'mailgun-email',
isEmailConnector: true,
name: 'Mailgun',
initialFormData: {
'formConfig.endpoint': 'https://fake.mailgun.net',
'formConfig.domain': 'mailgun-domain.com',
'formConfig.apiKey': 'api-key',
'formConfig.from': 'from',
},
updateFormData: {
'formConfig.endpoint': 'https://new-fake.mailgun.net',
'formConfig.domain': 'new-mailgun-domain.com',
'formConfig.apiKey': 'new-api-key',
'formConfig.from': 'new-from',
},
errorFormData: {
'formConfig.domain': '',
'formConfig.apiKey': '',
'formConfig.from': '',
},
};
const smpt: PasswordlessConnectorCase = {
factoryId: 'simple-mail-transfer-protocol',
isEmailConnector: true,
name: 'SMTP',
initialFormData: {
'formConfig.host': 'host',
'formConfig.port': '25',
'formConfig.fromEmail': 'from',
},
updateFormData: {
'formConfig.host': 'new-host',
'formConfig.port': '26',
'formConfig.fromEmail': 'new-from',
},
errorFormData: {
'formConfig.host': '',
'formConfig.port': '',
'formConfig.fromEmail': '',
},
};
const twilio: PasswordlessConnectorCase = {
factoryId: 'twilio-short-message-service',
isEmailConnector: false,
name: 'Twilio SMS Service',
initialFormData: {
'formConfig.accountSID': 'account-sid',
'formConfig.authToken': 'auth-token',
'formConfig.fromMessagingServiceSID': 'from-messaging-service-sid',
},
updateFormData: {
'formConfig.accountSID': 'new-account-sid',
'formConfig.authToken': 'new-auth-token',
'formConfig.fromMessagingServiceSID': 'new-from-messaging-service-sid',
},
errorFormData: {
'formConfig.accountSID': '',
'formConfig.authToken': '',
'formConfig.fromMessagingServiceSID': '',
},
};
const aliyunShortMessage: PasswordlessConnectorCase = {
factoryId: 'aliyun-short-message-service',
isEmailConnector: false,
name: 'Aliyun Short Message Service',
initialFormData: {
'formConfig.accessKeyId': 'access-key-id',
'formConfig.accessKeySecret': 'access-key-config',
'formConfig.signName': 'sign-name',
},
updateFormData: {
'formConfig.accessKeyId': 'new-access-key-id',
'formConfig.accessKeySecret': 'new-access-key-config',
'formConfig.signName': 'new-sign-name',
},
errorFormData: {
'formConfig.accessKeyId': '',
'formConfig.accessKeySecret': '',
'formConfig.signName': '',
},
};
// Tencent-short-message-service
const tencentShortMessage: PasswordlessConnectorCase = {
factoryId: 'tencent-short-message-service',
isEmailConnector: false,
name: 'Tencent Short Message Service',
initialFormData: {
'formConfig.accessKeyId': 'access-key-id',
'formConfig.accessKeySecret': 'access-key-config',
'formConfig.signName': 'sign-name',
'formConfig.sdkAppId': 'sdk-app-id',
'formConfig.region': 'region',
},
updateFormData: {
'formConfig.accessKeyId': 'new-access-key-id',
'formConfig.accessKeySecret': 'new-access-key-config',
'formConfig.signName': 'new-sign-name',
'formConfig.sdkAppId': 'new-sdk-app-id',
'formConfig.region': 'new-region',
},
errorFormData: {
'formConfig.accessKeyId': '',
'formConfig.accessKeySecret': '',
'formConfig.signName': '',
'formConfig.sdkAppId': '',
'formConfig.region': '',
},
};
// Smsaero-short-message-service
const smsaeroShortMessage: PasswordlessConnectorCase = {
factoryId: 'smsaero-short-message-service',
isEmailConnector: false,
name: 'SMS Aero service',
initialFormData: {
'formConfig.email': 'fake@email.com',
'formConfig.apiKey': 'api-key',
'formConfig.senderName': 'sender-name',
},
updateFormData: {
'formConfig.email': 'new-fake@email.com',
'formConfig.apiKey': 'new-api-key',
'formConfig.senderName': 'new-sender-name',
},
errorFormData: {
'formConfig.email': '',
'formConfig.apiKey': '',
'formConfig.senderName': '',
},
};
export const passwordlessConnectorTestCases = [
// Email
awsSesMail,
sendGrid,
aliyunDirectMail,
mailgun,
smpt,
// SMS
twilio,
aliyunShortMessage,
tencentShortMessage,
smsaeroShortMessage,
];

View file

@ -0,0 +1,174 @@
import { ConnectorType } from '@logto/connector-kit';
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
import {
expectToClickDetailsPageOption,
expectUnsavedChangesAlert,
goToAdminConsole,
trySaveChanges,
waitForToaster,
} from '#src/ui-helpers/index.js';
import { expectNavigation, appendPathname } from '#src/utils.js';
import {
expectToConfirmConnectorDeletion,
expectToSelectConnector,
findNextCompatibleConnector,
waitForConnectorCreationGuide,
} from './helpers.js';
import {
type PasswordlessConnectorCase,
passwordlessConnectorTestCases,
} from './passwordless-connector-test-cases.js';
await page.setViewport({ width: 1920, height: 1080 });
describe('passwordless connectors', () => {
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
beforeAll(async () => {
await goToAdminConsole();
});
it('navigate to passwordless connector page', async () => {
// Should navigate to passwordless page when visit '/console/connectors'
await expectNavigation(page.goto(appendPathname('/console/connectors', logtoConsoleUrl).href));
expect(page.url()).toBe(new URL('/console/connectors/passwordless', logtoConsoleUrl).href);
await expectNavigation(
page.goto(appendPathname('/console/connectors/passwordless', logtoConsoleUrl).href)
);
expect(page.url()).toBe(new URL('/console/connectors/passwordless', logtoConsoleUrl).href);
});
it.each(passwordlessConnectorTestCases)(
'can setup and modify a(n) $factoryId connector',
async (connector: PasswordlessConnectorCase) => {
const { factoryId, isEmailConnector, name, initialFormData, updateFormData, errorFormData } =
connector;
const connectorItem = await expect(page).toMatchElement(
'div[class$=item] div[class$=previewTitle]:has(>div)',
{
text: isEmailConnector ? 'Email connector' : 'SMS connector',
}
);
const setupConnectorButton = await expect(connectorItem).toMatchElement('button span', {
text: 'Set Up',
});
await setupConnectorButton.click();
await expectToSelectConnector(page, {
factoryId,
connectorType: isEmailConnector ? ConnectorType.Email : ConnectorType.Sms,
});
await waitForConnectorCreationGuide(page, name);
await expect(page).toClick(
'.ReactModalPortal form div[class$=footer] button[type=submit] span',
{
text: 'Save and Done',
}
);
// Display error input
await page.waitForSelector('form div[class$=field] div[class$=error]');
await expect(page).toFillForm('.ReactModalPortal form', initialFormData);
// Try click test button
await expect(page).toClick('.ReactModalPortal div[class$=send] button span', {
text: 'Send',
});
// Display test input error
await page.waitForSelector('.ReactModalPortal div[class$=error]:has(input[name=sendTo])');
await expect(page).toClick(
'.ReactModalPortal form div[class$=footer] button[type=submit] span',
{
text: 'Save and Done',
}
);
await waitForToaster(page, { text: 'Saved' });
await expect(page).toMatchElement('div[class$=header] div[class$=name] span', {
text: name,
});
// Try send test
await expect(page).toFill(
'input[name=sendTo]',
isEmailConnector ? 'fake@email.com' : '+1 555-123-4567'
);
await expect(page).toClick('div[class$=fields] div[class$=send] button span', {
text: 'Send',
});
await waitForToaster(page, {
text: /error/i,
isError: true,
});
// Fill incorrect form
await expect(page).toFillForm('form', errorFormData);
await trySaveChanges(page);
await page.waitForSelector('form div[class$=field] div[class$=error]');
// Update form
await expect(page).toFillForm('form', updateFormData);
await expectUnsavedChangesAlert(page);
await trySaveChanges(page);
await waitForToaster(page, { text: 'Saved' });
// Change to next connector
const nextConnector = findNextCompatibleConnector(connector);
if (nextConnector) {
await expectToClickDetailsPageOption(
page,
isEmailConnector ? 'Change email connector' : 'Change SMS connector'
);
await expectToSelectConnector(page, {
factoryId: nextConnector.factoryId,
connectorType: isEmailConnector ? ConnectorType.Email : ConnectorType.Sms,
});
await waitForConnectorCreationGuide(page, nextConnector.name);
await expect(page).toFillForm('.ReactModalPortal form', nextConnector.initialFormData);
await expect(page).toClick(
'.ReactModalPortal form div[class$=footer] button[type=submit] span',
{
text: 'Save and Done',
}
);
await waitForToaster(page, { text: 'Saved' });
await expect(page).toMatchElement('div[class$=header] div[class$=name] span', {
text: nextConnector.name,
});
}
// Delete email connector
await expectToClickDetailsPageOption(page, 'Delete');
await expectToConfirmConnectorDeletion(page);
expect(page.url()).toBe(new URL(`console/connectors/passwordless`, logtoConsoleUrl).href);
}
);
});

View file

@ -1,12 +1,20 @@
import { ConnectorType } from '@logto/connector-kit';
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
import {
expectToClickDetailsPageOption,
expectUnsavedChangesAlert,
goToAdminConsole,
trySaveChanges,
waitForSuccessToast,
waitForToaster,
} from '#src/ui-helpers/index.js';
import { expectNavigation, appendPathname } from '#src/utils.js';
import {
expectToConfirmConnectorDeletion,
expectToSelectConnector,
waitForConnectorCreationGuide,
} from './helpers.js';
import {
socialConnectorTestCases,
type SocialConnectorCase,
@ -71,44 +79,15 @@ describe('social connectors', () => {
text: 'Add Social Connector',
});
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=header] div[class$=titleEllipsis]',
{
text: 'Add Social Connector',
}
);
if (groupFactoryId) {
// Platform selector
await page.click(
`.ReactModalPortal div[role=radio]:has(input[name=group][value=${groupFactoryId}])`
);
await page.waitForSelector(
'.ReactModalPortal div[class$=platforms] div[class$=radioGroup]'
);
await page.click(
`.ReactModalPortal div[class$=platforms] div[role=radio]:has(input[name=connector][value=${factoryId}])`
);
} else {
await page.click(
`.ReactModalPortal div[role=radio]:has(input[name=group][value=${factoryId}])`
);
}
await expect(page).toClick('.ReactModalPortal div[class$=footer] button:not(disabled) span', {
text: 'Next',
await expectToSelectConnector(page, {
groupFactoryId,
factoryId,
connectorType: ConnectorType.Social,
});
await expect(page).toMatchElement('.ReactModalPortal div[class$=titleEllipsis] span', {
text: name,
});
await expect(page).toMatchElement('.ReactModalPortal div[class$=subtitle] span', {
text: 'A step by step guide to configure your connector',
});
await waitForConnectorCreationGuide(page, name);
// Save with empty form
await expect(page).toClick(
'.ReactModalPortal form div[class$=footer] button[type=submit] span',
{
@ -131,7 +110,7 @@ describe('social connectors', () => {
}
);
await waitForSuccessToast(page, 'Saved');
await waitForToaster(page, { text: 'Saved' });
await expect(page).toMatchElement('div[class$=header] div[class$=name] span', {
text: name,
@ -151,47 +130,12 @@ describe('social connectors', () => {
await trySaveChanges(page);
await waitForSuccessToast(page, 'Saved');
await waitForToaster(page, { text: 'Saved' });
// Delete connector
await expect(page).toClick(
'div[class$=header] div[class$=operations] button[class$=withIcon]:has(span[class$=icon] > svg[class$=moreIcon])'
);
await expectToClickDetailsPageOption(page, 'Delete');
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=dropdownContainer] div[class$=dropdownTitle]',
{
text: 'MORE OPTIONS',
}
);
// Wait for the dropdown menu to be rendered in the correct position
await page.waitForTimeout(500);
await expect(page).toClick(
'.ReactModalPortal div[class$=dropdownContainer] div[role=menuitem]',
{ text: 'Delete' }
);
await page.waitForSelector(
'.ReactModalPortal div[class$=dropdownContainer] div[class$=dropdownTitle]',
{
hidden: true,
}
);
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=header] div[class$=titleEllipsis]',
{
text: 'Reminder',
}
);
await expect(page).toClick('.ReactModalPortal div[class$=footer] button span', {
text: 'Delete',
});
await waitForSuccessToast(page, 'The connector has been successfully deleted');
await expectToConfirmConnectorDeletion(page);
expect(page.url()).toBe(new URL(`console/connectors/social`, logtoConsoleUrl).href);
}

View file

@ -20,13 +20,19 @@ export const goToAdminConsole = async () => {
}
};
export const waitForSuccessToast = async (page: Page, text: string) => {
const successToastHandle = await page.waitForSelector('div[class*=toast][class*=success]');
await expect(successToastHandle).toMatchElement('div[class$=message]', {
type WaitToasterOptions = {
text?: string | RegExp;
isError?: boolean;
};
export const waitForToaster = async (page: Page, { text, isError }: WaitToasterOptions) => {
const toastStyleClass = isError ? 'error' : 'success';
const toastHandle = await page.waitForSelector(`div[class*=toast][class*=${toastStyleClass}]`);
await expect(toastHandle).toMatchElement('div[class$=message]', {
text,
});
// Wait the success toast to disappear so that the next time we call this function we will match the brand new toast
await page.waitForSelector('div[class*=toast][class*=success]', {
// Wait the toast to disappear so that the next time we call this function we will match the brand new toast
await page.waitForSelector(`div[class*=toast][class*=${toastStyleClass}]`, {
hidden: true,
});
};
@ -49,3 +55,53 @@ export const trySaveChanges = async (page: Page) => {
await page.waitForTimeout(500);
await expect(page).toClick('div[class$=actionBar] button span', { text: 'Save Changes' });
};
export const expectToClickDetailsPageOption = async (page: Page, optionText: string) => {
await expect(page).toClick(
'div[class$=header] button[class$=withIcon]:last-of-type span[class$=icon]:has(svg)'
);
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=dropdownContainer] div[class$=dropdownTitle]',
{
text: 'MORE OPTIONS',
}
);
// Wait for the dropdown menu to be rendered in the correct position
await page.waitForTimeout(500);
await expect(page).toClick('.ReactModalPortal div[class$=dropdownContainer] div[role=menuitem]', {
text: optionText,
});
await page.waitForSelector(
'.ReactModalPortal div[class$=dropdownContainer] div[class$=dropdownTitle]',
{
hidden: true,
}
);
};
type ExpectConfirmModalAndActOptions = {
title?: string | RegExp;
actionText?: string | RegExp;
};
export const expectConfirmModalAndAct = async (
page: Page,
{ title, actionText }: ExpectConfirmModalAndActOptions
) => {
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=header] div[class$=titleEllipsis]',
{
text: title,
}
);
if (actionText) {
await expect(page).toClick('.ReactModalPortal div[class$=footer] button span', {
text: actionText,
});
}
};