test: Cypress en Playwright e2e-suites voor login, agenda, cortex en verpleegrapportage

Configs voor beide runners, login via session caching, AI-chat
mocks (SSE) voor deterministische Cortex-tests. Supabase local
config toegevoegd. Auth-state en reports zijn gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2026-07-09 23:16:17 +02:00
parent 924988dd15
commit 9195eb9fed
20 changed files with 1611 additions and 0 deletions

256
tests/README.md Normal file
View File

@@ -0,0 +1,256 @@
# Testhandleiding - Mini EPD Prototype
Deze handleiding is bedoeld voor iedereen die de EPD-applicatie wil testen — ook zonder technische achtergrond. Je kunt kiezen tussen twee testtools: **Playwright** en **Cypress**. Beide doen hetzelfde — een browser automatisch bedienen alsof er een echte gebruiker achter zit — maar de interface is net iets anders.
---
## Mappenstructuur
```
tests/
playwright/ # Playwright tests
.auth/ # Opgeslagen inlogsessie (automatisch aangemaakt)
fixtures/
ai-mocks.ts # Gesimuleerde AI-antwoorden
auth.setup.ts # Eenmalige inlog setup
login.spec.ts
agenda/agenda.spec.ts
cortex/
command-center.spec.ts
dagnotitie-flow.spec.ts
verpleegrapportage/overzicht.spec.ts
cypress/ # Cypress tests (zelfde flows, andere syntax)
support/
commands.ts # cy.login(), cy.mockDagnotitieChat()
e2e.ts
login.cy.ts
agenda/agenda.cy.ts
cortex/
command-center.cy.ts
dagnotitie-flow.cy.ts
verpleegrapportage/overzicht.cy.ts
screenshots/ # Schermafbeeldingen bij fouten (automatisch)
videos/ # Video-opnames per testrun (automatisch)
test-results/ # Playwright: details bij falende tests (automatisch)
playwright-report/ # Playwright: HTML rapport na afloop (automatisch)
README.md # Dit bestand
```
---
## Voorbereiding
Zorg dat je twee dingen hebt gestart voordat je de tests draait:
**1. De applicatie zelf**
Open een terminal en typ:
```bash
pnpm dev
```
Wacht tot je ziet: `✓ Ready on http://localhost:3000`. Laat dit venster open staan.
**2. De testtool naar keuze** — zie hieronder.
---
## Playwright gebruiken
Playwright is geïnstalleerd en geconfigureerd in `playwright.config.ts`.
### Starten
```bash
pnpm test:e2e:ui # visuele UI (aanbevolen voor demo en testers)
pnpm test:e2e # alle tests in de terminal
pnpm test:e2e:debug # stap-voor-stap debuggen
pnpm test:e2e:report # HTML rapport na afloop bekijken
```
### De Playwright UI gebruiken
```
┌─────────────────────────┬──────────────────────────────┐
│ Testlijst (links) │ Browser preview (rechts) │
│ │ │
│ ▶ auth.setup │ Hier zie je de app │
│ ▶ login │ meelopen terwijl de test │
│ ▶ agenda │ wordt uitgevoerd │
│ ▶ cortex │ │
│ ▶ verpleegrapportage │ │
└─────────────────────────┴──────────────────────────────┘
```
**Stap 1 — Filter instellen**
Zorg dat bovenaan "chromium" of "All" geselecteerd is, niet "setup". Anders zie je maar één test.
**Stap 2 — Inloggen (eenmalig per sessie)**
Klik op ▶ naast `auth.setup.ts`. Dit logt automatisch in en slaat de sessie op. Daarna starten alle andere tests al ingelogd.
**Stap 3 — Test uitvoeren**
Klik op een individuele testnaam (niet de groepsnaam). De browser aan de rechterkant springt tot leven.
**Wat betekenen de kleuren?**
- **Grijs** — test is nog niet gedraaid
- **Groen** ✓ — test geslaagd
- **Rood** ✗ — test gefaald, zie details onderaan
---
## Cypress gebruiken
Cypress is de tool die jullie testers al kennen. Geconfigureerd in `cypress.config.ts`.
### Starten
```bash
pnpm test:cypress:open # visuele UI (aanbevolen voor testers)
pnpm test:cypress:run # alle tests headless in de terminal
```
### De Cypress UI gebruiken
Na `pnpm test:cypress:open` opent de Cypress app. Kies **E2E Testing** en daarna **Chrome** als browser.
```
┌──────────────────────────────────────────────────────┐
│ Specs (testbestanden) │
│ │
│ login.cy.ts │
│ agenda / agenda.cy.ts │
│ cortex / command-center.cy.ts │
│ cortex / dagnotitie-flow.cy.ts │
│ verpleegrapportage / overzicht.cy.ts │
└──────────────────────────────────────────────────────┘
```
Klik op een bestand om alle tests erin te draaien. De browser opent en je ziet links de stappen en rechts de live applicatie.
**Inloggen gaat automatisch** via `cy.login()` — je hoeft niets extra te doen. De sessie wordt gecached zodat het maar één keer per testrun hoeft.
---
## Vergelijking Playwright vs Cypress
Beide tools testen exact dezelfde flows. De syntax verschilt iets:
| Actie | Playwright | Cypress |
|---|---|---|
| Navigeer naar pagina | `page.goto('/login')` | `cy.visit('/login')` |
| Vind element | `page.locator('#email')` | `cy.get('#email')` |
| Typ tekst | `page.fill('#email', '...')` | `cy.get('#email').type('...')` |
| Klik | `page.click('button')` | `cy.contains('button').click()` |
| Controleer zichtbaar | `expect(...).toBeVisible()` | `.should('be.visible')` |
| Mock API | `page.route('**/api/...')` | `cy.intercept('GET', '/api/...')` |
| Wacht op redirect | `page.waitForURL(/\/epd\//)` | `cy.url().should('match', /\/epd\//)` |
---
## Overzicht van alle tests
### login — Inlogpagina (5 tests)
| Test | Wat wordt gecontroleerd |
|---|---|
| Toont loginpagina correct | Email, wachtwoord en inlogknop zijn zichtbaar |
| Foutmelding bij verkeerde gegevens | Foutmelding verschijnt bij foute combinatie |
| Demo login knop werkt | ⚡ knop logt direct in en stuurt door naar het EPD |
| Redirect na succesvol inloggen | Na inloggen kom je op `/epd/` terecht |
| Beveiligde route stuurt door | Probeer `/epd/dashboard` zonder sessie → je gaat naar login |
---
### agenda — Agenda en afspraken (8 tests)
Zoekresultaten zijn gesimuleerd met **Robbert Robot** zodat de tests niet afhankelijk zijn van live patiëntdata.
**Basiscontroles**
| Test | Wat wordt gecontroleerd |
|---|---|
| Laadt de agendapagina | Pagina opent correct met "Agenda" titel en "Nieuwe Afspraak" knop |
| Navigatieknoppen zichtbaar | "Vandaag", "Dag" en andere knoppen aanwezig |
**Afspraak aanmaken**
| Test | Wat wordt gecontroleerd |
|---|---|
| Modal opent | Klik op "Nieuwe Afspraak" → formulier verschijnt |
| Verplichte velden aanwezig | Patiëntzoekveld, datum, tijd en "Afspraak maken" knop zichtbaar |
| Zoeken toont resultaat | Type "Robbert" → "Robbert Robot" verschijnt in de lijst |
| Patiënt selecteren | Klik op naam → bevestigingskaart verschijnt in formulier |
| Knop uitgeschakeld zonder patiënt | "Afspraak maken" is niet klikbaar zonder geselecteerde patiënt |
| Sluiten werkt | Klik op "Sluiten" → modal verdwijnt |
---
### cortex/command-center — Cortex interface (5 tests)
Puur UI — geen echte AI-calls.
| Test | Wat wordt gecontroleerd |
|---|---|
| Interface laadt | Invoerveld en voorbeeldtekst zichtbaar |
| Tekst invoeren werkt | Je kunt iets typen in het veld |
| ⌘K opent het veld | Sneltoets focust automatisch op het invoerveld |
| Escape wist de invoer | Escape-toets leegt het veld |
| Voorbeeldcommandos zichtbaar | Hints zoals "notitie jan" en "overdracht" staan in beeld |
---
### cortex/dagnotitie-flow — Chat flows (5 tests)
AI-antwoorden zijn gesimuleerd — tests werken altijd hetzelfde en hebben geen Anthropic API nodig.
| Test | Scenario |
|---|---|
| Dagnotitie | Typ "notitie jan de vries voelt zich goed" → AI antwoordt "Genoteerd." |
| Invoer leeg na verzenden | Na het sturen is het invoerveld weer leeg |
| Zoeken | Typ "zoek marie" → AI antwoordt "Even kijken." |
| Onbekende opdracht | AI vraagt om verduidelijking als de intentie onduidelijk is |
| Meerdere berichten | Eerder gestuurde berichten blijven zichtbaar in de chat |
---
### verpleegrapportage — Rapportagepagina's (3 tests)
| Test | Wat wordt gecontroleerd |
|---|---|
| Rapportage pagina laadt | `/epd/verpleegrapportage` opent zonder fout of redirect naar login |
| Inhoud zichtbaar | Er is een patiëntenlijst of een lege-staat melding |
| Overdracht pagina laadt | `/epd/verpleegrapportage/overdracht` opent correct |
---
## Als een test faalt
**Playwright** schrijft bij een falende test het volgende weg in `tests/test-results/`:
| Bestand | Inhoud |
|---|---|
| `screenshot.png` | Foto van de browser op het moment van de fout |
| `video.webm` | Video van de volledige testrun |
| `error-context.md` | Tekstbeschrijving van wat er misging |
Bekijk het volledige HTML-rapport (in `tests/playwright-report/`):
```bash
pnpm test:e2e:report
```
**Cypress** schrijft bij een falende test naar `tests/cypress/screenshots/` en `tests/cypress/videos/`. In de visuele UI zie je de fout direct in beeld met een tijdlijn van alle uitgevoerde stappen.
---
## Veelvoorkomende problemen
| Probleem | Oplossing |
|---|---|
| Alle tests falen meteen | `pnpm dev` draait niet — start de applicatie eerst |
| Auth setup faalt | Supabase is gepauzeerd — herstart het project op supabase.com |
| Alleen de auth test zichtbaar (Playwright) | Filter staat op "setup" — zet hem op "chromium" of "All" |
| Browser preview blijft leeg (Playwright) | Klik op een individuele test (niet de groepsnaam) en druk op ▶ |
| Cypress vraagt om browser te kiezen | Kies "Chrome" in het openingsscherm |
| Tests werken maar app doet het niet | Tests controleren de interface, niet alle logica — meld bevindingen apart |

