diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 7710e550a..d51c0e494 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -13,7 +13,7 @@ concurrency: jobs: package: - runs-on: ubuntu-latest + runs-on: buildjet-4vcpu-ubuntu-2204 steps: - uses: actions/checkout@v4 @@ -36,7 +36,7 @@ jobs: strategy: fail-fast: false matrix: - target: [api, ui] + target: [api, experience, console] needs: package runs-on: ubuntu-latest diff --git a/packages/integration-tests/jest-puppeteer.config.js b/packages/integration-tests/jest-puppeteer.config.js new file mode 100644 index 000000000..c543e8f00 --- /dev/null +++ b/packages/integration-tests/jest-puppeteer.config.js @@ -0,0 +1,7 @@ +const config = { + launch: { + headless: Boolean(process.env.CI), + }, +}; + +export default config; diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 3a0f486de..b857b0c22 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -12,9 +12,10 @@ "scripts": { "build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", - "test": "pnpm build && pnpm test:api && pnpm test:ui", + "test": "pnpm build && pnpm test:api && pnpm test:experience && pnpm test:console", "test:api": "pnpm test:only -i ./lib/tests/api/", - "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/flows/", + "test:experience": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/flows/", + "test:console": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/console/", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "start": "pnpm test" diff --git a/packages/integration-tests/src/tests/console/bootstrap.test.ts b/packages/integration-tests/src/tests/console/bootstrap.test.ts index e0d8c497d..8f39dd103 100644 --- a/packages/integration-tests/src/tests/console/bootstrap.test.ts +++ b/packages/integration-tests/src/tests/console/bootstrap.test.ts @@ -119,6 +119,6 @@ describe('smoke testing for console admin account creation and sign-in', () => { }); it('renders SVG correctly with viewbox property', async () => { - await page.waitForSelector('div[class$=topbar] > svg[viewbox][class$=logo]'); + await page.waitForSelector('div[class$=topbar] > svg[viewbox][class$=logo]', { visible: true }); }); }); diff --git a/packages/integration-tests/src/tests/console/mfa/index.test.ts b/packages/integration-tests/src/tests/console/mfa/index.test.ts index 4ff7cc58c..e38d514bc 100644 --- a/packages/integration-tests/src/tests/console/mfa/index.test.ts +++ b/packages/integration-tests/src/tests/console/mfa/index.test.ts @@ -15,7 +15,8 @@ import { await page.setViewport({ width: 1920, height: 1080 }); -describe('multi-factor authentication', () => { +// Skip this test suite since it's not public yet +describe.skip('multi-factor authentication', () => { beforeAll(async () => { await goToAdminConsole(); }); diff --git a/packages/integration-tests/src/tests/console/sign-in-experience/password-policy.test.ts b/packages/integration-tests/src/tests/console/sign-in-experience/password-policy.test.ts new file mode 100644 index 000000000..d3cf21fe6 --- /dev/null +++ b/packages/integration-tests/src/tests/console/sign-in-experience/password-policy.test.ts @@ -0,0 +1,68 @@ +import ExpectConsole from '#src/ui-helpers/expect-console.js'; +import { getInputValue } from '#src/ui-helpers/index.js'; + +const expectConsole = new ExpectConsole(await browser.newPage(), { tenantId: 'default' }); + +// Skip this test suite since it's not public yet +describe.skip('sign-in experience: password policy', () => { + it('navigate to sign-in experience page', async () => { + await expectConsole.start(); + await expectConsole.gotoPage('/sign-in-experience', 'Sign-in experience'); + await expectConsole.toClickTab('Password policy'); + await expectConsole.toExpectCards('PASSWORD REQUIREMENTS', 'PASSWORD REJECTION'); + }); + + it('should be able to set minimum length', async () => { + const input = await expectConsole.getFieldInput('Minimum length'); + + // Add some zeros to make it invalid + await input.type('000'); + await input.evaluate((element) => { + element.blur(); + }); + + // Should automatically set to the max value if the input is too large + expect(await getInputValue(input)).toBe(await input.evaluate((element) => element.max)); + + // Clear the input + await input.evaluate((element) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + element.value = ''; + }); + + // Input a valid value + await input.type('10'); + + // Should be able to save + await expectConsole.toSaveChanges(); + }); + + it('should be able to see character types selections', async () => { + const inputs = await expectConsole.getFieldInputs('Character types'); + + for (const input of inputs) { + // eslint-disable-next-line no-await-in-loop + expect(await input.evaluate((element) => element.type)).toBe('radio'); + } + }); + + it('should be able to see the custom words checkbox and its text input when enabled', async () => { + const checkbox = await expect(expectConsole.page).toMatchElement('div[role=checkbox]', { + text: 'Custom words', + visible: true, + }); + const getChecked = async () => checkbox.evaluate((element) => element.ariaChecked); + + if ((await getChecked()) !== 'true') { + await checkbox.click(); + } + + expect(await getChecked()).toBe('true'); + + await expect(expectConsole.page).toMatchElement( + // Select the div with `checkbox` in class name, and followed by a textarea container + 'div[class*=checkbox]:has(+ div[class*=textarea] textarea)', + { text: 'Custom words', visible: true } + ); + }); +}); 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 e691a0130..6387e8fc7 100644 --- a/packages/integration-tests/src/tests/flows/password-policy.test.ts +++ b/packages/integration-tests/src/tests/flows/password-policy.test.ts @@ -133,7 +133,7 @@ describe('password policy', () => { emailName + 'ABCD135' ); - await journey.waitForToast(/password changed/i); + journey.toBeAt('sign-in'); 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-console.ts b/packages/integration-tests/src/ui-helpers/expect-console.ts new file mode 100644 index 000000000..c533ab66e --- /dev/null +++ b/packages/integration-tests/src/ui-helpers/expect-console.ts @@ -0,0 +1,123 @@ +import path from 'node:path'; + +import { appendPath } from '@silverhand/essentials'; + +import { consolePassword, consoleUsername, logtoConsoleUrl } from '#src/constants.js'; + +import ExpectPage, { ExpectPageError } from './expect-page.js'; +import { expectConfirmModalAndAct, expectToSaveChanges } from './index.js'; + +type ExpectConsoleOptions = { + /** The URL of the console endpoint. */ + endpoint?: URL; + /** + * The tenant ID to use for the Console. + * + * @default 'console' as the special tenant ID for OSS + */ + tenantId?: string; +}; + +export type ConsoleTitle = 'Sign-in experience'; + +export default class ExpectConsole extends ExpectPage { + readonly options: Required; + + constructor(thePage = global.page, options: ExpectConsoleOptions = {}) { + super(thePage); + this.options = { + endpoint: new URL(logtoConsoleUrl), + tenantId: 'console', + ...options, + }; + } + + async start() { + const { endpoint } = this.options; + await this.page.setViewport({ width: 1920, height: 1080 }); + + await this.navigateTo(endpoint); + + if (new URL(this.page.url()).pathname === '/sign-in') { + await this.toFillForm({ + identifier: consoleUsername, + password: consolePassword, + }); + await this.toClickSubmit(); + } + } + + /** + * Navigate to a specific page in the Console. + */ + async gotoPage(pathname: string, title: ConsoleTitle) { + await this.navigateTo(this.buildUrl(path.join(this.options.tenantId, pathname))); + await expect(this.page).toMatchElement( + 'div[class$=main] div[class$=container] div[class$=cardTitle]', + { text: title } + ); + } + + /** + * Expect card components to be rendered in the Console. + * + * @param titles The titles of the cards to expect, case-insensitive. + */ + async toExpectCards(...titles: string[]) { + await Promise.all( + titles.map(async (title) => { + return expect(this.page).toMatchElement( + 'div[class$=tabContent] div[class$=card] div[class$=title]', + { text: new RegExp(title, 'i'), visible: true } + ); + }) + ); + } + + async getFieldInputs(title: string) { + const fieldTitle = await expect(this.page).toMatchElement( + // Use `:has()` for a quick and dirty way to match the field title. + // Not harmful in most cases. + 'div[class$=field]:has(div[class$=title])', + { + text: new RegExp(title, 'i'), + visible: true, + } + ); + + return fieldTitle.$$('input'); + } + + async getFieldInput(title: string) { + const [input] = await this.getFieldInputs(title); + if (!input) { + throw new ExpectPageError(`No input found for field "${title}"`, this.page); + } + return input; + } + + /** + * Click a `