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:
25
cypress.config.ts
Normal file
25
cypress.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config({ path: '.env.local' });
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:3000',
|
||||
specPattern: 'tests/cypress/**/*.cy.ts',
|
||||
supportFile: 'tests/cypress/support/e2e.ts',
|
||||
videosFolder: 'tests/cypress/videos',
|
||||
screenshotsFolder: 'tests/cypress/screenshots',
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
video: true,
|
||||
screenshotOnRunFailure: true,
|
||||
defaultCommandTimeout: 10000,
|
||||
pageLoadTimeout: 30000,
|
||||
experimentalModifyObstructiveThirdPartyCode: false,
|
||||
},
|
||||
env: {
|
||||
TEST_EMAIL: process.env.TEST_EMAIL,
|
||||
TEST_PASSWORD: process.env.TEST_PASSWORD,
|
||||
},
|
||||
});
|
||||
41
playwright.config.ts
Normal file
41
playwright.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config({ path: '.env.local' });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/playwright',
|
||||
outputDir: './tests/test-results',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: [['html', { open: 'never', outputFolder: 'tests/playwright-report' }], ['line']],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
locale: 'nl-NL',
|
||||
timezoneId: 'Europe/Amsterdam',
|
||||
},
|
||||
|
||||
projects: [
|
||||
// Auth setup - runs first, creates storageState
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
},
|
||||
|
||||
// All E2E tests - depend on setup, start authenticated
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'tests/playwright/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
8
supabase/.gitignore
vendored
Normal file
8
supabase/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
382
supabase/config.toml
Normal file
382
supabase/config.toml
Normal file
@@ -0,0 +1,382 @@
|
||||
# For detailed configuration reference documentation, visit:
|
||||
# https://supabase.com/docs/guides/local-development/cli/config
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "mini-epd-prototype"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||
schemas = ["public", "graphql_public"]
|
||||
# Extra schemas to add to the search_path of every request.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
|
||||
[api.tls]
|
||||
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||
enabled = false
|
||||
# Paths to self-signed certificate pair.
|
||||
# cert_path = "../certs/my-cert.pem"
|
||||
# key_path = "../certs/my-key.pem"
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 17
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
# Port to use for the local connection pooler.
|
||||
port = 54329
|
||||
# Specifies when a server connection can be reused by other clients.
|
||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||
pool_mode = "transaction"
|
||||
# How many server connections to allow per user/database pair.
|
||||
default_pool_size = 20
|
||||
# Maximum number of client connections allowed.
|
||||
max_client_conn = 100
|
||||
|
||||
# [db.vault]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[db.migrations]
|
||||
# If disabled, migrations will be skipped during a db push or reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of schema files that describe your database.
|
||||
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
||||
schema_paths = []
|
||||
|
||||
[db.seed]
|
||||
# If enabled, seeds the database after migrations during a db reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of seed files to load during db reset.
|
||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||
sql_paths = ["./seed.sql"]
|
||||
|
||||
[db.network_restrictions]
|
||||
# Enable management of network restrictions.
|
||||
enabled = false
|
||||
# List of IPv4 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs = ["0.0.0.0/0"]
|
||||
# List of IPv6 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs_v6 = ["::/0"]
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||
# ip_version = "IPv6"
|
||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||
# max_header_length = 4096
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
# Port to use for Supabase Studio.
|
||||
port = 54323
|
||||
# External URL of the API server that frontend connects to.
|
||||
api_url = "http://127.0.0.1"
|
||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||
openai_api_key = "env(OPENAI_API_KEY)"
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[inbucket]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||
# smtp_port = 54325
|
||||
# pop3_port = 54326
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||
file_size_limit = "50MiB"
|
||||
|
||||
# Uncomment to configure local storage buckets
|
||||
# [storage.buckets.images]
|
||||
# public = false
|
||||
# file_size_limit = "50MiB"
|
||||
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||
# objects_path = "./images"
|
||||
|
||||
# Allow connections via S3 compatible clients
|
||||
[storage.s3_protocol]
|
||||
enabled = true
|
||||
|
||||
# Image transformation API is available to Supabase Pro plan.
|
||||
# [storage.image_transformation]
|
||||
# enabled = true
|
||||
|
||||
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.analytics]
|
||||
enabled = false
|
||||
max_namespaces = 5
|
||||
max_tables = 10
|
||||
max_catalogs = 2
|
||||
|
||||
# Analytics Buckets is available to Supabase Pro plan.
|
||||
# [storage.analytics.buckets.my-warehouse]
|
||||
|
||||
# Store vector embeddings in S3 for large and durable datasets
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.vector]
|
||||
enabled = false
|
||||
max_buckets = 10
|
||||
max_indexes = 5
|
||||
|
||||
# Vector Buckets is available to Supabase Pro plan.
|
||||
# [storage.vector.buckets.documents-openai]
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
|
||||
# jwt_issuer = ""
|
||||
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
||||
# signing_keys_path = "./signing_keys.json"
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
# Allow/disallow anonymous sign-ins to your project.
|
||||
enable_anonymous_sign_ins = false
|
||||
# Allow/disallow testing manual linking of accounts
|
||||
enable_manual_linking = false
|
||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||
minimum_password_length = 6
|
||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||
password_requirements = ""
|
||||
|
||||
[auth.rate_limit]
|
||||
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||
email_sent = 2
|
||||
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||
sms_sent = 30
|
||||
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||
anonymous_users = 30
|
||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||
token_refresh = 150
|
||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||
sign_in_sign_ups = 30
|
||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||
token_verifications = 30
|
||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||
web3 = 30
|
||||
|
||||
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||
# [auth.captcha]
|
||||
# enabled = true
|
||||
# provider = "hcaptcha"
|
||||
# secret = ""
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = false
|
||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||
secure_password_change = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1s"
|
||||
# Number of characters used in the email OTP.
|
||||
otp_length = 6
|
||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||
otp_expiry = 3600
|
||||
|
||||
# Use a production-ready SMTP server
|
||||
# [auth.email.smtp]
|
||||
# enabled = true
|
||||
# host = "smtp.sendgrid.net"
|
||||
# port = 587
|
||||
# user = "apikey"
|
||||
# pass = "env(SENDGRID_API_KEY)"
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
# Uncomment to customize email template
|
||||
# [auth.email.template.invite]
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
# Uncomment to customize notification email template
|
||||
# [auth.email.notification.password_changed]
|
||||
# enabled = true
|
||||
# subject = "Your password has been changed"
|
||||
# content_path = "./templates/password_changed_notification.html"
|
||||
|
||||
[auth.sms]
|
||||
# Allow/disallow new user signups via SMS to your project.
|
||||
enable_signup = false
|
||||
# If enabled, users need to confirm their phone number before signing in.
|
||||
enable_confirmations = false
|
||||
# Template for sending OTP to users
|
||||
template = "Your code is {{ .Code }}"
|
||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||
max_frequency = "5s"
|
||||
|
||||
# Use pre-defined map of phone number to OTP for testing.
|
||||
# [auth.sms.test_otp]
|
||||
# 4152127777 = "123456"
|
||||
|
||||
# Configure logged in session timeouts.
|
||||
# [auth.sessions]
|
||||
# Force log out after the specified duration.
|
||||
# timebox = "24h"
|
||||
# Force log out if the user has been inactive longer than the specified duration.
|
||||
# inactivity_timeout = "8h"
|
||||
|
||||
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||
# [auth.hook.before_user_created]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||
|
||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||
# [auth.hook.custom_access_token]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||
|
||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||
[auth.sms.twilio]
|
||||
enabled = false
|
||||
account_sid = ""
|
||||
message_service_sid = ""
|
||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||
|
||||
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||
[auth.mfa]
|
||||
# Control how many MFA factors can be enrolled at once per user.
|
||||
max_enrolled_factors = 10
|
||||
|
||||
# Control MFA via App Authenticator (TOTP)
|
||||
[auth.mfa.totp]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
|
||||
# Configure MFA via Phone Messaging
|
||||
[auth.mfa.phone]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
otp_length = 6
|
||||
template = "Your code is {{ .Code }}"
|
||||
max_frequency = "5s"
|
||||
|
||||
# Configure MFA via WebAuthn
|
||||
# [auth.mfa.web_authn]
|
||||
# enroll_enabled = true
|
||||
# verify_enabled = true
|
||||
|
||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
|
||||
[auth.external.apple]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||
# Overrides the default auth redirectUrl.
|
||||
redirect_uri = ""
|
||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||
# or any other third-party OIDC providers.
|
||||
url = ""
|
||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||
skip_nonce_check = false
|
||||
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
||||
email_optional = false
|
||||
|
||||
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||
[auth.web3.solana]
|
||||
enabled = false
|
||||
|
||||
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.firebase]
|
||||
enabled = false
|
||||
# project_id = "my-firebase-project"
|
||||
|
||||
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.auth0]
|
||||
enabled = false
|
||||
# tenant = "my-auth0-tenant"
|
||||
# tenant_region = "us"
|
||||
|
||||
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.aws_cognito]
|
||||
enabled = false
|
||||
# user_pool_id = "my-user-pool-id"
|
||||
# user_pool_region = "us-east-1"
|
||||
|
||||
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.clerk]
|
||||
enabled = false
|
||||
# Obtain from https://clerk.com/setup/supabase
|
||||
# domain = "example.clerk.accounts.dev"
|
||||
|
||||
# OAuth server configuration
|
||||
[auth.oauth_server]
|
||||
# Enable OAuth server functionality
|
||||
enabled = false
|
||||
# Path for OAuth consent flow UI
|
||||
authorization_url_path = "/oauth/consent"
|
||||
# Allow dynamic client registration
|
||||
allow_dynamic_registration = false
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
# Supported request policies: `oneshot`, `per_worker`.
|
||||
# `per_worker` (default) — enables hot reload during local development.
|
||||
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
||||
policy = "per_worker"
|
||||
# Port to attach the Chrome inspector for debugging edge functions.
|
||||
inspector_port = 8083
|
||||
# The Deno major version to use.
|
||||
deno_version = 2
|
||||
|
||||
# [edge_runtime.secrets]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[analytics]
|
||||
enabled = true
|
||||
port = 54327
|
||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||
backend = "postgres"
|
||||
|
||||
# Experimental features may be deprecated any time
|
||||
[experimental]
|
||||
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||
orioledb_version = ""
|
||||
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||
s3_host = "env(S3_HOST)"
|
||||
# Configures S3 bucket region, eg. us-east-1
|
||||
s3_region = "env(S3_REGION)"
|
||||
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||
256
tests/README.md
Normal file
256
tests/README.md
Normal 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 |
|
||||
92
tests/cypress/agenda/agenda.cy.ts
Normal file
92
tests/cypress/agenda/agenda.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
35
tests/cypress/cortex/command-center.cy.ts
Normal file
35
tests/cypress/cortex/command-center.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
44
tests/cypress/cortex/dagnotitie-flow.cy.ts
Normal file
44
tests/cypress/cortex/dagnotitie-flow.cy.ts
Normal 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
36
tests/cypress/login.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
77
tests/cypress/support/commands.ts
Normal file
77
tests/cypress/support/commands.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
tests/cypress/support/e2e.ts
Normal file
1
tests/cypress/support/e2e.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './commands';
|
||||
31
tests/cypress/verpleegrapportage/overzicht.cy.ts
Normal file
31
tests/cypress/verpleegrapportage/overzicht.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
120
tests/playwright/agenda/agenda.spec.ts
Normal file
120
tests/playwright/agenda/agenda.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
tests/playwright/auth.setup.ts
Normal file
49
tests/playwright/auth.setup.ts
Normal 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 });
|
||||
});
|
||||
39
tests/playwright/cortex/command-center.spec.ts
Normal file
39
tests/playwright/cortex/command-center.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
63
tests/playwright/cortex/dagnotitie-flow.spec.ts
Normal file
63
tests/playwright/cortex/dagnotitie-flow.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
110
tests/playwright/fixtures/ai-mocks.ts
Normal file
110
tests/playwright/fixtures/ai-mocks.ts
Normal 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');
|
||||
}
|
||||
56
tests/playwright/login.spec.ts
Normal file
56
tests/playwright/login.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
108
tests/playwright/patients/rapportage.spec.ts
Normal file
108
tests/playwright/patients/rapportage.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
38
tests/playwright/verpleegrapportage/overzicht.spec.ts
Normal file
38
tests/playwright/verpleegrapportage/overzicht.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user