Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions .github/workflows/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ concurrency:
jobs:
run-tests:
uses: ./.github/workflows/run_tests.yml
run-e2e-tests:
uses: ./.github/workflows/e2e_tests.yml
build-dev-container:
uses: ./.github/workflows/build_docker.yml
secrets: inherit
deploy-dev-container:
needs:
- run-tests
- run-e2e-tests
- build-dev-container
uses: ./.github/workflows/deploy_docker.yml
secrets: inherit
Expand Down
183 changes: 183 additions & 0 deletions .github/workflows/e2e_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
name: E2E Tests

on:
workflow_call:

jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30

services:
postgres:
image: postgres:18.2-alpine
env:
POSTGRES_USER: thesis-management-postgres
POSTGRES_PASSWORD: thesis-management-postgres
POSTGRES_DB: thesis-management
ports:
- 5144:5432
options: >-
--health-cmd "pg_isready -d thesis-management -U thesis-management-postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

keycloak:
image: quay.io/keycloak/keycloak:26.4
env:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- 8081:8080
# Note: realm import is handled via the entrypoint below
options: >-
--health-cmd "exec 3<>/dev/tcp/127.0.0.1/8080; echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\n\r\n' >&3; head -1 <&3 | grep -q '200'"
--health-interval 10s
--health-timeout 5s
--health-retries 30
--health-start-period 60s

steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref || github.ref }}
fetch-depth: 1

# Import Keycloak realm (service containers can't mount volumes, so we use the REST API)
- name: Wait for Keycloak to be ready
run: |
echo "Waiting for Keycloak..."
for i in $(seq 1 60); do
if curl -sf http://localhost:8081/health/ready > /dev/null 2>&1; then
echo "Keycloak is ready"
break
fi
sleep 2
done

- name: Import Keycloak realm
run: |
# Get admin token
TOKEN=$(curl -sf -X POST "http://localhost:8081/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin&grant_type=password&client_id=admin-cli" | jq -r '.access_token')

# Import realm
curl -sf -X POST "http://localhost:8081/admin/realms" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @keycloak/thesis-management-realm.json

echo "Realm imported successfully"

# Set up Java for server
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
java-version: '25'
distribution: 'zulu'
cache: 'gradle'

- name: Grant execute permission for gradlew
run: chmod +x ./server/gradlew

# Start server in background with dev profile
- name: Start server
working-directory: ./server
run: ./gradlew bootRun --args='--spring.profiles.active=dev' &
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5144/thesis-management
SPRING_DATASOURCE_USERNAME: thesis-management-postgres
SPRING_DATASOURCE_PASSWORD: thesis-management-postgres
KEYCLOAK_HOST: http://localhost:8081
KEYCLOAK_REALM_NAME: thesis-management
KEYCLOAK_CLIENT_ID: thesis-management-app
KEYCLOAK_SERVICE_CLIENT_ID: thesis-management-service-client
KEYCLOAK_SERVICE_CLIENT_SECRET: ""
CLIENT_HOST: http://localhost:3000

# Set up Node.js for client
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: client/package-lock.json

- name: Install client dependencies
working-directory: ./client
run: npm ci

- name: Install Playwright browsers
working-directory: ./client
run: npx playwright install --with-deps chromium

# Start client dev server in background
- name: Start client dev server
working-directory: ./client
run: npx webpack serve --env NODE_ENV=development &
env:
SERVER_HOST: http://localhost:8080
KEYCLOAK_HOST: http://localhost:8081
KEYCLOAK_REALM_NAME: thesis-management
KEYCLOAK_CLIENT_ID: thesis-management-app

# Wait for both services to be ready
- name: Wait for server to be ready
run: |
echo "Waiting for Spring Boot server..."
for i in $(seq 1 120); do
if curl -sf http://localhost:8080/api/actuator/health > /dev/null 2>&1; then
echo "Server is ready"
break
fi
if [ $i -eq 120 ]; then
echo "Server failed to start"
exit 1
fi
sleep 2
done

- name: Wait for client to be ready
run: |
echo "Waiting for client dev server..."
for i in $(seq 1 60); do
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
echo "Client is ready"
break
fi
if [ $i -eq 60 ]; then
echo "Client failed to start"
exit 1
fi
sleep 2
done

