diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5c8c5cdc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +test-results/ +playwright-report/ +blob-report/ +e2e/.auth/ diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 00000000..9b76910e --- /dev/null +++ b/e2e/auth.spec.ts @@ -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/); + }); +}); diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts new file mode 100644 index 00000000..594c23b0 --- /dev/null +++ b/e2e/clients.spec.ts @@ -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(); + }); +}); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 00000000..ba147948 --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -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/); + }); +}); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..c251dbff --- /dev/null +++ b/e2e/global-setup.ts @@ -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 }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..25dcb5ec --- /dev/null +++ b/e2e/helpers.ts @@ -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 }); +} diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 00000000..d6a21ab9 --- /dev/null +++ b/e2e/navigation.spec.ts @@ -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/); + }); +}); diff --git a/e2e/reports.spec.ts b/e2e/reports.spec.ts new file mode 100644 index 00000000..53e44522 --- /dev/null +++ b/e2e/reports.spec.ts @@ -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(); + }); +}); diff --git a/e2e/work-entries.spec.ts b/e2e/work-entries.spec.ts new file mode 100644 index 00000000..5b18bea9 --- /dev/null +++ b/e2e/work-entries.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { createClientViaAPI } from './helpers'; + +test.describe('Work Entries Management', () => { + test('should show work entries page heading', async ({ page }) => { + await page.goto('/work-entries'); + await expect(page.getByRole('heading', { name: 'Work Entries', exact: true })).toBeVisible(); + }); + + test('should create, edit, and delete a work entry', async ({ page, request }) => { + const suffix = Date.now(); + const clientName = `WE Client ${suffix}`; + await createClientViaAPI(request, clientName); + await page.goto('/work-entries'); + + await expect(page.getByRole('button', { name: 'Add Work Entry' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Client' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Date' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Hours' })).toBeVisible(); + + // Create a work entry via the dialog + await page.getByRole('button', { name: 'Add Work Entry' }).click(); + await expect(page.getByText('Add New Work Entry')).toBeVisible(); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: clientName }).click(); + await page.getByRole('spinbutton', { name: 'Hours' }).fill('4'); + await page.getByRole('textbox', { name: 'Description' }).fill('Initial work'); + await page.getByRole('button', { name: 'Create' }).click(); + + // Wait for the new entry to appear in the table + await expect(page.getByRole('heading', { name: clientName })).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('4 hours')).toBeVisible(); + await expect(page.getByText('Initial work')).toBeVisible(); + + // Edit the work entry + const row = page.getByRole('row').filter({ hasText: clientName }).filter({ hasText: '4 hours' }); + await row.getByRole('button').first().click(); + await expect(page.getByText('Edit Work Entry')).toBeVisible(); + + await page.getByRole('spinbutton', { name: 'Hours' }).clear(); + await page.getByRole('spinbutton', { name: 'Hours' }).fill('8'); + await page.getByRole('textbox', { name: 'Description' }).clear(); + await page.getByRole('textbox', { name: 'Description' }).fill('Updated work'); + await page.getByRole('button', { name: 'Update' }).click(); + + await expect(page.getByText('8 hours')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Updated work')).toBeVisible(); + + // Delete the work entry + page.on('dialog', (d) => d.accept()); + const entryRow = page.getByRole('row').filter({ hasText: clientName }).filter({ hasText: '8 hours' }); + await entryRow.getByRole('button').nth(1).click(); + + await expect(entryRow).not.toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..0eefa852 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "app-timesheet", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "app-timesheet", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.60.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..fa8b0dbf --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "app-timesheet", + "version": "1.0.0", + "description": "A full-stack web application for tracking and reporting employee hourly work across different clients.", + "main": "index.js", + "scripts": { + "test:e2e": "npx playwright test", + "test:e2e:headed": "npx playwright test --headed", + "test:e2e:ui": "npx playwright test --ui" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.60.0" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..20c95cad --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + timeout: 30000, + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'setup', + testMatch: /global-setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'e2e/.auth/user.json', + }, + dependencies: ['setup'], + testIgnore: /global-setup\.ts/, + }, + ], + webServer: [ + { + command: 'cd backend && npm run dev', + port: 3001, + reuseExistingServer: !process.env.CI, + timeout: 15000, + }, + { + command: 'cd frontend && npm run dev', + port: 5173, + reuseExistingServer: !process.env.CI, + timeout: 15000, + }, + ], +});