mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
test: add console tests for password policy (#4493)
* test: add console tests for password policy * test: increase time duration * test: fix sequencer * chore: add jest-pptr config * ci: separate experience and console tests * test: skip tests of unpublished features
This commit is contained in:
parent
2a64e7f32d
commit
e1fac554db
11 changed files with 240 additions and 20 deletions
4
.github/workflows/integration-test.yml
vendored
4
.github/workflows/integration-test.yml
vendored
|
@ -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
|
||||
|
|
7
packages/integration-tests/jest-puppeteer.config.js
Normal file
7
packages/integration-tests/jest-puppeteer.config.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
launch: {
|
||||
headless: Boolean(process.env.CI),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -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"
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
|
123
packages/integration-tests/src/ui-helpers/expect-console.ts
Normal file
123
packages/integration-tests/src/ui-helpers/expect-console.ts
Normal file
|
@ -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<ExpectConsoleOptions>;
|
||||
|
||||
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 `<nav>` navigation tab (not the page tab) in the Console.
|
||||
*/
|
||||
async toClickTab(tabName: string | RegExp) {
|
||||
await expect(this.page).toClick(`nav div[class$=item] div[class$=link] a`, { text: tabName });
|
||||
}
|
||||
|
||||
async toSaveChanges(confirmation?: string | RegExp) {
|
||||
await expectToSaveChanges(this.page);
|
||||
|
||||
if (confirmation) {
|
||||
await expectConfirmModalAndAct(this.page, {
|
||||
title: confirmation,
|
||||
actionText: 'Confirm',
|
||||
});
|
||||
}
|
||||
|
||||
await this.waitForToast('Saved', 'success');
|
||||
}
|
||||
|
||||
/** Build a full Console URL from a pathname. */
|
||||
protected buildUrl(pathname = '') {
|
||||
return appendPath(this.options.endpoint, pathname);
|
||||
}
|
||||
}
|
|
@ -1,9 +1,10 @@
|
|||
import { condString } from '@silverhand/essentials';
|
||||
import { type ElementHandle, type Page } from 'puppeteer';
|
||||
|
||||
import { expectNavigation } from '#src/utils.js';
|
||||
|
||||
/** Error thrown by {@link ExpectPage}. */
|
||||
class ExpectPageError extends Error {
|
||||
export class ExpectPageError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly page: Page
|
||||
|
@ -122,15 +123,29 @@ export default class ExpectPage {
|
|||
expect(this.page.url()).toBe(typeof url === 'string' ? url : url.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the given URL and wait for the page to be navigated.
|
||||
*/
|
||||
async navigateTo(url: URL | string) {
|
||||
const [result] = await Promise.all([
|
||||
this.page.goto(typeof url === 'string' ? url : url.href),
|
||||
this.page.waitForNavigation({ waitUntil: 'networkidle0' }),
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a toast to appear with the given text, then remove it immediately.
|
||||
*
|
||||
* @param text The text to match.
|
||||
*/
|
||||
async waitForToast(text: string | RegExp) {
|
||||
const toast = await expect(this.page).toMatchElement(`.ReactModal__Content[class*=toast]`, {
|
||||
text,
|
||||
});
|
||||
async waitForToast(text: string | RegExp, type?: 'success' | 'error') {
|
||||
const toast = await expect(this.page).toMatchElement(
|
||||
`[class*=toast]${condString(type && `[class*=${type}]`)}:has(div[class$=message]`,
|
||||
{
|
||||
text,
|
||||
}
|
||||
);
|
||||
|
||||
// Remove immediately to prevent waiting for the toast to disappear and matching the same toast again
|
||||
await toast.evaluate((element) => {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { type Browser, type Page } from 'puppeteer';
|
||||
import { type ElementHandle, type Browser, type Page } from 'puppeteer';
|
||||
|
||||
import {
|
||||
consolePassword,
|
||||
consoleUsername,
|
||||
logtoConsoleUrl as logtoConsoleUrlString,
|
||||
} from '#src/constants.js';
|
||||
import { expectNavigation } from '#src/utils.js';
|
||||
import { expectNavigation, waitFor } from '#src/utils.js';
|
||||
|
||||
export const goToAdminConsole = async () => {
|
||||
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
|
||||
|
@ -50,13 +50,13 @@ export const expectUnsavedChangesAlert = async (page: Page) => {
|
|||
|
||||
export const expectToSaveChanges = async (page: Page) => {
|
||||
// Wait for the action bar to finish animating
|
||||
await page.waitForTimeout(500);
|
||||
await waitFor(500);
|
||||
await expect(page).toClick('div[class$=actionBar] button span', { text: 'Save Changes' });
|
||||
};
|
||||
|
||||
export const expectToDiscardChanges = async (page: Page) => {
|
||||
// Wait for the action bar to finish animating
|
||||
await page.waitForTimeout(500);
|
||||
await waitFor(500);
|
||||
await expect(page).toClick('div[class$=actionBar] button span', { text: 'Discard' });
|
||||
};
|
||||
|
||||
|
@ -73,7 +73,7 @@ export const expectToClickDetailsPageOption = async (page: Page, optionText: str
|
|||
);
|
||||
|
||||
// Wait for the dropdown menu to be rendered in the correct position
|
||||
await page.waitForTimeout(500);
|
||||
await waitFor(500);
|
||||
|
||||
await expect(page).toClick('.ReactModalPortal div[class$=dropdownContainer] div[role=menuitem]', {
|
||||
text: optionText,
|
||||
|
@ -142,3 +142,7 @@ export const expectToClickSidebarMenu = async (page: Page, menuText: string) =>
|
|||
text: menuText,
|
||||
});
|
||||
};
|
||||
|
||||
export const getInputValue = async (input: ElementHandle<HTMLInputElement>) => {
|
||||
return input.evaluate((element) => element.value);
|
||||
};
|
||||
|
|
|
@ -7,10 +7,11 @@ const bootstrapTestSuitePathSuffix = '/bootstrap.test.js';
|
|||
|
||||
class CustomSequencer extends Sequencer {
|
||||
sort(tests) {
|
||||
const bootstrap = tests.find(({ path }) => path.includes(bootstrapTestSuitePathSuffix));
|
||||
// Let the bootstrap test suite does its job first
|
||||
const bootstrap = tests.filter(({ path }) => path.endsWith(bootstrapTestSuitePathSuffix));
|
||||
return [
|
||||
bootstrap,
|
||||
...tests.filter(({ path }) => !path.includes(bootstrapTestSuitePathSuffix)),
|
||||
...bootstrap,
|
||||
...tests.filter(({ path }) => !path.endsWith(bootstrapTestSuitePathSuffix)),
|
||||
].filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue