0
Fork 0
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:
Gao Sun 2023-09-14 17:28:00 +08:00 committed by GitHub
parent 2a64e7f32d
commit e1fac554db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 240 additions and 20 deletions

View file

@ -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

View file

@ -0,0 +1,7 @@
const config = {
launch: {
headless: Boolean(process.env.CI),
},
};
export default config;

View file

@ -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"

View file

@ -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 });
});
});

View file

@ -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();
});

View file

@ -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 }
);
});
});

View file

@ -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();

View 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);
}
}

View file

@ -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) => {

View file

@ -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);
};

View file

@ -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);
}
}