From 281fd55e63efdb2cf2ca798ceceac01676235450 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 12 Sep 2023 19:07:42 +0800 Subject: [PATCH] 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 `