# Run E2E tests
- name: Run Playwright tests
working-directory: ./client
run: npx playwright test
env:
CI: "1"
CLIENT_URL: http://localhost:3000
KEYCLOAK_HOST: http://localhost:8081
KEYCLOAK_REALM_NAME: thesis-management
KEYCLOAK_CLIENT_ID: thesis-management-app

- name: Upload Playwright report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: client/playwright-report/
retention-days: 14

- name: Upload test results
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-results
path: client/test-results/
retention-days: 14
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ postfix-config
node_modules/

# misc
.DS_Store
.DS_Store

# E2E test runner
.e2e-pids/
.e2e-server.log
.e2e-client.log
41 changes: 40 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,44 @@ This file provides guidance for Claude Code when working with this repository.
- **Lint**: `cd client && npx eslint src/`
- **Type check**: `cd client && npx tsc --noEmit` (ignore mantine-datatable type errors — pre-existing)

### Client E2E Tests (Playwright)

E2E tests require the full dev stack running: PostgreSQL, Keycloak, server (dev profile), and client dev server.

**Prerequisites** (start these first):
1. `docker-compose up` (starts PostgreSQL + Keycloak + CalDAV)
2. `cd server && ./gradlew bootRun --args='--spring.profiles.active=dev'` (starts server with seed data)
3. `cd client && npm run dev` (starts client dev server on port 3000)

**One-command local run** (starts all services automatically):
- **Headless**: `./execute-e2e-local.sh`
- **Interactive UI**: `./execute-e2e-local.sh --ui`
- **Headed browser**: `./execute-e2e-local.sh --headed`
- **Stop services**: `./execute-e2e-local.sh --stop`

The script is idempotent — it reuses already-running services and can be executed repeatedly. Services stay running between invocations for fast re-runs.

**Run tests only** (when services are already running manually):
- **Headless**: `cd client && npm run e2e`
- **Interactive UI**: `cd client && npm run e2e:ui`
- **Headed browser**: `cd client && npm run e2e:headed`

**Install browsers** (first time only): `cd client && npx playwright install chromium`

Tests authenticate via the Keycloak login form using seeded test users (student/student, advisor/advisor, supervisor/supervisor, admin/admin). Auth state is cached in `e2e/.auth/` and reused across tests. Test files are in `client/e2e/`.

**Test coverage** (50 tests across 11 files):
- `auth.spec.ts` — Authentication & role-based nav visibility (5 roles: unauthenticated, student, advisor, supervisor, admin)
- `navigation.spec.ts` — Public pages, sidebar navigation, route access per role
- `dashboard.spec.ts` — Dashboard sections per role (My Theses, My Applications)
- `topics.spec.ts` — Public topic browsing (search, filters, list/grid), management view, student apply
- `applications.spec.ts` — Student stepper form, pre-selected topic, advisor/supervisor review access
- `theses.spec.ts` — Browse view per role, overview, detail page sections, student own thesis
- `interviews.spec.ts` — Supervisor overview/detail, advisor access, student denied
- `presentations.spec.ts` — Student/supervisor access, public presentation detail
- `settings.spec.ts` — My Information and Notification Settings tabs per role
- `research-groups.spec.ts` — Admin CRUD, search filtering, supervisor access, student denied

## Architecture

- **Server**: Spring Boot 3, Java 25, PostgreSQL, Keycloak for auth, Liquibase for migrations
Expand Down Expand Up @@ -51,7 +89,8 @@ The backend/Keycloak uses `supervisor` and `advisor` roles. In the UI these are

## CI/CD

