From 423e799b5dce63c89a3f19687f1c9834bdcbdd5e Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 12 Sep 2023 18:42:15 +0800 Subject: [PATCH 1/2] refactor(test): add flows integration tests --- packages/integration-tests/package.json | 5 +- packages/integration-tests/src/constants.ts | 3 +- .../happy-path.test.ts | 4 +- .../src/tests/flows/bootstrap.test.ts | 55 ++++++- .../src/tests/flows/password-policy.test.ts | 141 +++++++++++++++++ .../src/ui-helpers/expect-journey.ts | 143 ++++++++++++++++++ .../src/ui-helpers/expect-page.ts | 86 +++++++++++ packages/integration-tests/src/utils.ts | 6 +- packages/integration-tests/tsconfig.json | 1 + .../integration-tests/ui-test-sequencer.js | 2 +- .../ui/src/components/ErrorMessage/index.tsx | 6 +- pnpm-lock.yaml | 105 +------------ 12 files changed, 443 insertions(+), 114 deletions(-) create mode 100644 packages/integration-tests/src/tests/flows/password-policy.test.ts create mode 100644 packages/integration-tests/src/ui-helpers/expect-journey.ts create mode 100644 packages/integration-tests/src/ui-helpers/expect-page.ts diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index cbb94d267..3a0f486de 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -14,7 +14,7 @@ "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", "test": "pnpm build && pnpm test:api && pnpm test:ui", "test:api": "pnpm test:only -i ./lib/tests/api/", - "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/console/ ./lib/tests/flows/", + "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/flows/", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "start": "pnpm test" @@ -30,12 +30,11 @@ "@silverhand/eslint-config": "4.0.1", "@silverhand/essentials": "^2.8.4", "@silverhand/ts-config": "4.0.0", - "@types/expect-puppeteer": "^5.0.3", "@types/jest": "^29.4.0", - "@types/jest-environment-puppeteer": "^5.0.3", "@types/node": "^18.11.18", "dotenv": "^16.0.0", "eslint": "^8.44.0", + "expect-puppeteer": "^9.0.0", "got": "^13.0.0", "jest": "^29.5.0", "jest-puppeteer": "^9.0.0", diff --git a/packages/integration-tests/src/constants.ts b/packages/integration-tests/src/constants.ts index f349c409c..b3b9dbd4b 100644 --- a/packages/integration-tests/src/constants.ts +++ b/packages/integration-tests/src/constants.ts @@ -1,5 +1,5 @@ import { SignInIdentifier, demoAppApplicationId } from '@logto/schemas'; -import { getEnv } from '@silverhand/essentials'; +import { appendPath, getEnv } from '@silverhand/essentials'; export const logtoUrl = getEnv('INTEGRATION_TESTS_LOGTO_URL', 'http://localhost:3001'); export const logtoConsoleUrl = getEnv( @@ -7,6 +7,7 @@ export const logtoConsoleUrl = getEnv( 'http://localhost:3002' ); export const logtoCloudUrl = getEnv('INTEGRATION_TESTS_LOGTO_CLOUD_URL', 'http://localhost:3003'); +export const demoAppUrl = appendPath(new URL(logtoUrl), 'demo-app'); export const discoveryUrl = `${logtoUrl}/oidc/.well-known/openid-configuration`; diff --git a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts index aaeace2e5..a286c3f4d 100644 --- a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts @@ -24,7 +24,7 @@ import { } from '#src/helpers/sign-in-experience.js'; import { generateNewUserProfile, generateNewUser } from '#src/helpers/user.js'; -describe('Register with username and password', () => { +describe('register with username and password', () => { it('register with username and password', async () => { await enableAllPasswordSignInMethods({ identifiers: [SignInIdentifier.Username], @@ -51,7 +51,7 @@ describe('Register with username and password', () => { }); }); -describe('Register with passwordless identifier', () => { +describe('register with passwordless identifier', () => { beforeAll(async () => { await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); await setEmailConnector(); diff --git a/packages/integration-tests/src/tests/flows/bootstrap.test.ts b/packages/integration-tests/src/tests/flows/bootstrap.test.ts index 736d6ba79..54c4a9cf3 100644 --- a/packages/integration-tests/src/tests/flows/bootstrap.test.ts +++ b/packages/integration-tests/src/tests/flows/bootstrap.test.ts @@ -1,11 +1,60 @@ +import { SignInMode, SignInIdentifier, ConnectorType } from '@logto/schemas'; + +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { clearConnectorsByTypes } from '#src/helpers/connector.js'; +import ExpectJourney from '#src/ui-helpers/expect-journey.js'; + +const credentials = { + username: 'test_bootstrap', + pwnedPassword: 'test_password', + password: 'test_passWorD_not_PWNED:-)', +}; + /** * NOTE: This test suite assumes test cases will run sequentially (which is Jest default). * Parallel execution will lead to errors. */ // Tip: See https://github.com/argos-ci/jest-puppeteer/blob/main/packages/expect-puppeteer/README.md // for convenient expect methods -describe('smoke testing for the main flow', () => { - it('should not explode', async () => { - expect(true); +describe('smoke testing on the demo app', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await updateSignInExperience({ + signInMode: SignInMode.SignInAndRegister, + signUp: { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + passwordPolicy: {}, + }); + }); + + it('should be able to create a new account with a credential preset', async () => { + const journey = new ExpectJourney(await browser.newPage()); + + // Open the demo app and navigate to the register page + await journey.startWith(demoAppUrl, 'register'); + await journey.toFillInput('identifier', credentials.username, { submit: true }); + + // Simple password tests + journey.toBeAt('register/password'); + await journey.toFillPasswords( + [credentials.pwnedPassword, 'simple password'], + credentials.password + ); + + await journey.verifyThenEnd(); }); }); diff --git a/packages/integration-tests/src/tests/flows/password-policy.test.ts b/packages/integration-tests/src/tests/flows/password-policy.test.ts new file mode 100644 index 000000000..464b6428a --- /dev/null +++ b/packages/integration-tests/src/tests/flows/password-policy.test.ts @@ -0,0 +1,141 @@ +/* Test the sign-in with different password policies. */ + +import { ConnectorType, SignInIdentifier, SignInMode } from '@logto/schemas'; + +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js'; +import ExpectJourney from '#src/ui-helpers/expect-journey.js'; +import { waitFor } from '#src/utils.js'; + +describe('password policy', () => { + const username = 'test_pass_policy_30'; + const emailName = 'a_good_foo_30'; + const email = emailName + '@bar.com'; + const invalidPasswords: Array<[string, string | RegExp]> = [ + ['123', 'minimum length'], + ['12345678', 'at least 3 types'], + ['123456aA', 'simple password'], + ['defghiZ@', 'sequential characters'], + ['TTTTTT@z', 'repeated characters'], + ]; + + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await updateSignInExperience({ + signInMode: SignInMode.SignInAndRegister, + signUp: { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: true, + isPasswordPrimary: true, + }, + ], + }, + passwordPolicy: { + length: { min: 8, max: 32 }, + characterTypes: { min: 3 }, + rejects: { + pwned: true, + repetitionAndSequence: true, + userInfo: true, + words: [username], + }, + }, + }); + }); + + it('should work for username + password', async () => { + const journey = new ExpectJourney(await browser.newPage(), { forgotPassword: true }); + + // Open the demo app and navigate to the register page + await journey.startWith(demoAppUrl, 'register'); + await journey.toFillInput('identifier', username, { submit: true }); + + // Password tests + journey.toBeAt('register/password'); + await journey.toFillPasswords( + ...invalidPasswords, + [username + 'A', /product context .* personal information/], + username + 'ABCD_ok' + ); + + await journey.verifyThenEnd(); + }); + + it('should work for email + password', async () => { + // Enable email verification and make password primary + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Email], + password: true, + verify: true, + }, + }); + const journey = new ExpectJourney(await browser.newPage(), { forgotPassword: true }); + + // Open the demo app and navigate to the register page + await journey.startWith(demoAppUrl, 'register'); + + // Complete verification code flow + await journey.toFillInput('identifier', email, { submit: true }); + await journey.toCompleteVerification('register'); + + // Wait for the password page to load + await waitFor(100); + journey.toBeAt('continue/password'); + await journey.toFillPasswords( + ...invalidPasswords, + [emailName, 'personal information'], + emailName + 'ABCD@# $' + ); + + await journey.verifyThenEnd(); + }); + + it('should work for forgot password', async () => { + const journey = new ExpectJourney(await browser.newPage(), { forgotPassword: true }); + + // Open the demo app and navigate to the register page + await journey.startWith(demoAppUrl, 'sign-in'); + + // Click the forgot password link + await journey.toFillInput('identifier', email, { submit: true }); + await journey.toClick('a', 'Forgot your password'); + + // Submit to continue + await journey.toClickSubmit(); + + // Complete verification code flow + await journey.toCompleteVerification('forgot-password'); + + // Wait for the password page to load + await waitFor(100); + journey.toBeAt('forgot-password/reset'); + await journey.toFillPasswords( + ...invalidPasswords, + [emailName, 'personal information'], + [emailName + 'ABCD@# $', 'be the same as'], + emailName + 'ABCD135' + ); + + await journey.waitForToast(/password changed/i); + await journey.toFillInput('identifier', email, { submit: true }); + await journey.toFillInput('password', emailName + 'ABCD135', { submit: true }); + await journey.verifyThenEnd(); + }); +}); diff --git a/packages/integration-tests/src/ui-helpers/expect-journey.ts b/packages/integration-tests/src/ui-helpers/expect-journey.ts new file mode 100644 index 000000000..f7e6cc150 --- /dev/null +++ b/packages/integration-tests/src/ui-helpers/expect-journey.ts @@ -0,0 +1,143 @@ +import { appendPath } from '@silverhand/essentials'; + +import { logtoUrl } from '#src/constants.js'; +import { readVerificationCode } from '#src/helpers/index.js'; + +import ExpectPage from './expect-page.js'; + +const demoAppUrl = appendPath(new URL(logtoUrl), 'demo-app'); + +/** Remove the query string together with the `?` from a URL string. */ +const stripQuery = (url: string) => url.split('?')[0]; + +export type JourneyType = 'sign-in' | 'register' | 'continue' | 'forgot-password'; + +export type JourneyPath = + | JourneyType + | `${JourneyType}/password` + | `${JourneyType}/verify` + | `${JourneyType}/verification-code` + | `forgot-password/reset`; + +export type ExpectJourneyOptions = { + /** The URL of the journey endpoint. */ + endpoint?: URL; + /** + * Whether the forgot password flow is enabled. + * + * @default false + */ + forgotPassword?: boolean; +}; + +type OngoingJourney = { + type: JourneyType; + initialUrl: URL; +}; + +/** + * A class that provides: + * + * - A set of methods to navigate to a specific page for a journey. + * - A set of methods to assert the state of a journey and its side effects. + */ +export default class ExpectJourney extends ExpectPage { + readonly options: Required; + + protected get journeyType() { + if (this.#ongoing === undefined) { + return this.throwNoOngoingJourneyError(); + } + return this.#ongoing.type; + } + + #ongoing?: OngoingJourney; + + constructor(thePage = global.page, options: ExpectJourneyOptions = {}) { + super(thePage); + this.options = { + endpoint: new URL(logtoUrl), + forgotPassword: false, + ...options, + }; + } + + async startWith(initialUrl = demoAppUrl, type: JourneyType = 'sign-in') { + await this.toStart(initialUrl); + this.toBeAt('sign-in'); + + if (type === 'register') { + await this.toClick('a', 'Create account'); + this.toBeAt('register'); + } + + this.#ongoing = { type, initialUrl }; + } + + async verifyThenEnd() { + if (this.#ongoing === undefined) { + return this.throwNoOngoingJourneyError(); + } + + this.toMatchUrl(this.#ongoing.initialUrl); + await this.toClick('div[role=button]', /sign out/i); + + this.#ongoing = undefined; + await this.page.close(); + } + + toBeAt(mode: JourneyPath) { + const stripped = stripQuery(this.page.url()); + expect(stripped).toBe(this.buildJourneyUrl(mode).href); + } + + async toCompleteVerification(type: JourneyType) { + this.toBeAt(`${type}/verification-code`); + const { code } = await readVerificationCode(); + + for (const [index, char] of code.split('').entries()) { + // eslint-disable-next-line no-await-in-loop + await this.toFillInput(`passcode_${index}`, char); + } + } + + async toFillPasswords( + ...passwords: Array + ) { + for (const element of passwords) { + const [password, errorMessage] = Array.isArray(element) ? element : [element, undefined]; + + // eslint-disable-next-line no-await-in-loop + await this.toFillForm( + this.options.forgotPassword + ? { newPassword: password } + : { + newPassword: password, + confirmPassword: password, + }, + { submit: true, shouldNavigate: errorMessage === undefined } + ); + + if (errorMessage === undefined) { + break; + } else { + // Reject the password and assert the error message + // eslint-disable-next-line no-await-in-loop + await this.toMatchAlert( + typeof errorMessage === 'string' ? new RegExp(errorMessage, 'i') : errorMessage + ); + } + } + } + + /** Build a full journey URL from a pathname. */ + protected buildJourneyUrl(pathname = '') { + return appendPath(this.options.endpoint, pathname); + } + + protected throwNoOngoingJourneyError() { + return this.throwError( + 'The journey has not started yet. Use `startWith` to start the journey.' + ); + } +} diff --git a/packages/integration-tests/src/ui-helpers/expect-page.ts b/packages/integration-tests/src/ui-helpers/expect-page.ts new file mode 100644 index 000000000..7f0ab7174 --- /dev/null +++ b/packages/integration-tests/src/ui-helpers/expect-page.ts @@ -0,0 +1,86 @@ +import { type ElementHandle, type Page } from 'puppeteer'; + +import { expectNavigation } from '#src/utils.js'; + +class ExpectPageError extends Error { + constructor( + message: string, + public readonly page: Page + ) { + super(message); + } +} + +/** + * A class that provides a set of methods to assert the state of page tests and its side effects. + */ +export default class ExpectPage { + constructor(public readonly page = global.page) {} + + async toStart(initialUrl: URL) { + await expectNavigation(this.page.goto(initialUrl.href), this.page); + await expect(this.page).toMatchElement('#app'); + } + + async toClick(selector: string, text?: string | RegExp, shouldNavigate = true) { + const clicked = expect(this.page).toClick(selector, { text }); + return shouldNavigate ? expectNavigation(clicked, this.page) : clicked; + } + + async toClickSubmit(shouldNavigate = true) { + return this.toClick('button[type=submit]', undefined, shouldNavigate); + } + + async toSubmit(shouldNavigate = true) { + const form = await expect(this.page).toMatchElement('form'); + // eslint-disable-next-line no-restricted-syntax + const submitted = (form as ElementHandle).evaluate((form) => { + form.submit(); + }); + return shouldNavigate ? expectNavigation(submitted, this.page) : submitted; + } + + async toFillInput( + name: string, + value: string, + options?: { submit: true; shouldNavigate?: boolean } + ) { + await expect(this.page).toFill(`input[name=${name}]`, value); + if (options?.submit) { + await this.toClickSubmit(options.shouldNavigate); + } + } + + async toFillForm( + values: Record, + options?: { submit: true; shouldNavigate?: boolean } + ) { + await expect(this.page).toFillForm('form', values); + if (options?.submit) { + await this.toClickSubmit(options.shouldNavigate); + } + } + + async toMatchAlert(text?: string | RegExp): Promise { + return expect(this.page).toMatchElement('*[role=alert]', { text }); + } + + toMatchUrl(url: URL | string) { + expect(this.page.url()).toBe(typeof url === 'string' ? url : url.href); + } + + async waitForToast(text: string | RegExp) { + const toast = await expect(this.page).toMatchElement(`.ReactModal__Content[class*=toast]`, { + text, + }); + + // Remove immediately to prevent waiting for the toast to disappear and matching the same toast again + await toast.evaluate((element) => { + element.remove(); + }); + } + + protected throwError(message: string): never { + throw new ExpectPageError(message, this.page); + } +} diff --git a/packages/integration-tests/src/utils.ts b/packages/integration-tests/src/utils.ts index 0d0c649aa..441b8e03f 100644 --- a/packages/integration-tests/src/utils.ts +++ b/packages/integration-tests/src/utils.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import path from 'node:path'; import { assert } from '@silverhand/essentials'; +import { type Page } from 'puppeteer'; export const generateName = () => crypto.randomUUID(); export const generateUserId = () => crypto.randomUUID(); @@ -70,7 +71,10 @@ export const appendPathname = (pathname: string, baseUrl: URL) => * useful for actions that trigger navigation, such as clicking a link or * submitting a form. */ -export const expectNavigation = async (action: Promise): Promise => { +export const expectNavigation = async ( + action: Promise, + page: Page = global.page +): Promise => { const [result] = await Promise.all([ action, page.waitForNavigation({ waitUntil: 'networkidle0' }), diff --git a/packages/integration-tests/tsconfig.json b/packages/integration-tests/tsconfig.json index 11d4e0cda..b5a1d63d6 100644 --- a/packages/integration-tests/tsconfig.json +++ b/packages/integration-tests/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@silverhand/ts-config/tsconfig.base", "compilerOptions": { + "rootDir": "src", "isolatedModules": false, "allowJs": true, "outDir": "lib", diff --git a/packages/integration-tests/ui-test-sequencer.js b/packages/integration-tests/ui-test-sequencer.js index a5c95f30e..3851a751e 100644 --- a/packages/integration-tests/ui-test-sequencer.js +++ b/packages/integration-tests/ui-test-sequencer.js @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const Sequencer = require('@jest/test-sequencer').default; -const bootstrapTestSuitePathSuffix = '/tests/ui/bootstrap.test.js'; +const bootstrapTestSuitePathSuffix = '/bootstrap.test.js'; class CustomSequencer extends Sequencer { sort(tests) { diff --git a/packages/ui/src/components/ErrorMessage/index.tsx b/packages/ui/src/components/ErrorMessage/index.tsx index e777e3e5f..e58745647 100644 --- a/packages/ui/src/components/ErrorMessage/index.tsx +++ b/packages/ui/src/components/ErrorMessage/index.tsx @@ -37,7 +37,11 @@ const ErrorMessage = ({ error, className, children }: Props) => { return t(error.code, { ...error.data }); }; - return
{getMessage()}
; + return ( +
+ {getMessage()} +
+ ); }; export default ErrorMessage; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29bd68951..ca4ccc1f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3500,15 +3500,9 @@ importers: '@silverhand/ts-config': specifier: 4.0.0 version: 4.0.0(typescript@5.0.2) - '@types/expect-puppeteer': - specifier: ^5.0.3 - version: 5.0.3 '@types/jest': specifier: ^29.4.0 version: 29.4.0 - '@types/jest-environment-puppeteer': - specifier: ^5.0.3 - version: 5.0.3 '@types/node': specifier: ^18.11.18 version: 18.11.18 @@ -3518,6 +3512,9 @@ importers: eslint: specifier: ^8.44.0 version: 8.44.0 + expect-puppeteer: + specifier: ^9.0.0 + version: 9.0.0 got: specifier: ^13.0.0 version: 13.0.0 @@ -6937,16 +6934,6 @@ packages: '@jest/types': 27.5.1 dev: true - /@jest/environment@27.5.1: - resolution: {integrity: sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - dependencies: - '@jest/fake-timers': 27.5.1 - '@jest/types': 27.5.1 - '@types/node': 18.11.18 - jest-mock: 27.5.1 - dev: true - /@jest/environment@29.5.0: resolution: {integrity: sha512-5FXw2+wD29YU1d4I2htpRX7jYnAyTRjP2CsXQdo9SAM8g3ifxWPSV0HnClSn71xwctr0U3oZIIH+dtbfmnbXVQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6974,18 +6961,6 @@ packages: - supports-color dev: true - /@jest/fake-timers@27.5.1: - resolution: {integrity: sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - dependencies: - '@jest/types': 27.5.1 - '@sinonjs/fake-timers': 8.1.0 - '@types/node': 18.11.18 - jest-message-util: 27.5.1 - jest-mock: 27.5.1 - jest-util: 27.5.1 - dev: true - /@jest/fake-timers@29.5.0: resolution: {integrity: sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9017,12 +8992,6 @@ packages: '@sinonjs/commons': 1.8.3 dev: true - /@sinonjs/fake-timers@8.1.0: - resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==} - dependencies: - '@sinonjs/commons': 1.8.3 - dev: true - /@sinonjs/fake-timers@9.1.2: resolution: {integrity: sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==} dependencies: @@ -9471,13 +9440,6 @@ packages: '@types/node': 18.11.18 dev: true - /@types/expect-puppeteer@5.0.3: - resolution: {integrity: sha512-NIqATm95VmFbc2s9v1L3yj9ZS9/rCrtptSgBsvW8mcw2KFpLFQqXPyEbo0Vju1eiBieI38jRGWgpbVuUKfQVoQ==} - dependencies: - '@types/jest': 29.4.0 - '@types/puppeteer': 5.4.6 - dev: true - /@types/express-serve-static-core@4.17.26: resolution: {integrity: sha512-zeu3tpouA043RHxW0gzRxwCHchMgftE8GArRsvYT0ByDMbn19olQHx5jLue0LxWY6iYtXb7rXmuVtSkhy9YZvQ==} dependencies: @@ -9561,14 +9523,6 @@ packages: '@types/istanbul-lib-report': 3.0.0 dev: true - /@types/jest-environment-puppeteer@5.0.3: - resolution: {integrity: sha512-vWGfeb+0TOPZy7+VscKURWzE5lzYjclSWLxtjVpDAYcjUv8arAS1av06xK3mpgeNCDVx7XvavD8Elq1a4w9wIA==} - dependencies: - '@jest/types': 27.5.1 - '@types/puppeteer': 5.4.6 - jest-environment-node: 27.5.1 - dev: true - /@types/jest@29.4.0: resolution: {integrity: sha512-VaywcGQ9tPorCX/Jkkni7RWGFfI11whqzs8dvxF41P17Z+z872thvEvlIbznjPJ02kl1HMX3LmLOonsj2n7HeQ==} dependencies: @@ -9735,12 +9689,6 @@ packages: resolution: {integrity: sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==} dev: true - /@types/puppeteer@5.4.6: - resolution: {integrity: sha512-98Kghehs7+/GD9b56qryhqdqVCXUTbetTv3PlvDnmFRTHQH0j9DIp1f7rkAW3BAj4U3yoeSEQnKgdW8bDq0Y0Q==} - dependencies: - '@types/node': 18.11.18 - dev: true - /@types/qs@6.9.7: resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} dev: true @@ -14410,18 +14358,6 @@ packages: - utf-8-validate dev: true - /jest-environment-node@27.5.1: - resolution: {integrity: sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - dependencies: - '@jest/environment': 27.5.1 - '@jest/fake-timers': 27.5.1 - '@jest/types': 27.5.1 - '@types/node': 18.11.18 - jest-mock: 27.5.1 - jest-util: 27.5.1 - dev: true - /jest-environment-node@29.5.0: resolution: {integrity: sha512-ExxuIK/+yQ+6PRGaHkKewYtg6hto2uGCgvKdb2nfJfKXgZ17DfXjvbZ+jA1Qt9A8EQSfPnt5FKIfnOO3u1h9qw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -14494,21 +14430,6 @@ packages: pretty-format: 29.5.0 dev: true - /jest-message-util@27.5.1: - resolution: {integrity: sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - dependencies: - '@babel/code-frame': 7.22.5 - '@jest/types': 27.5.1 - '@types/stack-utils': 2.0.1 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.5 - pretty-format: 27.5.1 - slash: 3.0.0 - stack-utils: 2.0.5 - dev: true - /jest-message-util@29.5.0: resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -14524,14 +14445,6 @@ packages: stack-utils: 2.0.5 dev: true - /jest-mock@27.5.1: - resolution: {integrity: sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - dependencies: - '@jest/types': 27.5.1 - '@types/node': 18.11.18 - dev: true - /jest-mock@29.5.0: resolution: {integrity: sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -14701,18 +14614,6 @@ packages: react: 18.2.0 dev: true - /jest-util@27.5.1: - resolution: {integrity: sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - dependencies: - '@jest/types': 27.5.1 - '@types/node': 18.11.18 - chalk: 4.1.2 - ci-info: 3.8.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - dev: true - /jest-util@29.5.0: resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} From 281fd55e63efdb2cf2ca798ceceac01676235450 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 12 Sep 2023 19:07:42 +0800 Subject: [PATCH 2/2] refactor(test): fix naming and add comments --- .../src/tests/flows/bootstrap.test.ts | 4 +- .../src/tests/flows/password-policy.test.ts | 8 +- .../src/ui-helpers/expect-flows.ts | 186 ++++++++++++++++++ .../src/ui-helpers/expect-journey.ts | 143 -------------- .../src/ui-helpers/expect-page.ts | 58 ++++++ 5 files changed, 250 insertions(+), 149 deletions(-) create mode 100644 packages/integration-tests/src/ui-helpers/expect-flows.ts delete mode 100644 packages/integration-tests/src/ui-helpers/expect-journey.ts diff --git a/packages/integration-tests/src/tests/flows/bootstrap.test.ts b/packages/integration-tests/src/tests/flows/bootstrap.test.ts index 54c4a9cf3..336e69b83 100644 --- a/packages/integration-tests/src/tests/flows/bootstrap.test.ts +++ b/packages/integration-tests/src/tests/flows/bootstrap.test.ts @@ -3,7 +3,7 @@ import { SignInMode, SignInIdentifier, ConnectorType } from '@logto/schemas'; import { updateSignInExperience } from '#src/api/sign-in-experience.js'; import { demoAppUrl } from '#src/constants.js'; import { clearConnectorsByTypes } from '#src/helpers/connector.js'; -import ExpectJourney from '#src/ui-helpers/expect-journey.js'; +import ExpectFlows from '#src/ui-helpers/expect-flows.js'; const credentials = { username: 'test_bootstrap', @@ -42,7 +42,7 @@ describe('smoke testing on the demo app', () => { }); it('should be able to create a new account with a credential preset', async () => { - const journey = new ExpectJourney(await browser.newPage()); + const journey = new ExpectFlows(await browser.newPage()); // Open the demo app and navigate to the register page await journey.startWith(demoAppUrl, 'register'); diff --git a/packages/integration-tests/src/tests/flows/password-policy.test.ts b/packages/integration-tests/src/tests/flows/password-policy.test.ts index 464b6428a..e691a0130 100644 --- a/packages/integration-tests/src/tests/flows/password-policy.test.ts +++ b/packages/integration-tests/src/tests/flows/password-policy.test.ts @@ -5,7 +5,7 @@ import { ConnectorType, SignInIdentifier, SignInMode } from '@logto/schemas'; import { updateSignInExperience } from '#src/api/sign-in-experience.js'; import { demoAppUrl } from '#src/constants.js'; import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js'; -import ExpectJourney from '#src/ui-helpers/expect-journey.js'; +import ExpectFlows from '#src/ui-helpers/expect-flows.js'; import { waitFor } from '#src/utils.js'; describe('password policy', () => { @@ -60,7 +60,7 @@ describe('password policy', () => { }); it('should work for username + password', async () => { - const journey = new ExpectJourney(await browser.newPage(), { forgotPassword: true }); + const journey = new ExpectFlows(await browser.newPage(), { forgotPassword: true }); // Open the demo app and navigate to the register page await journey.startWith(demoAppUrl, 'register'); @@ -86,7 +86,7 @@ describe('password policy', () => { verify: true, }, }); - const journey = new ExpectJourney(await browser.newPage(), { forgotPassword: true }); + const journey = new ExpectFlows(await browser.newPage(), { forgotPassword: true }); // Open the demo app and navigate to the register page await journey.startWith(demoAppUrl, 'register'); @@ -108,7 +108,7 @@ describe('password policy', () => { }); it('should work for forgot password', async () => { - const journey = new ExpectJourney(await browser.newPage(), { forgotPassword: true }); + const journey = new ExpectFlows(await browser.newPage(), { forgotPassword: true }); // Open the demo app and navigate to the register page await journey.startWith(demoAppUrl, 'sign-in'); diff --git a/packages/integration-tests/src/ui-helpers/expect-flows.ts b/packages/integration-tests/src/ui-helpers/expect-flows.ts new file mode 100644 index 000000000..84dbd8d8a --- /dev/null +++ b/packages/integration-tests/src/ui-helpers/expect-flows.ts @@ -0,0 +1,186 @@ +import { appendPath } from '@silverhand/essentials'; + +import { logtoUrl } from '#src/constants.js'; +import { readVerificationCode } from '#src/helpers/index.js'; + +import ExpectPage from './expect-page.js'; + +const demoAppUrl = appendPath(new URL(logtoUrl), 'demo-app'); + +/** Remove the query string together with the `?` from a URL string. */ +const stripQuery = (url: string) => url.split('?')[0]; + +export type FlowsType = 'sign-in' | 'register' | 'continue' | 'forgot-password'; + +export type FlowsPath = + | FlowsType + | `${FlowsType}/password` + | `${FlowsType}/verify` + | `${FlowsType}/verification-code` + | `forgot-password/reset`; + +export type ExpectFlowsOptions = { + /** The URL of the flows endpoint. */ + endpoint?: URL; + /** + * Whether the forgot password flow is enabled. + * + * @default false + */ + forgotPassword?: boolean; +}; + +type OngoingFlows = { + type: FlowsType; + initialUrl: URL; +}; + +/** + * A class that provides: + * + * - A set of methods to navigate to a specific page for a flows. + * - A set of methods to assert the state of a flows and its side effects. + */ +export default class ExpectFlows extends ExpectPage { + readonly options: Required; + + protected get flowsType() { + if (this.#ongoing === undefined) { + return this.throwNoOngoingFlowsError(); + } + return this.#ongoing.type; + } + + #ongoing?: OngoingFlows; + + constructor(thePage = global.page, options: ExpectFlowsOptions = {}) { + super(thePage); + this.options = { + endpoint: new URL(logtoUrl), + forgotPassword: false, + ...options, + }; + } + + /** + * Start flows with the given initial URL. Expect the initial URL is protected by Logto, and + * navigate to the flows sign-in page if unauthenticated. + * + * If the flows can be started, the instance will be marked as ongoing. + * + * @param initialUrl The initial URL to start the flows with. + * @param type The type of flows to expect. If it's `register`, it will try to click the "Create + * account" link on the sign-in page. + */ + async startWith(initialUrl = demoAppUrl, type: FlowsType = 'sign-in') { + await this.toStart(initialUrl); + this.toBeAt('sign-in'); + + if (type === 'register') { + await this.toClick('a', 'Create account'); + this.toBeAt('register'); + } + + this.#ongoing = { type, initialUrl }; + } + + /** + * Ensure the flows is ongoing and the page is at the initial URL; then try to click the "sign out" + * button (case-insensitive) and close the page. + * + * It will clear the ongoing flows if the flows is ended successfully. + */ + async verifyThenEnd() { + if (this.#ongoing === undefined) { + return this.throwNoOngoingFlowsError(); + } + + this.toMatchUrl(this.#ongoing.initialUrl); + await this.toClick('div[role=button]', /sign out/i); + + this.#ongoing = undefined; + await this.page.close(); + } + + /** + * Assert the page is at the given flows path. + * + * @param pathname The flows path to assert. + */ + toBeAt(pathname: FlowsPath) { + const stripped = stripQuery(this.page.url()); + expect(stripped).toBe(this.buildFlowsUrl(pathname).href); + } + + /** + * Assert the page is at the verification code page and fill the verification code input with the + * code from Logto database. + * + * @param type The type of flows to expect. + */ + async toCompleteVerification(type: FlowsType) { + this.toBeAt(`${type}/verification-code`); + const { code } = await readVerificationCode(); + + for (const [index, char] of code.split('').entries()) { + // eslint-disable-next-line no-await-in-loop + await this.toFillInput(`passcode_${index}`, char); + } + } + + /** + * Fill the password inputs with the given passwords. If the password is an array, the second + * element will be used to assert the error message; otherwise, the password is expected to be + * valid and the form will be submitted. + * + * @param passwords The passwords to fill. + * @example + * + * In the following example, the first password is expected to be rejected with the error message + * "simple password" (case-insensitive), and the second password is expected to be accepted. + * + * ```ts + * await journey.toFillPasswords( + * [credentials.pwnedPassword, 'simple password'], + * credentials.password, + * ); + * ``` + */ + async toFillPasswords( + ...passwords: Array + ) { + for (const element of passwords) { + const [password, errorMessage] = Array.isArray(element) ? element : [element, undefined]; + + // eslint-disable-next-line no-await-in-loop + await this.toFillForm( + this.options.forgotPassword + ? { newPassword: password } + : { + newPassword: password, + confirmPassword: password, + }, + { submit: true, shouldNavigate: errorMessage === undefined } + ); + + if (errorMessage === undefined) { + break; + } else { + // Reject the password and assert the error message + // eslint-disable-next-line no-await-in-loop + await this.toMatchAlert( + typeof errorMessage === 'string' ? new RegExp(errorMessage, 'i') : errorMessage + ); + } + } + } + + /** Build a full flows URL from a pathname. */ + protected buildFlowsUrl(pathname = '') { + return appendPath(this.options.endpoint, pathname); + } + + protected throwNoOngoingFlowsError() { + return this.throwError('The flows has not started yet. Use `startWith` to start the flows.'); + } +} diff --git a/packages/integration-tests/src/ui-helpers/expect-journey.ts b/packages/integration-tests/src/ui-helpers/expect-journey.ts deleted file mode 100644 index f7e6cc150..000000000 --- a/packages/integration-tests/src/ui-helpers/expect-journey.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { appendPath } from '@silverhand/essentials'; - -import { logtoUrl } from '#src/constants.js'; -import { readVerificationCode } from '#src/helpers/index.js'; - -import ExpectPage from './expect-page.js'; - -const demoAppUrl = appendPath(new URL(logtoUrl), 'demo-app'); - -/** Remove the query string together with the `?` from a URL string. */ -const stripQuery = (url: string) => url.split('?')[0]; - -export type JourneyType = 'sign-in' | 'register' | 'continue' | 'forgot-password'; - -export type JourneyPath = - | JourneyType - | `${JourneyType}/password` - | `${JourneyType}/verify` - | `${JourneyType}/verification-code` - | `forgot-password/reset`; - -export type ExpectJourneyOptions = { - /** The URL of the journey endpoint. */ - endpoint?: URL; - /** - * Whether the forgot password flow is enabled. - * - * @default false - */ - forgotPassword?: boolean; -}; - -type OngoingJourney = { - type: JourneyType; - initialUrl: URL; -}; - -/** - * A class that provides: - * - * - A set of methods to navigate to a specific page for a journey. - * - A set of methods to assert the state of a journey and its side effects. - */ -export default class ExpectJourney extends ExpectPage { - readonly options: Required; - - protected get journeyType() { - if (this.#ongoing === undefined) { - return this.throwNoOngoingJourneyError(); - } - return this.#ongoing.type; - } - - #ongoing?: OngoingJourney; - - constructor(thePage = global.page, options: ExpectJourneyOptions = {}) { - super(thePage); - this.options = { - endpoint: new URL(logtoUrl), - forgotPassword: false, - ...options, - }; - } - - async startWith(initialUrl = demoAppUrl, type: JourneyType = 'sign-in') { - await this.toStart(initialUrl); - this.toBeAt('sign-in'); - - if (type === 'register') { - await this.toClick('a', 'Create account'); - this.toBeAt('register'); - } - - this.#ongoing = { type, initialUrl }; - } - - async verifyThenEnd() { - if (this.#ongoing === undefined) { - return this.throwNoOngoingJourneyError(); - } - - this.toMatchUrl(this.#ongoing.initialUrl); - await this.toClick('div[role=button]', /sign out/i); - - this.#ongoing = undefined; - await this.page.close(); - } - - toBeAt(mode: JourneyPath) { - const stripped = stripQuery(this.page.url()); - expect(stripped).toBe(this.buildJourneyUrl(mode).href); - } - - async toCompleteVerification(type: JourneyType) { - this.toBeAt(`${type}/verification-code`); - const { code } = await readVerificationCode(); - - for (const [index, char] of code.split('').entries()) { - // eslint-disable-next-line no-await-in-loop - await this.toFillInput(`passcode_${index}`, char); - } - } - - async toFillPasswords( - ...passwords: Array - ) { - for (const element of passwords) { - const [password, errorMessage] = Array.isArray(element) ? element : [element, undefined]; - - // eslint-disable-next-line no-await-in-loop - await this.toFillForm( - this.options.forgotPassword - ? { newPassword: password } - : { - newPassword: password, - confirmPassword: password, - }, - { submit: true, shouldNavigate: errorMessage === undefined } - ); - - if (errorMessage === undefined) { - break; - } else { - // Reject the password and assert the error message - // eslint-disable-next-line no-await-in-loop - await this.toMatchAlert( - typeof errorMessage === 'string' ? new RegExp(errorMessage, 'i') : errorMessage - ); - } - } - } - - /** Build a full journey URL from a pathname. */ - protected buildJourneyUrl(pathname = '') { - return appendPath(this.options.endpoint, pathname); - } - - protected throwNoOngoingJourneyError() { - return this.throwError( - 'The journey has not started yet. Use `startWith` to start the journey.' - ); - } -} diff --git a/packages/integration-tests/src/ui-helpers/expect-page.ts b/packages/integration-tests/src/ui-helpers/expect-page.ts index 7f0ab7174..66913bd63 100644 --- a/packages/integration-tests/src/ui-helpers/expect-page.ts +++ b/packages/integration-tests/src/ui-helpers/expect-page.ts @@ -2,6 +2,7 @@ import { type ElementHandle, type Page } from 'puppeteer'; import { expectNavigation } from '#src/utils.js'; +/** Error thrown by {@link ExpectPage}. */ class ExpectPageError extends Error { constructor( message: string, @@ -17,20 +18,43 @@ class ExpectPageError extends Error { export default class ExpectPage { constructor(public readonly page = global.page) {} + /** + * Navigate to the given URL and wait for the page to load. Assert that an element with ID `app` + * is present. + * + * @param initialUrl The URL to navigate to. + */ async toStart(initialUrl: URL) { await expectNavigation(this.page.goto(initialUrl.href), this.page); await expect(this.page).toMatchElement('#app'); } + /** + * Click on the element matching the given selector and text. + * + * @param selector The selector to match. + * @param text The text to match, if provided. + * @param shouldNavigate Whether the click should trigger a navigation. Defaults to `true`. + */ async toClick(selector: string, text?: string | RegExp, shouldNavigate = true) { const clicked = expect(this.page).toClick(selector, { text }); return shouldNavigate ? expectNavigation(clicked, this.page) : clicked; } + /** + * Click on the `