View File

@@ -0,0 +1,92 @@
const MOCK_PATIENT = {
id: 'test-patient-uuid',
name_family: 'Robot',
name_given: ['Robbert'],
birth_date: '1990-01-01',
identifier_client_number: '99001',
};
const FHIR_RESPONSE = {
entry: [{
resource: {
id: MOCK_PATIENT.id,
name: [{ family: MOCK_PATIENT.name_family, given: MOCK_PATIENT.name_given }],
birthDate: MOCK_PATIENT.birth_date,
identifier: [{ system: 'http://example.com/client/999.7.6', value: MOCK_PATIENT.identifier_client_number }],
},
}],
};
describe('Agenda', () => {
beforeEach(() => {
cy.login();
cy.visit('/epd/agenda');
});
it('laadt de agendapagina', () => {
cy.contains('h1', 'Agenda').should('be.visible');
cy.contains('Nieuwe Afspraak').should('be.visible');
});
it('toont navigatieknoppen en weergave-switcher', () => {
cy.contains('Vandaag').should('be.visible');
cy.contains('Dag').should('be.visible');
});
});
describe('Afspraak maken', () => {
beforeEach(() => {
cy.intercept('GET', '/api/fhir/Patient**', FHIR_RESPONSE).as('patientSearch');
cy.login();
cy.visit('/epd/agenda');
});
it('opent de "Nieuwe Afspraak" modal', () => {
cy.contains('Nieuwe Afspraak').click();
cy.get('[role="dialog"]').should('be.visible');
cy.get('[role="dialog"]').contains('Nieuwe Afspraak');
});
it('modal toont alle verplichte velden', () => {
cy.contains('Nieuwe Afspraak').click();
cy.get('[role="dialog"]').within(() => {
cy.get('input[placeholder*="Zoek op naam"]').should('be.visible');
cy.get('input[type="date"]').should('be.visible');
cy.get('input[type="time"]').first().should('be.visible');
cy.contains('button', 'Afspraak maken').should('be.visible');
});
});
it('zoeken naar patiënt toont resultaat', () => {
cy.contains('Nieuwe Afspraak').click();
cy.get('[role="dialog"]').within(() => {
cy.get('input[placeholder*="Zoek op naam"]').type('Robbert');
});
cy.wait('@patientSearch');
cy.contains('Robbert Robot').should('be.visible');
});
it('selecteren van patiënt vult het veld in', () => {
cy.contains('Nieuwe Afspraak').click();
cy.get('[role="dialog"]').within(() => {
cy.get('input[placeholder*="Zoek op naam"]').type('Robbert');
});
cy.wait('@patientSearch');
cy.contains('Robbert Robot').click();
cy.get('.bg-teal-50').should('be.visible');
});
it('afspraak maken knop is uitgeschakeld zonder patiënt', () => {
cy.contains('Nieuwe Afspraak').click();
cy.get('[role="dialog"]').within(() => {
cy.contains('button', 'Afspraak maken').should('be.disabled');
});
});
it('sluiten sluit de modal', () => {
cy.contains('Nieuwe Afspraak').click();
cy.get('[role="dialog"]').should('be.visible');
cy.get('[role="dialog"]').contains('button', 'Sluiten').click();
cy.get('[role="dialog"]').should('not.exist');
});
});

