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:
JoeBot
2026-04-05 12:40:05 -04:00
parent 629d112850
commit dfd1bccb89
20 changed files with 1751 additions and 0 deletions

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

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

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

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

View 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';