test(ui): implement base page object and enhance sign-up flow tests

- Introduced a BasePage class to encapsulate common functionality for page objects, improving code reusability and maintainability.
- Created new page objects for HomePage, SignInPage, and SignUpPage to streamline the sign-up and login processes in tests.
- Added comprehensive sign-up flow tests to validate user registration and login functionality, ensuring a smooth user experience.
- Updated Playwright configuration to support new test structures and improve overall test organization.
This commit is contained in:
StylusFrost
2025-10-09 16:19:27 +02:00
parent d21222aa3a
commit 9b7e4f59e1
9 changed files with 359 additions and 82 deletions
+4 -4
View File
@@ -15,10 +15,10 @@
"format:check": "./node_modules/.bin/prettier --check ./app",
"format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app",
"prepare": "husky",
"test:e2e": "playwright test --project=chromium",
"test:e2e:ui": "playwright test --project=chromium --ui",
"test:e2e:debug": "playwright test --project=chromium --debug",
"test:e2e:headed": "playwright test --project=chromium --headed",
"test:e2e": "playwright test --project=chromium --project=sign-up",
"test:e2e:ui": "playwright test --project=chromium --project=sign-up --ui",
"test:e2e:debug": "playwright test --project=chromium --project=sign-up --debug",
"test:e2e:headed": "playwright test --project=chromium --project=sign-up --headed",
"test:e2e:report": "playwright show-report",
"test:e2e:install": "playwright install"
},
+6 -8
View File
@@ -74,14 +74,6 @@ export default defineConfig({
name: "invite-and-manage-users.auth.setup",
testMatch: "invite-and-manage-users.auth.setup.ts",
},
// All authentication setups combined
// Runs all authentication setup files to create all user states
{
name: "all.auth.setup",
testMatch: "**/*.auth.setup.ts",
},
// ===========================================
// Test Suite Projects
// ===========================================
@@ -89,6 +81,12 @@ export default defineConfig({
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
testMatch: "auth-login.spec.ts",
},
// This project runs the sign-up test suite
{
name: "sign-up",
testMatch: "sign-up.spec.ts",
},
],
+159
View File
@@ -0,0 +1,159 @@
import { Page, Locator, expect } from "@playwright/test";
/**
* Base page object class containing common functionality
* that can be shared across all page objects
*/
export abstract class BasePage {
readonly page: Page;
// Common UI elements that appear on most pages
readonly title: Locator;
readonly loadingIndicator: Locator;
readonly themeToggle: Locator;
constructor(page: Page) {
this.page = page;
// Common locators that most pages share
this.title = page.locator("h1, h2, [role='heading']").first();
this.loadingIndicator = page.getByText("Loading");
this.themeToggle = page.getByLabel("Toggle theme");
}
// Common navigation methods
async goto(url: string): Promise<void> {
await this.page.goto(url);
await this.waitForPageLoad();
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
async refresh(): Promise<void> {
await this.page.reload();
await this.waitForPageLoad();
}
async goBack(): Promise<void> {
await this.page.goBack();
await this.waitForPageLoad();
}
// Common verification methods
async verifyPageTitle(expectedTitle: string | RegExp): Promise<void> {
await expect(this.page).toHaveTitle(expectedTitle);
}
async verifyLoadingState(): Promise<void> {
await expect(this.loadingIndicator).toBeVisible();
}
async verifyNoLoadingState(): Promise<void> {
await expect(this.loadingIndicator).not.toBeVisible();
}
// Common form interaction methods
async clearInput(input: Locator): Promise<void> {
await input.clear();
}
async fillInput(input: Locator, value: string): Promise<void> {
await input.fill(value);
}
async clickButton(button: Locator): Promise<void> {
await button.click();
}
// Common validation methods
async verifyElementVisible(element: Locator): Promise<void> {
await expect(element).toBeVisible();
}
async verifyElementNotVisible(element: Locator): Promise<void> {
await expect(element).not.toBeVisible();
}
async verifyElementText(element: Locator, expectedText: string): Promise<void> {
await expect(element).toHaveText(expectedText);
}
async verifyElementContainsText(element: Locator, expectedText: string): Promise<void> {
await expect(element).toContainText(expectedText);
}
// Common accessibility methods
async verifyKeyboardNavigation(elements: Locator[]): Promise<void> {
for (const element of elements) {
await this.page.keyboard.press("Tab");
await expect(element).toBeFocused();
}
}
async verifyAriaLabels(elements: { locator: Locator; expectedLabel: string }[]): Promise<void> {
for (const { locator, expectedLabel } of elements) {
await expect(locator).toHaveAttribute("aria-label", expectedLabel);
}
}
// Common utility methods
async getElementText(element: Locator): Promise<string> {
return await element.textContent() || "";
}
async getElementValue(element: Locator): Promise<string> {
return await element.inputValue();
}
async isElementVisible(element: Locator): Promise<boolean> {
return await element.isVisible();
}
async isElementEnabled(element: Locator): Promise<boolean> {
return await element.isEnabled();
}
// Common error handling methods
async getFormErrors(): Promise<string[]> {
const errorElements = await this.page.locator('[role="alert"], .error-message, [data-testid="error"]').all();
const errors: string[] = [];
for (const element of errorElements) {
const text = await element.textContent();
if (text) {
errors.push(text.trim());
}
}
return errors;
}
async verifyNoErrors(): Promise<void> {
const errors = await this.getFormErrors();
expect(errors).toHaveLength(0);
}
// Common wait methods
async waitForElement(element: Locator, timeout: number = 5000): Promise<void> {
await element.waitFor({ timeout });
}
async waitForElementToDisappear(element: Locator, timeout: number = 5000): Promise<void> {
await element.waitFor({ state: "hidden", timeout });
}
async waitForUrl(expectedUrl: string | RegExp, timeout: number = 5000): Promise<void> {
await this.page.waitForURL(expectedUrl, { timeout });
}
// Common screenshot methods
async takeScreenshot(name: string): Promise<void> {
await this.page.screenshot({ path: `screenshots/${name}.png` });
}
async takeElementScreenshot(element: Locator, name: string): Promise<void> {
await element.screenshot({ path: `screenshots/${name}.png` });
}
}
+13 -1
View File
@@ -1,5 +1,5 @@
import { Page, expect } from "@playwright/test";
import { SignInPage, SignInCredentials } from "./page-objects/sign-in-page";
import { SignInPage, SignInCredentials } from "./sign-in/sign-in-page";
export const ERROR_MESSAGES = {
INVALID_CREDENTIALS: "Invalid email or password",
@@ -161,3 +161,15 @@ export async function authenticateAndSaveState(
// Save authentication state
await page.context().storageState({ path: storagePath });
}
/**
* Generate a random base36 suffix of specified length
* Used for creating unique test data to avoid conflicts
*/
export function makeSuffix(len: number): string {
let s = "";
while (s.length < len) {
s += Math.random().toString(36).slice(2);
}
return s.slice(0, len);
}
@@ -1,7 +1,7 @@
import { Page, Locator, expect } from "@playwright/test";
import { BasePage } from "../base-page";
export class HomePage {
readonly page: Page;
export class HomePage extends BasePage {
// Main content elements
readonly mainContent: Locator;
@@ -18,11 +18,10 @@ export class HomePage {
readonly overviewSection: Locator;
// UI elements
readonly themeToggle: Locator;
readonly logo: Locator;
constructor(page: Page) {
this.page = page;
super(page);
// Main content elements
this.mainContent = page.locator("main");
@@ -39,18 +38,12 @@ export class HomePage {
this.overviewSection = page.locator('[data-testid="overview-section"]');
// UI elements
this.themeToggle = page.getByLabel("Toggle theme");
this.logo = page.locator('svg[width="300"]');
}
// Navigation methods
async goto(): Promise<void> {
await this.page.goto("/");
await this.waitForPageLoad();
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
await super.goto("/");
}
// Verification methods
@@ -93,15 +86,6 @@ export class HomePage {
}
// Utility methods
async refresh(): Promise<void> {
await this.page.reload();
await this.waitForPageLoad();
}
async goBack(): Promise<void> {
await this.page.goBack();
await this.waitForPageLoad();
}
// Accessibility methods
async verifyKeyboardNavigation(): Promise<void> {
@@ -110,11 +94,6 @@ export class HomePage {
await expect(this.themeToggle).toBeFocused();
}
// Wait methods
async waitForRedirect(expectedUrl: string): Promise<void> {
await this.page.waitForURL(expectedUrl);
}
async waitForContentLoad(): Promise<void> {
await this.page.waitForFunction(() => {
const main = document.querySelector("main");
@@ -20,7 +20,7 @@ import {
ERROR_MESSAGES,
URLS,
verifyLoadingState,
} from "./helpers";
} from "../helpers";
test.describe("Login Flow", () => {
test.beforeEach(async ({ page }) => {
@@ -1,5 +1,6 @@
import { Page, Locator, expect } from "@playwright/test";
import { HomePage } from "./home-page";
import { BasePage } from "../base-page";
import { HomePage } from "../home/home-page";
export interface SignInCredentials {
email: string;
@@ -11,8 +12,7 @@ export interface SocialAuthConfig {
githubEnabled: boolean;
}
export class SignInPage {
readonly page: Page;
export class SignInPage extends BasePage {
readonly homePage: HomePage;
// Form elements
@@ -31,20 +31,17 @@ export class SignInPage {
readonly backButton: Locator;
// UI elements
readonly title: Locator;
readonly logo: Locator;
readonly themeToggle: Locator;
// Error messages
readonly errorMessages: Locator;
readonly loadingIndicator: Locator;
// SAML specific elements
readonly samlModeTitle: Locator;
readonly samlEmailInput: Locator;
constructor(page: Page) {
this.page = page;
super(page);
this.homePage = new HomePage(page);
// Form elements
@@ -63,13 +60,10 @@ export class SignInPage {
this.backButton = page.getByText("Back");
// UI elements
this.title = page.getByText("Sign in", { exact: true });
this.logo = page.locator('svg[width="300"]');
this.themeToggle = page.getByLabel("Toggle theme");
// Error messages
this.errorMessages = page.locator('[role="alert"], .error-message, [data-testid="error"]');
this.loadingIndicator = page.getByText("Loading");
// SAML specific elements
this.samlModeTitle = page.getByText("Sign in with SAML SSO");
@@ -78,12 +72,7 @@ export class SignInPage {
// Navigation methods
async goto(): Promise<void> {
await this.page.goto("/sign-in");
await this.waitForPageLoad();
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
await super.goto("/sign-in");
}
// Form interaction methods
@@ -148,7 +137,7 @@ export class SignInPage {
async verifyPageLoaded(): Promise<void> {
await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.logo).toBeVisible();
await expect(this.title).toBeVisible();
await expect(this.page.getByText("Sign in", { exact: true })).toBeVisible();
}
async verifyFormElements(): Promise<void> {
@@ -188,13 +177,13 @@ export class SignInPage {
}
async verifyNormalModeActive(): Promise<void> {
await expect(this.title).toBeVisible();
await expect(this.page.getByText("Sign in", { exact: true })).toBeVisible();
await expect(this.passwordInput).toBeVisible();
}
async verifyLoadingState(): Promise<void> {
await expect(this.loginButton).toHaveAttribute("aria-disabled", "true");
await expect(this.loadingIndicator).toBeVisible();
await super.verifyLoadingState();
}
async verifyFormValidation(): Promise<void> {
@@ -239,30 +228,7 @@ export class SignInPage {
return emailValue.length > 0 && passwordValue.length > 0;
}
async getFormErrors(): Promise<string[]> {
const errorElements = await this.errorMessages.all();
const errors: string[] = [];
for (const element of errorElements) {
const text = await element.textContent();
if (text) {
errors.push(text.trim());
}
}
return errors;
}
// Browser interaction methods
async refresh(): Promise<void> {
await this.page.reload();
await this.waitForPageLoad();
}
async goBack(): Promise<void> {
await this.page.goBack();
await this.waitForPageLoad();
}
// Session management methods
async logout(): Promise<void> {
@@ -271,7 +237,7 @@ export class SignInPage {
async verifyLogoutSuccess(): Promise<void> {
await expect(this.page).toHaveURL("/sign-in");
await expect(this.title).toBeVisible();
await expect(this.page.getByText("Sign in", { exact: true })).toBeVisible();
}
// Advanced interaction methods
+121
View File
@@ -0,0 +1,121 @@
import { Page, Locator, expect } from "@playwright/test";
import { BasePage } from "../base-page";
export interface SignUpData {
name: string;
email: string;
password: string;
confirmPassword: string;
company?: string;
invitationToken?: string | null;
acceptTerms?: boolean;
}
export class SignUpPage extends BasePage {
// Form inputs
readonly nameInput: Locator;
readonly companyInput: Locator;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly confirmPasswordInput: Locator;
readonly invitationTokenInput: Locator;
// UI elements
readonly submitButton: Locator;
readonly loginLink: Locator;
readonly termsCheckbox: Locator;
constructor(page: Page) {
super(page);
// Prefer stable name attributes to avoid label ambiguity in composed inputs
this.nameInput = page.locator('input[name="name"]');
this.companyInput = page.locator('input[name="company"]');
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.locator('input[name="password"]');
this.confirmPasswordInput = page.locator('input[name="confirmPassword"]');
this.invitationTokenInput = page.locator('input[name="invitationToken"]');
this.submitButton = page.getByRole("button", { name: "Sign up" });
this.loginLink = page.getByRole("link", { name: "Log in" });
this.termsCheckbox = page.getByText("I agree with the");
}
async goto(): Promise<void> {
await super.goto("/sign-up");
}
async verifyPageLoaded(): Promise<void> {
await expect(this.page.getByText("Sign up", { exact: true })).toBeVisible();
await expect(this.emailInput).toBeVisible();
await expect(this.submitButton).toBeVisible();
}
async fillName(name: string): Promise<void> {
await this.nameInput.fill(name);
}
async fillCompany(company?: string): Promise<void> {
if (company) {
await this.companyInput.fill(company);
}
}
async fillEmail(email: string): Promise<void> {
await this.emailInput.fill(email);
}
async fillPassword(password: string): Promise<void> {
await this.passwordInput.fill(password);
}
async fillConfirmPassword(confirmPassword: string): Promise<void> {
await this.confirmPasswordInput.fill(confirmPassword);
}
async fillInvitationToken(token?: string | null): Promise<void> {
if (token) {
await this.invitationTokenInput.fill(token);
}
}
async acceptTermsIfPresent(accept: boolean = true): Promise<void> {
// Only in cloud env; check presence before interacting
if (await this.termsCheckbox.isVisible()) {
if (accept) {
await this.termsCheckbox.click();
}
}
}
async submit(): Promise<void> {
await this.submitButton.click();
}
async signup(data: SignUpData): Promise<void> {
await this.fillName(data.name);
await this.fillCompany(data.company);
await this.fillEmail(data.email);
await this.fillPassword(data.password);
await this.fillConfirmPassword(data.confirmPassword);
await this.fillInvitationToken(data.invitationToken ?? undefined);
await this.acceptTermsIfPresent(data.acceptTerms ?? true);
await this.submit();
}
async verifyLoadingState(): Promise<void> {
await expect(this.submitButton).toHaveAttribute("aria-disabled", "true");
await super.verifyLoadingState();
}
async verifyRedirectToLogin(): Promise<void> {
await expect(this.page).toHaveURL("/sign-in");
}
async verifyRedirectToEmailVerification(): Promise<void> {
await expect(this.page).toHaveURL("/email-verification");
}
}
+42
View File
@@ -0,0 +1,42 @@
import { test } from "@playwright/test";
import { SignUpPage } from "./sign-up-page";
import { SignInPage } from "../sign-in/sign-in-page";
import { makeSuffix } from "../helpers";
test.describe("Sign Up Flow", () => {
test("should register a new user successfully", { tag: ['@critical'] }, async ({ page }) => {
// Initialize page objects for sign-up and sign-in flows
const signUpPage = new SignUpPage(page);
await signUpPage.goto();
// Generate unique test data to avoid conflicts with existing users
// Create a base36 random suffix of exactly 10 characters for uniqueness
const suffix = makeSuffix(10);
const uniqueEmail = `e2e+${suffix}@prowler.com`;
// Fill and submit the sign-up form with valid test data
// Name is exactly 19 characters to meet the max length requirement (20 chars)
await signUpPage.signup({
name: `E2E User ${suffix}`, // 9 chars + 10 chars = 19 chars
company: `Test E2E Co ${suffix}`,
email: uniqueEmail,
password: "Thisisapassword123@",
confirmPassword: "Thisisapassword123@",
acceptTerms: true,
});
// Verify successful sign-up redirects to login page (OSS environment)
await signUpPage.verifyRedirectToLogin();
// Verify the newly created user can successfully log in
// This ensures the user account was properly created and is functional
const signInPage = new SignInPage(page);
await signInPage.login({
email: uniqueEmail,
password: "Thisisapassword123@",
});
await signInPage.verifySuccessfulLogin();
});
});