View File

@@ -0,0 +1,35 @@
describe('Cortex Command Center - UI', () => {
beforeEach(() => {
cy.login();
cy.visit('/epd/cortex');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('be.visible');
});
it('laadt de command center interface', () => {
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('be.visible');
cy.contains('Welkom bij Cortex Assistent').first().should('be.visible');
});
it('chat input accepteert tekst', () => {
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('test invoer');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('have.value', 'test invoer');
});
it('keyboard shortcut ⌘K focust de input', () => {
cy.get('body').click();
cy.get('body').type('{meta}k');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('be.focused');
});
it('escape wist de input', () => {
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('iets typen');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('{esc}');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('have.value', '');
});
it('toont voorbeeldcommandos in de empty state', () => {
cy.contains('Dagnotitie maken').first().should('be.visible');
cy.contains('Patiënt zoeken').first().should('be.visible');
cy.contains('Overdracht maken').first().should('be.visible');
});
});

View File

@@ -0,0 +1,44 @@
describe('Cortex - Demo flows', () => {
beforeEach(() => {
cy.login();
cy.visit('/epd/cortex');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('be.visible');
});
it('dagnotitie flow: bericht → AI response → artifact verschijnt', () => {
cy.mockDagnotitieChat('Jan de Vries');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('notitie jan de vries voelt zich goed vandaag{enter}');
cy.contains('notitie jan de vries voelt zich goed vandaag').first().should('be.visible');
cy.contains('Genoteerd.').first().should('be.visible');
});
it('dagnotitie flow: input is leeg na verzenden', () => {
cy.mockDagnotitieChat();
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('notitie jan medicatie gegeven{enter}');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('have.value', '');
});
it('zoeken flow: bericht → zoek-artifact verschijnt', () => {
cy.mockZoekenChat('Marie');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('zoek marie{enter}');
cy.contains('Even kijken.').first().should('be.visible');
});
it('onbekende intent: AI vraagt om verduidelijking', () => {
cy.mockOnbekendChat();
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('xyz{enter}');
cy.contains('snap niet helemaal wat je bedoelt').first().should('be.visible');
});
it('meerdere berichten: chat history groeit', () => {
cy.mockDagnotitieChat();
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('notitie jan medicatie gegeven{enter}');
cy.contains('Genoteerd.').first().should('be.visible');
cy.mockDagnotitieChat('Marie');
cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('notitie marie slaapt slecht{enter}');
cy.contains('notitie jan medicatie gegeven').first().should('be.visible');
cy.contains('notitie marie slaapt slecht').first().should('be.visible');
});
});