- `dev.yml`: Triggers on PRs to develop/main and pushes to develop/main. Has concurrency control per PR.
- `dev.yml`: Triggers on PRs to develop/main and pushes to develop/main. Has concurrency control per PR. Runs server tests, E2E tests, builds Docker images, and deploys.
- `prod.yml`: Triggers on pushes to main only. Has concurrency control (no cancellation).
- `build_docker.yml`: Separate jobs for server and client builds (not a matrix) to avoid output race conditions.
- `deploy_docker.yml`: Deploys to VMs via SSH. Uses environment protection rules requiring approval.
- `e2e_tests.yml`: Reusable workflow that spins up PostgreSQL + Keycloak, starts the server (dev profile with seed data) and client, then runs Playwright E2E tests.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ Group heads have the Group Admin role for their group by default (this cannot be
1. [Production Setup](docs/PRODUCTION.md)
2. [Configuration](docs/CONFIGURATION.md)
3. [Customizing E-Mails](docs/MAILS.md)
4. [Development Setup](docs/DEVELOPMENT.md)
4. [Development Setup](docs/DEVELOPMENT.md) (includes [E2E Tests](docs/DEVELOPMENT.md#e2e-tests-playwright))
5. [Database Changes](docs/DATABASE.md)

## Features
Expand Down
4 changes: 4 additions & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

# testing
/coverage
/test-results/
/playwright-report/
/blob-report/
/e2e/.auth/

# production
/build
Expand Down
49 changes: 49 additions & 0 deletions client/e2e/applications.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test'
import { authStatePath, navigateTo } from './helpers'

test.describe('Applications - Student', () => {
test('submit application page shows stepper form', async ({ page }) => {
await navigateTo(page, '/submit-application')

await expect(page).toHaveURL(/\/submit-application/)
// The multi-step stepper form should be visible
await expect(page.locator('.mantine-Stepper-root')).toBeVisible({ timeout: 15_000 })
})

test('submit application with pre-selected topic', async ({ page }) => {
// Navigate with topic pre-selected (CI Pipeline Optimization)
await navigateTo(page, '/submit-application/00000000-0000-4000-b000-000000000002')

await expect(page).toHaveURL(/\/submit-application\/00000000/)
})

test('dashboard shows student applications section', async ({ page }) => {
await navigateTo(page, '/dashboard')

await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible({ timeout: 15_000 })
// Student should see My Applications section
await expect(page.getByRole('heading', { name: /my applications/i })).toBeVisible()
})
})

test.describe('Applications - Advisor review', () => {
test.use({ storageState: authStatePath('advisor') })

test('review page loads with application sidebar', async ({ page }) => {
await navigateTo(page, '/applications')

await expect(page).toHaveURL(/\/applications/)
// The page should have the sidebar with applications or an empty state
await expect(page.locator('body')).toContainText(/.+/)
})
})

test.describe('Applications - Supervisor review', () => {
test.use({ storageState: authStatePath('supervisor') })

test('review page is accessible', async ({ page }) => {
await navigateTo(page, '/applications')

await expect(page).toHaveURL(/\/applications/)
})
})
37 changes: 37 additions & 0 deletions client/e2e/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test as setup, expect } from '@playwright/test'

const TEST_USERS = [
{ name: 'student', username: 'student', password: 'student' },
{ name: 'advisor', username: 'advisor', password: 'advisor' },
{ name: 'supervisor', username: 'supervisor', password: 'supervisor' },
{ name: 'admin', username: 'admin', password: 'admin' },
] as const

for (const user of TEST_USERS) {
setup(`authenticate as ${user.name}`, async ({ page }) => {
// Navigate to a protected route to trigger Keycloak login redirect
await page.goto('/dashboard')

// Wait for Keycloak login page to load
await expect(page.locator('#kc-login')).toBeVisible({ timeout: 30_000 })

// Fill in credentials on the Keycloak login form
await page.locator('#username').fill(user.username)
await page.locator('#password').fill(user.password)
await page.locator('#kc-login').click()

// Wait for redirect back to the app and the dashboard to load
await expect(page).toHaveURL(/localhost:3000/, { timeout: 30_000 })

// Wait for the app to fully initialize with the auth tokens
await page.waitForFunction(() => {
const tokens = localStorage.getItem('authentication_tokens')
if (!tokens) return false
const parsed = JSON.parse(tokens)
return !!parsed.access_token && !!parsed.refresh_token
}, { timeout: 15_000 })

// Save the authenticated state (localStorage + cookies including Keycloak session)
await page.context().storageState({ path: `e2e/.auth/${user.name}.json` })
})
}
Loading
Loading