feat: add Playwright E2E and API regression test suite
Production-ready test infrastructure with Page Object Model pattern, reusable fixtures for auth/DB/test-data, and example tests covering login flow, dashboard, accounts CRUD API, and visual regression. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
82
tests/page-objects/AccountsPage.ts
Normal file
82
tests/page-objects/AccountsPage.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Page object for the Accounts page (/accounts).
|
||||
*
|
||||
* Maps to: frontend/src/pages/accounts/AccountsPage.tsx
|
||||
* Data: GET /api/accounts, POST /api/accounts, PUT /api/accounts/:id
|
||||
*/
|
||||
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class AccountsPage extends BasePage {
|
||||
readonly path = '/accounts';
|
||||
|
||||
// ─── Locators ────────────────────────────────────────────────
|
||||
|
||||
get heading() {
|
||||
return this.page.getByRole('heading', { name: /accounts|chart of accounts/i }).first();
|
||||
}
|
||||
|
||||
get addAccountButton() {
|
||||
return this.page.getByRole('button', { name: /add|create|new/i }).first();
|
||||
}
|
||||
|
||||
/** Account name input in the create/edit modal */
|
||||
get nameInput() {
|
||||
return this.page.getByLabel(/name/i).first();
|
||||
}
|
||||
|
||||
/** Account number input */
|
||||
get numberInput() {
|
||||
return this.page.getByLabel(/number/i).first();
|
||||
}
|
||||
|
||||
/** Account type select */
|
||||
get typeSelect() {
|
||||
return this.page.getByLabel(/type/i).first();
|
||||
}
|
||||
|
||||
/** Save/submit button in modal */
|
||||
get saveButton() {
|
||||
return this.page.getByRole('button', { name: /save|create|submit/i }).first();
|
||||
}
|
||||
|
||||
/** Cancel button in modal */
|
||||
get cancelButton() {
|
||||
return this.page.getByRole('button', { name: /cancel/i }).first();
|
||||
}
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
override async waitForReady(): Promise<void> {
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
// Wait for the accounts list or heading to be visible
|
||||
await expect(this.page.locator('main')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Get the count of visible account rows */
|
||||
async getAccountCount(): Promise<number> {
|
||||
return this.tableRows.count();
|
||||
}
|
||||
|
||||
/** Find an account row by name */
|
||||
accountRow(name: string) {
|
||||
return this.tableBody.locator('tr').filter({ hasText: name });
|
||||
}
|
||||
|
||||
/** Assert an account exists in the table */
|
||||
async assertAccountExists(name: string): Promise<void> {
|
||||
await expect(this.accountRow(name)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert an account does NOT exist in the table */
|
||||
async assertAccountNotExists(name: string): Promise<void> {
|
||||
await expect(this.accountRow(name)).not.toBeVisible();
|
||||
}
|
||||
|
||||
/** Open the create account modal */
|
||||
async openCreateModal(): Promise<void> {
|
||||
await this.addAccountButton.click();
|
||||
await expect(this.nameInput).toBeVisible();
|
||||
}
|
||||
}
|
||||
73
tests/page-objects/BasePage.ts
Normal file
73
tests/page-objects/BasePage.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Base page object with shared helpers.
|
||||
*
|
||||
* All page objects extend this class to inherit common navigation,
|
||||
* waiting, and assertion patterns.
|
||||
*/
|
||||
|
||||
import { type Page, type Locator, expect } from '@playwright/test';
|
||||
|
||||
export abstract class BasePage {
|
||||
constructor(protected readonly page: Page) {}
|
||||
|
||||
/** The path segment for this page (e.g. '/dashboard', '/accounts') */
|
||||
abstract readonly path: string;
|
||||
|
||||
/** Navigate to this page */
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(this.path);
|
||||
await this.waitForReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override in subclasses to wait for page-specific readiness signals.
|
||||
* Default: waits for network idle.
|
||||
*/
|
||||
async waitForReady(): Promise<void> {
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/** Assert the page URL contains the expected path */
|
||||
async assertOnPage(): Promise<void> {
|
||||
await expect(this.page).toHaveURL(new RegExp(this.path));
|
||||
}
|
||||
|
||||
/** Get the page title text (Mantine AppShell header or h1) */
|
||||
async getPageHeading(): Promise<string> {
|
||||
const heading = this.page.getByRole('heading', { level: 1 }).first();
|
||||
return heading.innerText();
|
||||
}
|
||||
|
||||
/** Wait for a Mantine notification to appear with the given text */
|
||||
async waitForNotification(text: string | RegExp): Promise<void> {
|
||||
await expect(
|
||||
this.page.locator('.mantine-Notification-root').filter({ hasText: text }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Click a navigation link in the sidebar */
|
||||
async navigateTo(linkText: string): Promise<void> {
|
||||
await this.page.getByRole('link', { name: linkText }).click();
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/** Get a Mantine table body locator */
|
||||
get tableBody(): Locator {
|
||||
return this.page.locator('tbody');
|
||||
}
|
||||
|
||||
/** Get all table rows */
|
||||
get tableRows(): Locator {
|
||||
return this.tableBody.locator('tr');
|
||||
}
|
||||
|
||||
/** Wait for API response on a specific endpoint pattern */
|
||||
async waitForApi(urlPattern: string | RegExp): Promise<void> {
|
||||
await this.page.waitForResponse(
|
||||
(response) =>
|
||||
(typeof urlPattern === 'string'
|
||||
? response.url().includes(urlPattern)
|
||||
: urlPattern.test(response.url())) && response.status() < 400,
|
||||
);
|
||||
}
|
||||
}
|
||||
56
tests/page-objects/DashboardPage.ts
Normal file
56
tests/page-objects/DashboardPage.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Page object for the Dashboard (/dashboard).
|
||||
*
|
||||
* Maps to: frontend/src/pages/dashboard/DashboardPage.tsx
|
||||
* Data: GET /api/reports/dashboard
|
||||
*/
|
||||
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class DashboardPage extends BasePage {
|
||||
readonly path = '/dashboard';
|
||||
|
||||
// ─── Locators ────────────────────────────────────────────────
|
||||
|
||||
/** Main dashboard heading */
|
||||
get heading() {
|
||||
return this.page.getByRole('heading', { name: /dashboard/i }).first();
|
||||
}
|
||||
|
||||
/** Account balance summary cards */
|
||||
get balanceCards() {
|
||||
return this.page.locator('[class*="Card"]').filter({ hasText: /balance|total/i });
|
||||
}
|
||||
|
||||
/** Sidebar navigation */
|
||||
get sidebar() {
|
||||
return this.page.locator('nav').first();
|
||||
}
|
||||
|
||||
/** Sidebar links */
|
||||
sidebarLink(name: string) {
|
||||
return this.sidebar.getByRole('link', { name });
|
||||
}
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Wait for dashboard data to load */
|
||||
override async waitForReady(): Promise<void> {
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
// Dashboard typically loads report data
|
||||
await expect(this.page.locator('main')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the dashboard has loaded with content */
|
||||
async assertLoaded(): Promise<void> {
|
||||
await this.assertOnPage();
|
||||
await expect(this.page.locator('main')).not.toBeEmpty();
|
||||
}
|
||||
|
||||
/** Navigate to a section via sidebar */
|
||||
async navigateToSection(section: string): Promise<void> {
|
||||
await this.sidebarLink(section).click();
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
}
|
||||
85
tests/page-objects/LoginPage.ts
Normal file
85
tests/page-objects/LoginPage.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Page object for the Login page (/login).
|
||||
*
|
||||
* Maps to: frontend/src/pages/auth/LoginPage.tsx
|
||||
* Auth: POST /api/auth/login (Passport local strategy)
|
||||
*/
|
||||
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class LoginPage extends BasePage {
|
||||
readonly path = '/login';
|
||||
|
||||
// ─── Locators (resilient, role/label based) ──────────────────
|
||||
get emailInput() {
|
||||
return this.page.getByLabel('Email');
|
||||
}
|
||||
|
||||
get passwordInput() {
|
||||
return this.page.getByLabel('Password');
|
||||
}
|
||||
|
||||
get signInButton() {
|
||||
return this.page.getByRole('button', { name: 'Sign in' });
|
||||
}
|
||||
|
||||
get passkeyButton() {
|
||||
return this.page.getByRole('button', { name: /passkey/i });
|
||||
}
|
||||
|
||||
get errorAlert() {
|
||||
return this.page.getByRole('alert');
|
||||
}
|
||||
|
||||
get registerLink() {
|
||||
return this.page.getByRole('link', { name: /register/i });
|
||||
}
|
||||
|
||||
// ─── MFA locators ────────────────────────────────────────────
|
||||
get mfaHeading() {
|
||||
return this.page.getByText('Two-Factor Authentication');
|
||||
}
|
||||
|
||||
get mfaPinInput() {
|
||||
return this.page.locator('.mantine-PinInput-input').first();
|
||||
}
|
||||
|
||||
get mfaVerifyButton() {
|
||||
return this.page.getByRole('button', { name: 'Verify' });
|
||||
}
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Fill and submit login credentials */
|
||||
async login(email: string, password: string): Promise<void> {
|
||||
await this.emailInput.fill(email);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.signInButton.click();
|
||||
}
|
||||
|
||||
/** Login and wait for successful redirect */
|
||||
async loginAndWaitForRedirect(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
await this.login(email, password);
|
||||
await this.page.waitForURL(/\/(select-org|dashboard|admin|onboarding)/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert an error message is displayed */
|
||||
async assertError(message: string | RegExp): Promise<void> {
|
||||
await expect(this.errorAlert).toContainText(message);
|
||||
}
|
||||
|
||||
/** Assert we're still on the login page */
|
||||
async assertStillOnLogin(): Promise<void> {
|
||||
await expect(this.page).toHaveURL(/\/login/);
|
||||
}
|
||||
|
||||
override async waitForReady(): Promise<void> {
|
||||
await expect(this.signInButton).toBeVisible();
|
||||
}
|
||||
}
|
||||
10
tests/page-objects/index.ts
Normal file
10
tests/page-objects/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Re-export all page objects for convenient imports.
|
||||
*
|
||||
* Usage: import { LoginPage, DashboardPage } from '../page-objects';
|
||||
*/
|
||||
|
||||
export { BasePage } from './BasePage';
|
||||
export { LoginPage } from './LoginPage';
|
||||
export { DashboardPage } from './DashboardPage';
|
||||
export { AccountsPage } from './AccountsPage';
|
||||
Reference in New Issue
Block a user