36
tests/cypress/login.cy.ts Normal file
View File

@@ -0,0 +1,36 @@
describe('Login flow', () => {
beforeEach(() => {
cy.visit('/login');
});
it('toont de loginpagina correct', () => {
cy.get('h2').should('contain', 'Welkom terug');
cy.get('#email').should('be.visible');
cy.get('#password').should('be.visible');
cy.get('button[type="submit"]').should('contain', 'Inloggen');
});
it('toont foutmelding bij verkeerde inloggegevens', () => {
cy.get('#email').type('verkeerd@email.nl');
cy.get('#password').type('foutWachtwoord123');
cy.get('button[type="submit"]').click();
cy.contains('onjuist').should('be.visible');
});
it('demo login knop is zichtbaar en werkt', () => {
cy.contains('Demo Account Proberen').should('be.visible').click();
cy.url().should('match', /\/epd\//);
});
it('redirect naar EPD na succesvol inloggen', () => {
cy.get('#email').type(Cypress.env('TEST_EMAIL') ?? 'demo@mini-ecd.demo');
cy.get('#password').type(Cypress.env('TEST_PASSWORD') ?? 'Demo2024!');
cy.get('button[type="submit"]').click();
cy.url().should('match', /\/epd\//);
});
it('beveiligde route stuurt door naar login', () => {
cy.visit('/epd/dashboard');
cy.url().should('include', '/login');
});
});

View File

@@ -0,0 +1,77 @@
/// <reference types="cypress" />
const TEST_EMAIL = Cypress.env('TEST_EMAIL') ?? 'demo@mini-ecd.demo';
const TEST_PASSWORD = Cypress.env('TEST_PASSWORD') ?? 'Demo2024!';
// Login command met session caching — logt maar één keer in per testrun
Cypress.Commands.add('login', () => {
cy.session('epd-user', () => {
cy.visit('/login');
cy.get('h2').should('contain', 'Welkom terug');
cy.get('#email').type(TEST_EMAIL);
cy.get('#password').type(TEST_PASSWORD);
cy.get('button[type="submit"]').click();
cy.url().should('match', /\/epd\//);
});
});
// AI chat mock — onderschept de Cortex chat API
Cypress.Commands.add('mockDagnotitieChat', (patientName = 'Jan de Vries') => {
const sseBody = [
`data: ${JSON.stringify({ type: 'content', text: `Genoteerd.\n\n\`\`\`json\n${JSON.stringify({
type: 'action',
intent: 'dagnotitie',
entities: { patientName, category: 'observatie', content: 'Patiënt voelt zich goed vandaag.' },
confidence: 0.95,
artifact: { type: 'dagnotitie', prefill: { patientName, category: 'observatie', content: 'Patiënt voelt zich goed vandaag.' } },
}, null, 2)}\n\`\`\`` })}\n\n`,
`data: ${JSON.stringify({ type: 'done' })}\n\n`,
].join('');
cy.intercept('POST', '/api/cortex/chat', {
statusCode: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: sseBody,
}).as('cortexChat');
});
Cypress.Commands.add('mockZoekenChat', (query = 'Jan') => {
const sseBody = [
`data: ${JSON.stringify({ type: 'content', text: `Even kijken.\n\n\`\`\`json\n${JSON.stringify({
type: 'action', intent: 'zoeken',
entities: { query }, confidence: 0.97,
artifact: { type: 'zoeken', prefill: { query } },
}, null, 2)}\n\`\`\`` })}\n\n`,
`data: ${JSON.stringify({ type: 'done' })}\n\n`,
].join('');
cy.intercept('POST', '/api/cortex/chat', {
statusCode: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: sseBody,
}).as('cortexChat');
});
Cypress.Commands.add('mockOnbekendChat', () => {
const sseBody = [
`data: ${JSON.stringify({ type: 'content', text: 'Hmm, snap niet helemaal wat je bedoelt. Kun je het anders zeggen?' })}\n\n`,
`data: ${JSON.stringify({ type: 'done' })}\n\n`,
].join('');
cy.intercept('POST', '/api/cortex/chat', {
statusCode: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: sseBody,
}).as('cortexChat');
});
declare global {
namespace Cypress {
interface Chainable {
login(): Chainable<void>;
mockDagnotitieChat(patientName?: string): Chainable<void>;
mockZoekenChat(query?: string): Chainable<void>;
mockOnbekendChat(): Chainable<void>;
}
}
}

View File

@@ -0,0 +1 @@
import './commands';

View File

@@ -0,0 +1,31 @@
describe('Verpleegrapportage', () => {
beforeEach(() => {
cy.login();
});
it('laadt de rapportage pagina', () => {
cy.visit('/epd/verpleegrapportage');
cy.url().should('not.include', '/login');
cy.get('body').should('be.visible');
});
it('toont lege staat of patiëntenlijst', () => {
cy.visit('/epd/verpleegrapportage');
cy.get('body').then(($body) => {
const hasContent =
$body.find('[class*="rapportage"]').length > 0 ||
$body.find('[class*="patient"]').length > 0 ||
$body.text().includes('Geen patiënten');
expect(hasContent).to.be.true;
});
});
});
describe('Overdracht pagina', () => {
it('laadt de overdracht pagina', () => {
cy.login();
cy.visit('/epd/verpleegrapportage/overdracht');
cy.url().should('not.include', '/login');
cy.get('body').should('be.visible');
});
});

View File

@@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test';
const MOCK_PATIENT = {
id: 'test-patient-uuid',
name_family: 'Robot',
name_given: ['Robbert'],
birth_date: '1990-01-01',
identifier_client_number: '99001',
};
const FHIR_PATIENT_RESPONSE = {
entry: [
{
resource: {
id: MOCK_PATIENT.id,
name: [{ family: MOCK_PATIENT.name_family, given: MOCK_PATIENT.name_given }],
birthDate: MOCK_PATIENT.birth_date,
identifier: [
{ system: 'http://example.com/client/999.7.6', value: MOCK_PATIENT.identifier_client_number },
],
},
},
],
};
test.describe('Agenda', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/epd/agenda');
await page.waitForLoadState('networkidle');
});
test('laadt de agendapagina', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Agenda' })).toBeVisible();
await expect(page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i })).toBeVisible();
});
test('toont navigatieknoppen en weergave-switcher', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Vandaag' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Dag' })).toBeVisible();
});
});
test.describe('Afspraak maken', () => {
test.beforeEach(async ({ page }) => {
// Mock patient search
await page.route('**/api/fhir/Patient**', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FHIR_PATIENT_RESPONSE) });
});
// Mock afspraak aanmaken (voorkom echte database write)
await page.route('**/epd/agenda**', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }) });
} else {
await route.continue();
}
});
await page.goto('/epd/agenda');
await page.waitForLoadState('networkidle');
});
test('opent de "Nieuwe Afspraak" modal', async ({ page }) => {
await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Nieuwe Afspraak' })).toBeVisible();
});
test('modal toont alle verplichte velden', async ({ page }) => {
await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByPlaceholder(/Zoek op naam/i)).toBeVisible();
await expect(dialog.locator('input[type="date"]')).toBeVisible();
await expect(dialog.locator('input[type="time"]').first()).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Afspraak maken' })).toBeVisible();
});
test('zoeken naar patiënt toont resultaat', async ({ page }) => {
await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder(/Zoek op naam/i).fill('Robbert');
// Dropdown met patiënt verschijnt
await expect(dialog.getByText('Robbert Robot')).toBeVisible({ timeout: 5_000 });
});
test('selecteren van patiënt vult het veld in', async ({ page }) => {
await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder(/Zoek op naam/i).fill('Robbert');
await expect(dialog.getByText('Robbert Robot')).toBeVisible({ timeout: 5_000 });
await dialog.getByText('Robbert Robot').click();
// Patiënt bevestigingskaart zichtbaar
await expect(dialog.locator('.bg-teal-50')).toBeVisible();
});
test('afspraak maken knop is uitgeschakeld zonder patiënt', async ({ page }) => {
await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click();
const dialog = page.getByRole('dialog');
const submitButton = dialog.getByRole('button', { name: 'Afspraak maken' });
await expect(submitButton).toBeDisabled();
});
test('sluiten sluit de modal', async ({ page }) => {
await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Sluiten' }).click();
await expect(dialog).not.toBeVisible();
});
});

View File

@@ -0,0 +1,49 @@
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(process.cwd(), 'tests/playwright/.auth/user.json');
const DEMO_EMAIL = process.env.TEST_EMAIL ?? 'demo@mini-ecd.demo';
const DEMO_PASSWORD = process.env.TEST_PASSWORD ?? 'Demo2024!';
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
setup('authenticate as demo user', async ({ page, request }) => {
// Authenticate via Supabase REST API - faster and more reliable than UI form
const response = await request.post(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
headers: {
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
},
data: { email: DEMO_EMAIL, password: DEMO_PASSWORD },
});
if (!response.ok()) {
throw new Error(`Supabase auth failed: ${response.status()} ${await response.text()}`);
}
const tokenData = await response.json();
// Build Supabase auth cookie (format: base64-<base64 encoded JSON>)
const projectRef = SUPABASE_URL.replace('https://', '').split('.')[0];
const cookieName = `sb-${projectRef}-auth-token`;
const cookieValue = `base64-${Buffer.from(JSON.stringify(tokenData)).toString('base64')}`;
await page.context().addCookies([
{
name: cookieName,
value: cookieValue,
domain: 'localhost',
path: '/',
httpOnly: false,
secure: false,
sameSite: 'Lax',
},
]);
// Verify the session works by navigating to a protected route
await page.goto('/epd/cortex');
await page.waitForURL(/\/epd\//, { timeout: 15_000 });
await page.context().storageState({ path: authFile });
});

View File

@@ -0,0 +1,39 @@
import { test, expect, type Page } from '@playwright/test';
// De pagina heeft twee textareas (desktop + mobiel layout). .first() pakt de zichtbare desktop-versie.
const input = (page: Page) => page.getByPlaceholder('Typ of spreek wat je wilt doen...').first();
test.describe('Cortex Command Center - UI', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/epd/cortex');
await expect(input(page)).toBeVisible({ timeout: 10_000 });
});
test('laadt de command center interface', async ({ page }) => {
await expect(input(page)).toBeVisible();
await expect(page.locator('text=Welkom bij Cortex Assistent').first()).toBeVisible();
});
test('chat input accepteert tekst', async ({ page }) => {
await input(page).fill('test invoer');
await expect(input(page)).toHaveValue('test invoer');
});
test('keyboard shortcut ⌘K focust de input', async ({ page }) => {
await page.click('body');
await page.keyboard.press('Meta+k');
await expect(input(page)).toBeFocused();
});
test('escape wist de input', async ({ page }) => {
await input(page).fill('iets typen');
await input(page).press('Escape');
await expect(input(page)).toHaveValue('');
});
test('toont voorbeeldcommandos in de empty state', async ({ page }) => {
await expect(page.locator('text=Dagnotitie maken').first()).toBeVisible();
await expect(page.locator('text=Patiënt zoeken').first()).toBeVisible();
await expect(page.locator('text=Overdracht maken').first()).toBeVisible();
});
});

View File

@@ -0,0 +1,63 @@
import { test, expect, type Page } from '@playwright/test';
import { mockDagnotitieChat, mockZoekenChat, mockOverdrachtChat, mockOnbekendChat } from '../fixtures/ai-mocks';
// De pagina heeft twee textareas (desktop + mobiel layout). .first() pakt de zichtbare desktop-versie.
const input = (page: Page) => page.getByPlaceholder('Typ of spreek wat je wilt doen...').first();
test.describe('Cortex - Demo flows', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/epd/cortex');
await expect(input(page)).toBeVisible({ timeout: 10_000 });
});
test('dagnotitie flow: bericht → AI response → artifact verschijnt', async ({ page }) => {
await mockDagnotitieChat(page, 'Jan de Vries');
await input(page).fill('notitie jan de vries voelt zich goed vandaag');
await input(page).press('Enter');
await expect(page.locator('text=notitie jan de vries voelt zich goed vandaag').first()).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=Genoteerd.').first()).toBeVisible({ timeout: 10_000 });
});
test('dagnotitie flow: input is leeg na verzenden', async ({ page }) => {
await mockDagnotitieChat(page);
await input(page).fill('notitie jan medicatie gegeven');
await input(page).press('Enter');
await expect(input(page)).toHaveValue('', { timeout: 3_000 });
});
test('zoeken flow: bericht → zoek-artifact verschijnt', async ({ page }) => {
await mockZoekenChat(page, 'Marie');
await input(page).fill('zoek marie');
await input(page).press('Enter');
await expect(page.locator('text=zoek marie').first()).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=Even kijken.').first()).toBeVisible({ timeout: 10_000 });
});
test('overdracht flow: bericht → overdracht-artifact', async ({ page }) => {
await mockOverdrachtChat(page);
await input(page).fill('overdracht');
await input(page).press('Enter');
await expect(page.locator('text=overdracht').first()).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=Overdracht klaar.').first()).toBeVisible({ timeout: 10_000 });
});
test('onbekende intent: AI vraagt om verduidelijking', async ({ page }) => {
await mockOnbekendChat(page);
await input(page).fill('xyz');
await input(page).press('Enter');
await expect(page.locator('text=snap niet helemaal wat je bedoelt').first()).toBeVisible({ timeout: 10_000 });
});
test('meerdere berichten: chat history groeit', async ({ page }) => {
await mockDagnotitieChat(page);
await input(page).fill('notitie jan medicatie gegeven');
await input(page).press('Enter');
await expect(page.locator('text=Genoteerd.').first()).toBeVisible({ timeout: 10_000 });
await mockDagnotitieChat(page, 'Marie');
await input(page).fill('notitie marie slaapt slecht');
await input(page).press('Enter');
await expect(page.locator('text=notitie jan medicatie gegeven').first()).toBeVisible();
await expect(page.locator('text=notitie marie slaapt slecht').first()).toBeVisible();
});
});

View File

@@ -0,0 +1,110 @@
import type { Page } from '@playwright/test';
/**
* Builds a mock SSE response body for the Cortex chat endpoint.
* The chat route streams: { type: 'content', text } chunks, then { type: 'done' }.
*/
function buildSSEBody(text: string, actionJson?: object): string {
const encoder = new TextEncoder();
const parts: string[] = [];
// Stream text in one chunk for simplicity
const fullText = actionJson
? `${text}\n\n\`\`\`json\n${JSON.stringify(actionJson, null, 2)}\n\`\`\``
: text;
parts.push(`data: ${JSON.stringify({ type: 'content', text: fullText })}\n\n`);
parts.push(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
return parts.join('');
}
/** Mock de Cortex chat endpoint voor een dagnotitie intent. */
export async function mockDagnotitieChat(page: Page, patientName = 'Jan de Vries') {
await page.route('**/api/cortex/chat', async (route) => {
const action = {
type: 'action',
intent: 'dagnotitie',
entities: {
patientName,
category: 'observatie',
content: 'Patiënt voelt zich goed vandaag.',
},
confidence: 0.95,
artifact: {
type: 'dagnotitie',
prefill: {
patientName,
category: 'observatie',
content: 'Patiënt voelt zich goed vandaag.',
},
},
};
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: buildSSEBody('Genoteerd.', action),
});
});
}
/** Mock de Cortex chat endpoint voor een zoeken intent. */
export async function mockZoekenChat(page: Page, query = 'Jan') {
await page.route('**/api/cortex/chat', async (route) => {
const action = {
type: 'action',
intent: 'zoeken',
entities: { query },
confidence: 0.97,
artifact: {
type: 'zoeken',
prefill: { query },
},
};
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: buildSSEBody('Even kijken.', action),
});
});
}
/** Mock de Cortex chat endpoint voor een overdracht intent. */
export async function mockOverdrachtChat(page: Page) {
await page.route('**/api/cortex/chat', async (route) => {
const action = {
type: 'action',
intent: 'overdracht',
entities: {},
confidence: 0.98,
artifact: {
type: 'overdracht',
prefill: {},
},
};
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: buildSSEBody('Overdracht klaar.', action),
});
});
}
/** Mock de Cortex chat endpoint voor een onduidelijke intent (geen artifact). */
export async function mockOnbekendChat(page: Page) {
await page.route('**/api/cortex/chat', async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: buildSSEBody('Hmm, snap niet helemaal wat je bedoelt. Kun je het anders zeggen?'),
});
});
}
/** Verwijder alle actieve route mocks op de chat endpoint. */
export async function clearChatMocks(page: Page) {
await page.unroute('**/api/cortex/chat');
}

