mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -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:
|
jobs:
|
||||||
package:
|
package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
@ -36,7 +36,7 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
target: [api, ui]
|
target: [api, experience, console]
|
||||||
|
|
||||||
needs: package
|
needs: package
|
||||||
runs-on: ubuntu-latest
|
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": {
|
"scripts": {
|
||||||
"build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
|
"build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
|
||||||
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
|
"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: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": "eslint --ext .ts src",
|
||||||
"lint:report": "pnpm lint --format json --output-file report.json",
|
"lint:report": "pnpm lint --format json --output-file report.json",
|
||||||
"start": "pnpm test"
|
"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 () => {
|
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 });
|
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 () => {
|
beforeAll(async () => {
|
||||||
await goToAdminConsole();
|
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'
|
emailName + 'ABCD135'
|
||||||
);
|
);
|
||||||
|
|
||||||
await journey.waitForToast(/password changed/i);
|
journey.toBeAt('sign-in');
|
||||||
await journey.toFillInput('identifier', email, { submit: true });
|
await journey.toFillInput('identifier', email, { submit: true });
|
||||||
await journey.toFillInput('password', emailName + 'ABCD135', { submit: true });
|
await journey.toFillInput('password', emailName + 'ABCD135', { submit: true });
|
||||||
await journey.verifyThenEnd();
|
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 { type ElementHandle, type Page } from 'puppeteer';
|
||||||
|
|
||||||
import { expectNavigation } from '#src/utils.js';
|
import { expectNavigation } from '#src/utils.js';
|
||||||
|
|
||||||
/** Error thrown by {@link ExpectPage}. */
|
/** Error thrown by {@link ExpectPage}. */
|
||||||
class ExpectPageError extends Error {
|
export class ExpectPageError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly page: Page
|
public readonly page: Page
|
||||||
|
@ -122,15 +123,29 @@ export default class ExpectPage {
|
||||||
expect(this.page.url()).toBe(typeof url === 'string' ? url : url.href);
|
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.
|
* Expect a toast to appear with the given text, then remove it immediately.
|
||||||
*
|
*
|
||||||
* @param text The text to match.
|
* @param text The text to match.
|
||||||
*/
|
*/
|
||||||
async waitForToast(text: string | RegExp) {
|
async waitForToast(text: string | RegExp, type?: 'success' | 'error') {
|
||||||
const toast = await expect(this.page).toMatchElement(`.ReactModal__Content[class*=toast]`, {
|
const toast = await expect(this.page).toMatchElement(
|
||||||
text,
|
`[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
|
// Remove immediately to prevent waiting for the toast to disappear and matching the same toast again
|
||||||
await toast.evaluate((element) => {
|
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 {
|
import {
|
||||||
consolePassword,
|
consolePassword,
|
||||||
consoleUsername,
|
consoleUsername,
|
||||||
logtoConsoleUrl as logtoConsoleUrlString,
|
logtoConsoleUrl as logtoConsoleUrlString,
|
||||||
} from '#src/constants.js';
|
} from '#src/constants.js';
|
||||||
import { expectNavigation } from '#src/utils.js';
|
import { expectNavigation, waitFor } from '#src/utils.js';
|
||||||
|
|
||||||
export const goToAdminConsole = async () => {
|
export const goToAdminConsole = async () => {
|
||||||
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
|
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
|
||||||
|
@ -50,13 +50,13 @@ export const expectUnsavedChangesAlert = async (page: Page) => {
|
||||||
|
|
||||||
export const expectToSaveChanges = async (page: Page) => {
|
export const expectToSaveChanges = async (page: Page) => {
|
||||||
// Wait for the action bar to finish animating
|
// 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' });
|
await expect(page).toClick('div[class$=actionBar] button span', { text: 'Save Changes' });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const expectToDiscardChanges = async (page: Page) => {
|
export const expectToDiscardChanges = async (page: Page) => {
|
||||||
// Wait for the action bar to finish animating
|
// 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' });
|
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
|
// 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]', {
|
await expect(page).toClick('.ReactModalPortal div[class$=dropdownContainer] div[role=menuitem]', {
|
||||||
text: optionText,
|
text: optionText,
|
||||||
|
@ -142,3 +142,7 @@ export const expectToClickSidebarMenu = async (page: Page, menuText: string) =>
|
||||||
text: menuText,
|
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 {
|
class CustomSequencer extends Sequencer {
|
||||||
sort(tests) {
|
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 [
|
return [
|
||||||
bootstrap,
|
...bootstrap,
|
||||||
...tests.filter(({ path }) => !path.includes(bootstrapTestSuitePathSuffix)),
|
...tests.filter(({ path }) => !path.endsWith(bootstrapTestSuitePathSuffix)),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue