Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
test-results/
playwright-report/
blob-report/
e2e/.auth/
43 changes: 43 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test';
import { TEST_USER_EMAIL } from './helpers';

test.describe('Authentication', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test('should display the login page correctly', async ({ page }) => {
await page.goto('/login');

await expect(page.getByRole('heading', { name: 'Time Tracker' })).toBeVisible();
await expect(page.getByText('Enter your email to log in')).toBeVisible();
await expect(page.getByLabel('Email Address')).toBeVisible();

// Login button should be disabled when email is empty
await expect(page.getByRole('button', { name: 'Log In' })).toBeDisabled();

// Login button should be enabled when email is entered
await page.getByLabel('Email Address').fill(TEST_USER_EMAIL);
await expect(page.getByRole('button', { name: 'Log In' })).toBeEnabled();
});

test('should log in, show user email, and log out', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email Address').fill(TEST_USER_EMAIL);
await page.getByRole('button', { name: 'Log In' }).click();

// Should redirect to dashboard
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

// Should display user email in the header
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible();

// Should log out and redirect to login page
await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL(/.*login/);
});

test('should redirect unauthenticated users to login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/.*login/);
});
});
78 changes: 78 additions & 0 deletions e2e/clients.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test';

test.describe('Client Management', () => {
test('should display clients page with table headers', async ({ page }) => {
await page.goto('/clients');

await expect(page.getByRole('heading', { name: 'Clients' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Name' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Department' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Email' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Description' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Created' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Actions' })).toBeVisible();
});

test('should open and close the add client dialog', async ({ page }) => {
await page.goto('/clients');

await page.getByRole('button', { name: 'Add Client' }).click();
await expect(page.getByText('Add New Client')).toBeVisible();
await expect(page.getByLabel('Client Name')).toBeVisible();
await expect(page.getByLabel('Department')).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Description')).toBeVisible();

await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByText('Add New Client')).not.toBeVisible();
});

test('should create, edit, and delete a client', async ({ page }) => {
await page.goto('/clients');

// Create a client
await page.getByRole('button', { name: 'Add Client' }).click();
await page.getByLabel('Client Name').fill('Test Corp');
await page.getByLabel('Department').fill('Engineering');
await page.getByLabel('Email').fill('test@corp.com');
await page.getByLabel('Description').fill('A test client');
await page.getByRole('button', { name: 'Create' }).click();

await expect(page.getByRole('cell', { name: 'Test Corp' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Engineering' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'test@corp.com' })).toBeVisible();

// Edit the client
const row = page.getByRole('row').filter({ hasText: 'Test Corp' });
await row.getByRole('button').first().click();
await expect(page.getByText('Edit Client')).toBeVisible();
await page.getByLabel('Client Name').clear();
await page.getByLabel('Client Name').fill('Updated Corp');
await page.getByRole('button', { name: 'Update' }).click();

await expect(page.getByRole('cell', { name: 'Updated Corp' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Test Corp' })).not.toBeVisible();

// Delete the client
page.on('dialog', (dialog) => dialog.accept());
const updatedRow = page.getByRole('row').filter({ hasText: 'Updated Corp' });
await updatedRow.getByRole('button').nth(1).click();

await expect(page.getByRole('cell', { name: 'Updated Corp' })).not.toBeVisible();
});

test('should clear all clients', async ({ page }) => {
await page.goto('/clients');

// Create a client first
await page.getByRole('button', { name: 'Add Client' }).click();
await page.getByLabel('Client Name').fill('Temp Client');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByRole('cell', { name: 'Temp Client' })).toBeVisible();

// Clear all
page.on('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Clear All' }).click();
await expect(page.getByText('No clients found')).toBeVisible();
});
});
31 changes: 31 additions & 0 deletions e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';

test.describe('Dashboard', () => {
test('should display all dashboard sections and stats', async ({ page }) => {
await page.goto('/dashboard');

await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

// Statistic cards
await expect(page.getByText('Total Clients')).toBeVisible();
await expect(page.getByText('Total Work Entries')).toBeVisible();
await expect(page.getByText('Total Hours')).toBeVisible();

// Sections
await expect(page.getByText('Recent Work Entries')).toBeVisible();
await expect(page.getByText('Quick Actions')).toBeVisible();
});

test('should navigate from dashboard to other pages', async ({ page }) => {
await page.goto('/dashboard');

// Click Total Clients card to navigate to clients
await page.getByText('Total Clients').click();
await expect(page).toHaveURL(/.*clients/);

// Go back and click Add Entry
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Add Entry' }).click();
await expect(page).toHaveURL(/.*work-entries/);
});
});
13 changes: 13 additions & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { test as setup, expect } from '@playwright/test';
import { TEST_USER_EMAIL } from './helpers';

const authFile = 'e2e/.auth/user.json';

setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email Address').fill(TEST_USER_EMAIL);
await page.getByRole('button', { name: 'Log In' }).click();
await page.waitForURL('**/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
64 changes: 64 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { type Page, type APIRequestContext } from '@playwright/test';

export const TEST_USER_EMAIL = 'playwright-test@example.com';
export const API_URL = 'http://localhost:3001';

export async function login(page: Page, email: string = TEST_USER_EMAIL) {
await page.goto('/login');
await page.getByLabel('Email Address').fill(email);
await page.getByRole('button', { name: 'Log In' }).click();
await page.waitForURL('**/dashboard');
}

export async function createClientViaAPI(
request: APIRequestContext,
name: string
): Promise<{ id: number; name: string }> {
const response = await request.post(`${API_URL}/api/clients`, {
headers: { 'x-user-email': TEST_USER_EMAIL },
data: { name, department: '', email: '', description: '' },
});
const body = await response.json();
return body.client;
}

export async function createWorkEntryViaAPI(
request: APIRequestContext,
entry: { clientId: number; hours: number; description: string; date?: string }
): Promise<{ id: number }> {
const response = await request.post(`${API_URL}/api/work-entries`, {
headers: { 'x-user-email': TEST_USER_EMAIL },
data: {
clientId: entry.clientId,
hours: entry.hours,
description: entry.description,
date: entry.date || new Date().toISOString().split('T')[0],
},
});
const body = await response.json();
return body.workEntry;
}

export async function createClient(
page: Page,
client: { name: string; department?: string; email?: string; description?: string }
) {
await page.goto('/clients');
await page.getByRole('button', { name: 'Add Client' }).click();
await page.getByLabel('Client Name').fill(client.name);
if (client.department) {
await page.getByLabel('Department').fill(client.department);
}
if (client.email) {
await page.getByLabel('Email').fill(client.email);
}
if (client.description) {
await page.getByLabel('Description').fill(client.description);
}
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('cell', { name: client.name }).waitFor();
}

export function sidebarButton(page: Page, name: string) {
return page.locator('nav').getByRole('button', { name, exact: true });
}
53 changes: 53 additions & 0 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
import { sidebarButton } from './helpers';

test.describe('Navigation', () => {
test('should display sidebar items and navigate to all pages', async ({ page }) => {
await page.goto('/dashboard');

// Verify sidebar navigation items
await expect(sidebarButton(page, 'Dashboard')).toBeVisible();
await expect(sidebarButton(page, 'Clients')).toBeVisible();
await expect(sidebarButton(page, 'Work Entries')).toBeVisible();
await expect(sidebarButton(page, 'Reports')).toBeVisible();

// Navigate to Clients
await sidebarButton(page, 'Clients').click();
await expect(page).toHaveURL(/.*clients/);
await expect(page.getByRole('heading', { name: 'Clients' })).toBeVisible();

// Navigate to Work Entries
await sidebarButton(page, 'Work Entries').click();
await expect(page).toHaveURL(/.*work-entries/);
await expect(page.getByRole('heading', { name: 'Work Entries', exact: true })).toBeVisible();

// Navigate to Reports
await sidebarButton(page, 'Reports').click();
await expect(page).toHaveURL(/.*reports/);
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();

// Navigate back to Dashboard
await sidebarButton(page, 'Dashboard').click();
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

test('should highlight active navigation item and show app title', async ({ page }) => {
await page.goto('/dashboard');

// App title in sidebar
await expect(page.locator('nav').getByText('Time Tracker')).toBeVisible();

// Dashboard should be selected
await expect(sidebarButton(page, 'Dashboard')).toHaveClass(/Mui-selected/);

// Navigate to Clients and verify selection changes
await sidebarButton(page, 'Clients').click();
await expect(sidebarButton(page, 'Clients')).toHaveClass(/Mui-selected/);
});

test('should redirect root path to dashboard', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/.*dashboard/);
});
});
49 changes: 49 additions & 0 deletions e2e/reports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { createClientViaAPI, createWorkEntryViaAPI } from './helpers';

test.describe('Reports', () => {
test('should display reports page', async ({ page }) => {
await page.goto('/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
});

test('should show report with data and export buttons', async ({ page, request }) => {
const suffix = Date.now();
const client = await createClientViaAPI(request, `Report Client ${suffix}`);
await createWorkEntryViaAPI(request, {
clientId: client.id,
hours: 5,
description: 'Report test entry',
});

await page.goto('/reports');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: `Report Client ${suffix}` }).click();

// Verify report summary cards
await expect(page.getByText('Total Hours')).toBeVisible();
await expect(page.getByText('Total Entries')).toBeVisible();
await expect(page.getByText('Average Hours per Entry')).toBeVisible();

// Verify report table data
await expect(page.getByRole('columnheader', { name: 'Date' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Hours' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Description' })).toBeVisible();
await expect(page.getByText('Report test entry')).toBeVisible();

// Verify export buttons
await expect(page.getByRole('button', { name: 'Export as CSV' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Export as PDF' })).toBeVisible();
});

test('should show empty message for client without entries', async ({ page, request }) => {
const suffix = Date.now();
await createClientViaAPI(request, `Empty Client ${suffix}`);

await page.goto('/reports');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: `Empty Client ${suffix}` }).click();

await expect(page.getByText('No work entries found for this client')).toBeVisible();
});
});
Loading