View File

@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
// These tests run WITHOUT storageState (they test the login UI itself)
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Login flow', () => {
test('toont de loginpagina correct', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('h2')).toContainText('Welkom terug');
await expect(page.locator('#email')).toBeVisible();
await expect(page.locator('#password')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('toont foutmelding bij verkeerde inloggegevens', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'verkeerd@email.nl');
await page.fill('#password', 'foutWachtwoord123');
await page.click('button[type="submit"]');
// Wait for error message
await expect(page.locator('text=onjuist')).toBeVisible({ timeout: 8_000 });
});
test('demo login knop is zichtbaar en werkt', async ({ page }) => {
await page.goto('/login');
const demoButton = page.getByRole('button', { name: /Demo Account Proberen/i });
await expect(demoButton).toBeVisible();
await demoButton.click();
// Should redirect to EPD after demo login
await page.waitForURL(/\/epd\//, { timeout: 10_000 });
});
test('redirect naar EPD na succesvol inloggen', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'demo@mini-ecd.demo');
await page.fill('#password', 'Demo2024!');
await page.click('button[type="submit"]');
await page.waitForURL(/\/epd\//, { timeout: 10_000 });
expect(page.url()).toMatch(/\/epd\//);
});
test('beveiligde route stuurt door naar login', async ({ page }) => {
await page.goto('/epd/dashboard');
await page.waitForURL('/login**', { timeout: 5_000 });
expect(page.url()).toContain('/login');
});
});

