Skip to content
Merged
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
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ jobs:
needs: changes
if: ${{ needs.changes.outputs.web_client == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
defaults:
run:
working-directory: web-client
Expand Down Expand Up @@ -355,6 +355,20 @@ jobs:
path: web-client/coverage/
if-no-files-found: ignore

- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium

- name: Run E2E tests (Playwright)
run: pnpm e2e

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: web-client-playwright-report
path: web-client/playwright-report/
if-no-files-found: ignore

- name: Build (Vite)
run: pnpm build

Expand Down
18 changes: 7 additions & 11 deletions web-client/.env.development.example
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
# Copy this file to `.env.development` for local dev. Vite loads `.env.development`
# automatically for `pnpm dev`. The real `.env.development` is gitignored so each
# developer can choose mocks vs. live backend without committing the choice.
# developer can keep local overrides without committing them.
#
# The app talks only to the real server. Run the stack with
# `docker compose up -d --build` from infra/ (not just keycloak). For offline UI work,
# run the Playwright E2E suite, which serves the API in-memory (see e2e/README.md).

# Defaults to the real backend (requires the full stack running --
# `docker compose up -d --build` from infra/, not just keycloak).
# Set to true to serve fixtures everywhere instead, for UI work with no backend running.
VITE_USE_MOCKS=false

# Only used when VITE_USE_MOCKS=true. Demo as a specific persona so member-scoped
# pages (dashboard feedback, payments, development report) resolve against that
# fixture identity.
# Options: member | coach | director | admin
VITE_MOCK_PERSONA=member
# Keycloak base URL (defaults to http://localhost:8081/auth if unset).
# VITE_KEYCLOAK_URL=http://localhost:8081/auth
5 changes: 5 additions & 0 deletions web-client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ coverage
*.local
progress/

# Playwright outputs
playwright-report/
test-results/
.playwright/

# Editor directories and files
.vscode/*
!.vscode/extensions.json
Expand Down
53 changes: 53 additions & 0 deletions web-client/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Web-client E2E suite (Playwright)

Drives the real, server-only app with its `/api/v1` calls answered at the network
layer by an in-memory server — no real services and no Keycloak server needed.

## Run it

```bash
pnpm -C web-client e2e # headless run (boots its own Vite dev server on :5199)
pnpm -C web-client e2e:ui # Playwright UI mode
```

The app makes real axios calls to `/api/v1/*`; `e2e/support/api.ts` intercepts them per
test context and answers from `e2e/support/server/*` (one module per resource). Browser:
chromium (`pnpm exec playwright install chromium`).

## How the app gets past Keycloak

`AuthenticatedApp` always runs `keycloak.init({ onLoad: 'check-sso' })`. The suite uses a
test-side-only approach with zero production changes: `e2e/support/auth.ts` intercepts the
three requests keycloak-js makes (3p-cookie probe iframe, silent-check-sso authorization
request, code-for-token exchange) with `context.route(...)` and answers them like a real
Keycloak would, minting an unsigned JWT whose `nonce` echoes the login request (keycloak-js
does not verify signatures client-side, but does verify the nonce). The app reaches
`status: 'ready'` in ~1s.

**Identity:** `getCurrentUser()` reads the Keycloak token only. The stubbed token is minted
for the seeded **admin**, and the server answers every request as that same admin, so the
suite runs as one identity (admin) — no persona switching. Because the admin may use every
route, the route-guard deny branch is covered by unit tests (`RouteRoleGuard.test.tsx`),
along with the per-role nav/scoping sweep (`AppShell.test.tsx`, `scope.test.ts`).

## State isolation

The in-memory server keeps its mutable state (deep clones of the fixtures) in the
Playwright/Node process, shared across a worker's tests. `stubApi()` calls each resource's
`reset()` before wiring the routes, so every test starts from a pristine copy — one test's
create/edit/delete never leaks into the next. Tests run fully parallel (each Playwright
worker is its own process, and tests within a worker run serially, so the shared state is
safe). Do not chain assertions across tests expecting mutated state to survive.

Assertions derive from the fixtures themselves (`e2e/support/data.ts` re-exports the same
fixtures the server serves), not hard-coded strings.

## What lives where

- **E2E (this suite):** boot past auth, sidebar nav for the admin role, user-menu identity,
theme persistence, 404, per-page fixture-scoped rendering, and the full happy paths of
members CRUD (+409 duplicate email), feedback compose/edit/delete, events
create/edit/delete, payments create (+2 form-error paths)/delete, letters
preview/send/PDF-mode.
- **Unit/component tests:** the per-role sweep (nav visibility, route guard, scoping),
dialog validation/error branches, and view-model logic.
57 changes: 57 additions & 0 deletions web-client/e2e/app-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ADMIN, NAV_ITEMS } from './support/data'
import { expect, gotoApp, test } from './support/fixtures'

test('sidebar shows exactly the nav items the admin role may use', async ({ page }) => {
await gotoApp(page)

const allowed = NAV_ITEMS.filter((item) => item.roles.includes(ADMIN.role))
for (const item of allowed) {
await expect(page.getByRole('link', { name: item.label, exact: true })).toBeVisible()
}

// Nav renders from navPolicy.ts only — no extra destinations beyond the allowed set.
await expect(page.locator('a[data-sidebar="menu-button"]')).toHaveCount(allowed.length)
})

test('user menu shows the signed-in identity and links to the profile', async ({ page }) => {
await gotoApp(page)

const userMenuTrigger = page.getByRole('button', { name: new RegExp(ADMIN.name) })
await expect(userMenuTrigger).toBeVisible()
await expect(userMenuTrigger).toContainText(ADMIN.email)

await userMenuTrigger.click()
await expect(page.getByRole('menuitem', { name: 'Profile' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Log out' })).toBeVisible()

await page.getByRole('menuitem', { name: 'Profile' }).click()
await expect(page).toHaveURL(/\/profile$/)
})

test('theme switch flips the dark class and persists to localStorage', async ({ page }) => {
await gotoApp(page)

await page.getByRole('button', { name: new RegExp(ADMIN.name) }).click()
await page.getByRole('menuitemradio', { name: 'Dark' }).click()

await expect(page.locator('html')).toHaveClass(/dark/)
await expect
.poll(() => page.evaluate(() => localStorage.getItem('ui-theme')))
.toBe('dark')

// Survives a full reload (ThemeProvider re-reads localStorage).
await gotoApp(page)
await expect(page.locator('html')).toHaveClass(/dark/)

await page.getByRole('button', { name: new RegExp(ADMIN.name) }).click()
await page.getByRole('menuitemradio', { name: 'Light' }).click()
await expect(page.locator('html')).not.toHaveClass(/dark/)
})

test('unknown paths render the NotFound page inside the shell', async ({ page }) => {
await gotoApp(page, '/this-route-does-not-exist')

await expect(page.getByRole('heading', { name: '404 - Page not found' })).toBeVisible()
await page.getByRole('button', { name: 'Go home' }).click()
await expect(page.getByText("Here's what's happening across your club.")).toBeVisible()
})
10 changes: 10 additions & 0 deletions web-client/e2e/boot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { expect, gotoApp, test } from './support/fixtures'

test('boots past the auth spinner into the app shell', async ({ page }) => {
await gotoApp(page)

await expect(page.getByRole('heading', { name: 'Roost' })).toBeVisible()
// Neither the auth error card nor the spinner may remain.
await expect(page.getByText('Sign-in error')).toHaveCount(0)
await expect(page.getByText('Cannot reach authentication server')).toHaveCount(0)
})
68 changes: 68 additions & 0 deletions web-client/e2e/events.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { eventSummaryFixtures } from './support/data'
import { expect, gotoApp, test } from './support/fixtures'

test('creates an event through the four-step dialog', async ({ page }) => {
await gotoApp(page, '/sport-events')

await page.getByRole('button', { name: 'New event' }).click()
const dialog = page.getByRole('dialog', { name: 'New Event' })

await dialog.getByLabel('Name').fill('E2E Friendly Match')
await dialog.getByRole('button', { name: 'Next' }).click()
// Schedule step is pre-filled with a valid start/end pair.
await dialog.getByRole('button', { name: 'Next' }).click()
// Sports & teams and attendees are optional for the admin.
await dialog.getByRole('button', { name: 'Next' }).click()
await dialog.getByRole('button', { name: 'Create Event' }).click()

// Creation opens the detail sheet for the new event; the notice behind the
// modal sheet is aria-hidden until the sheet closes.
const sheet = page.getByRole('dialog', { name: 'E2E Friendly Match' })
await expect(sheet).toBeVisible()
await sheet.getByRole('button', { name: 'Close' }).click()

await expect(page.getByRole('status')).toContainText('Event created.')
await expect(page.getByRole('button', { name: 'View E2E Friendly Match' })).toBeVisible()
})

test('blocks an empty name on the details step', async ({ page }) => {
await gotoApp(page, '/sport-events')

await page.getByRole('button', { name: 'New event' }).click()
const dialog = page.getByRole('dialog', { name: 'New Event' })

// The name input is `required`, so a blank submit is stopped by the browser
// and the dialog stays on the first step.
await dialog.getByRole('button', { name: 'Next' }).click()
await expect(dialog.getByLabel('Name')).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Next' })).toBeVisible()
})

test('edits an event name from the list', async ({ page }) => {
await gotoApp(page, '/sport-events')

const target = eventSummaryFixtures[0]
await page.getByRole('button', { name: `Edit ${target.name}` }).first().click()

const dialog = page.getByRole('dialog', { name: 'Edit Event' })
await dialog.getByLabel('Name').fill('Renamed E2E Session')
await dialog.getByRole('button', { name: 'Next' }).click()
await dialog.getByRole('button', { name: 'Next' }).click()
await dialog.getByRole('button', { name: 'Next' }).click()
await dialog.getByRole('button', { name: 'Save Event' }).click()

await expect(page.getByRole('status')).toContainText('Event updated.')
await expect(page.getByRole('button', { name: 'View Renamed E2E Session' })).toBeVisible()
})

test('deletes an event after confirmation', async ({ page }) => {
await gotoApp(page, '/sport-events')

const target = eventSummaryFixtures[0]
await page.getByRole('button', { name: `Delete ${target.name}` }).first().click()

const confirm = page.getByRole('alertdialog')
await confirm.getByRole('button', { name: 'Delete', exact: true }).click()

await expect(page.getByRole('status')).toContainText('Event deleted.')
})
64 changes: 64 additions & 0 deletions web-client/e2e/feedback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { feedbackSummaryFixtures } from './support/data'
import { expect, gotoApp, test } from './support/fixtures'

test('composes feedback via the sport → team → event picker', async ({ page }) => {
await gotoApp(page, '/feedback')

await page.getByRole('button', { name: 'New feedback' }).click()
const picker = page.getByRole('dialog', { name: 'New feedback' })

// Drill down the coverage tree: first sport → first team → first event.
for (let level = 0; level < 3; level += 1) {
await picker.locator('ul > li button').first().click()
}

// Remember who we are giving feedback to, then open the compose dialog.
const traineeRow = picker.locator('ul > li').first()
const traineeName = (await traineeRow.locator('p').first().innerText()).trim()
await traineeRow.getByRole('button', { name: `Give feedback for ${traineeName}` }).click()

const compose = page.getByRole('dialog', { name: 'Give Feedback' })
await expect(compose).toContainText(traineeName)
await compose.getByLabel('Feedback').fill('Great energy in the session — keep it up.')
await compose.getByLabel('Rating').fill('9')
await compose.getByRole('button', { name: 'Save Feedback' }).click()

await expect(page.getByRole('status')).toContainText(`Feedback added for ${traineeName}.`)
await expect(
page.getByRole('button', { name: `View feedback for ${traineeName}` }).first(),
).toBeVisible()
})

test('edits existing feedback from the detail sheet', async ({ page }) => {
await gotoApp(page, '/feedback')

const target = feedbackSummaryFixtures[0]
await page.getByRole('button', { name: `View feedback for ${target.member.name}` }).first().click()

const sheet = page.getByRole('dialog', { name: target.event.name })
await sheet.getByRole('button', { name: 'Edit Feedback' }).click()

const editDialog = page.getByRole('dialog', { name: 'Edit feedback' })
await editDialog.getByLabel('Rating').fill('10')
await editDialog.getByRole('button', { name: 'Save changes' }).click()

await expect(sheet.getByText('10/10')).toBeVisible()
})

test('deletes feedback after confirmation', async ({ page }) => {
await gotoApp(page, '/feedback')

const target = feedbackSummaryFixtures[0]
const deleteButtons = page.getByRole('button', {
name: `Delete feedback for ${target.member.name}`,
})
// Wait for the row to render before counting, so the count isn't read early.
await expect(deleteButtons.first()).toBeVisible()
const countBefore = await deleteButtons.count()

await deleteButtons.first().click()
const confirm = page.getByRole('alertdialog', { name: 'Delete feedback' })
await confirm.getByRole('button', { name: 'Delete', exact: true }).click()

await expect(deleteButtons).toHaveCount(countBefore - 1)
})
38 changes: 38 additions & 0 deletions web-client/e2e/helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ADMIN } from './support/data'
import { expect, gotoApp, test } from './support/fixtures'

test('generates a report', async ({ page }) => {
await gotoApp(page, '/helper')

await page.getByRole('button', { name: 'Generate report' }).click()

await expect(page.getByText('Report generation started')).toBeVisible()
await expect(page.getByRole('button', { name: `Read report for ${ADMIN.name}` })).toBeVisible()
})

test('views a report', async ({ page }) => {
await gotoApp(page, '/helper')

await page.getByRole('button', { name: 'Generate report' }).click()
await page.getByRole('button', { name: `Read report for ${ADMIN.name}` }).click()

const sheet = page.getByRole('dialog', { name: ADMIN.name })
await expect(sheet).toBeVisible()
await expect(sheet.getByText('Report generation in progress.')).toBeVisible()
})

test('deletes a report', async ({ page }) => {
await gotoApp(page, '/helper')

await page.getByRole('button', { name: 'Generate report' }).click()
const deleteButtons = page.getByRole('button', { name: `Delete report for ${ADMIN.name}` })
await expect(deleteButtons.first()).toBeVisible()
const countBefore = await deleteButtons.count()

await deleteButtons.first().click()
const confirm = page.getByRole('alertdialog', { name: 'Delete report' })
await expect(confirm).toContainText(`Delete ${ADMIN.name}?`)
await confirm.getByRole('button', { name: 'Delete', exact: true }).click()

await expect(deleteButtons).toHaveCount(countBefore - 1)
})
34 changes: 34 additions & 0 deletions web-client/e2e/letters.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, gotoApp, test } from './support/fixtures'

test('renders a live preview with sample recipient data', async ({ page }) => {
await gotoApp(page, '/letters')

// Admin audience goes to the whole club.
await expect(page.getByText('This will go to all members.')).toBeVisible()

await page.getByLabel('Template').fill('<p>Hello {{first_name}}, welcome to the club.</p>')

const preview = page.frameLocator('iframe[title="Letter template sample preview"]')
// Tokens are substituted with the documented sample values ({{first_name}} -> Alex).
await expect(preview.getByText('Hello Alex, welcome to the club.')).toBeVisible()
})

test('sends a mail letter to the admin audience', async ({ page }) => {
await gotoApp(page, '/letters')

await page.getByLabel('Subject').fill('E2E newsletter')
await page.getByLabel('Template').fill('<h1>Hello {{first_name}}</h1><p>See you at training.</p>')
await page.getByRole('button', { name: 'Send Mail' }).click()

await expect(page.getByRole('status')).toContainText('Mail sent to all members.')
})

test('switches to PDF mode and hides the subject field', async ({ page }) => {
await gotoApp(page, '/letters')

await expect(page.getByLabel('Subject')).toBeVisible()
await page.getByRole('button', { name: 'PDF' }).click()

await expect(page.getByLabel('Subject')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Download PDF' })).toBeVisible()
})
Loading
Loading