View File

@@ -0,0 +1,108 @@
import { test, expect } from '@playwright/test';
const PATIENT_NAAM = 'Robbert Robot';
const AUTH_STATE = 'tests/playwright/.auth/user.json';
const RAPPORTAGES = [
{
type: 'Voortgang',
inhoud:
'Cliënt heeft vandaag goed geslapen en voelt zich uitgerust. Medicatie correct ingenomen zonder bijwerkingen. Stemming is stabiel en positief gedurende de gehele dag.',
},
{
type: 'Contact',
inhoud:
'Telefonisch contact gehad met de familie van de cliënt. Positieve terugkoppeling over het afgelopen weekend. Familie ervaart momenteel weinig zorgen over de thuissituatie.',
},
{
type: 'Vrije notitie',
inhoud:
'Cliënt heeft interesse getoond in deelname aan de dagactiviteitengroep. Dit voorstel wordt volgende week besproken tijdens het multidisciplinair overleg met het behandelteam.',
},
];
test.describe(`Rapportages schrijven - ${PATIENT_NAAM}`, () => {
let rapportageUrl: string;
test.beforeAll(async ({ browser }) => {
// Gebruik een aparte context om het patiënt-ID op te zoeken
const context = await browser.newContext({ storageState: AUTH_STATE });
const page = await context.newPage();
await page.goto('/epd/patients');
await page.waitForLoadState('networkidle');
// Zoek Robbert Robot via de zoekbalk
await page.getByPlaceholder('Zoek op naam of BSN...').fill('Robbert');
await expect(page.getByText(PATIENT_NAAM).first()).toBeVisible({ timeout: 8_000 });
await page.getByText(PATIENT_NAAM).first().click();
await page.waitForURL(/\/epd\/patients\/.+/);
const patientId = page.url().match(/\/epd\/patients\/([^/?#]+)/)?.[1];
if (!patientId) throw new Error(`Patiënt "${PATIENT_NAAM}" niet gevonden in de patiëntenlijst`);
rapportageUrl = `/epd/patients/${patientId}/rapportage`;
await context.close();
});
test.beforeEach(async ({ page }) => {
await page.goto(rapportageUrl);
await page.waitForLoadState('networkidle');
// Wacht tot de TipTap editor volledig geladen is
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 });
// Verwijder eventuele localStorage drafts voor een schone start
await page.evaluate(() => {
Object.keys(localStorage)
.filter((k) => k.startsWith('rapportage-draft-'))
.forEach((k) => localStorage.removeItem(k));
});
});
RAPPORTAGES.forEach((rapportage, i) => {
test(`rapportage ${i + 1}: ${rapportage.type}`, async ({ page }) => {
// Selecteer het rapportage type via de QuickActions (exact=true voorkomt match met tijdlijn items)
await page.getByRole('button', { name: rapportage.type, exact: true }).click();
// Klik op de editor en typ de inhoud
const editor = page.locator('.ProseMirror');
await editor.click();
await page.keyboard.type(rapportage.inhoud);
// Verifieer dat de tekst meetelt in de karakterteller
await expect(page.locator('text=/\\d+ \\/ 5000 karakters/')).toBeVisible();
// Opslaan knop moet actief zijn (inhoud > 20 karakters)
const opslaanKnop = page.getByRole('button', { name: 'Opslaan' });
await expect(opslaanKnop).toBeEnabled();
await opslaanKnop.click();
// Verifieer de succesmelding
await expect(page.getByText('Rapportage opgeslagen').first()).toBeVisible({ timeout: 10_000 });
// Na opslaan is de editor leeg → Opslaan knop is weer uitgeschakeld
await expect(opslaanKnop).toBeDisabled({ timeout: 5_000 });
});
});
test('alle drie rapportages staan in de tijdlijn', async ({ page }) => {
// Schrijf de drie rapportages in volgorde
for (const rapportage of RAPPORTAGES) {
await page.getByRole('button', { name: rapportage.type, exact: true }).click();
await page.locator('.ProseMirror').click();
await page.keyboard.type(rapportage.inhoud);
await page.getByRole('button', { name: 'Opslaan', exact: true }).click();
await expect(page.getByText('Rapportage opgeslagen').first()).toBeVisible({ timeout: 10_000 });
// exact: true onderscheidt "Opslaan" (klaar) van "Opslaan…" (bezig) — wacht tot opslaan echt klaar is
await expect(page.getByRole('button', { name: 'Opslaan', exact: true })).toBeDisabled({ timeout: 10_000 });
}
// Controleer dat de tijdlijn de rapportages toont
// TimelineCard rendert als <article role="button"> — anders dan QuickAction <button> elementen
const tijdlijnKaarten = page.locator('article[role="button"]');
await expect(tijdlijnKaarten.filter({ hasText: 'Voortgang' }).first()).toBeVisible({ timeout: 5_000 });
await expect(tijdlijnKaarten.filter({ hasText: 'Contact' }).first()).toBeVisible({ timeout: 5_000 });
await expect(tijdlijnKaarten.filter({ hasText: 'Vrije notitie' }).first()).toBeVisible({ timeout: 5_000 });
});
});

View File

@@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
test.describe('Verpleegrapportage', () => {
test('laadt de rapportage pagina', async ({ page }) => {
await page.goto('/epd/verpleegrapportage');
// Pagina laadt zonder fouten (geen 404/500)
await expect(page).not.toHaveURL(/\/login/);
// Wacht op content
await page.waitForLoadState('networkidle');
// Pagina is zichtbaar (lege state of workspace)
const body = page.locator('body');
await expect(body).toBeVisible();
});
test('toont lege staat of patiëntenlijst', async ({ page }) => {
await page.goto('/epd/verpleegrapportage');
await page.waitForLoadState('networkidle');
// Workspace toont "Ronde overzicht" als er patiënten zijn, anders lege staat
const hasWorkspace = await page.locator('text=Ronde overzicht').isVisible().catch(() => false);
const hasEmptyState = await page.locator('text=Geen patiënten').isVisible().catch(() => false);
expect(hasWorkspace || hasEmptyState).toBeTruthy();
});
});
test.describe('Overdracht pagina', () => {
test('laadt de overdracht pagina', async ({ page }) => {
await page.goto('/epd/verpleegrapportage/overdracht');
await page.waitForLoadState('networkidle');
await expect(page).not.toHaveURL(/\/login/);
await expect(page.locator('body')).toBeVisible();
});
});