This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
{
"files": ["README.md"],
"imageSize": 100,
"commit": false,
"contributors": [
{
"login": "swederik",
"name": "Erik Ziegler",
"avatar_url": "https://avatars3.githubusercontent.com/u/607793?v=4",
"profile": "https://github.com/swederik",
"contributions": ["code", "infra"]
},
{
"login": "evren217",
"name": "Evren Ozkan",
"avatar_url": "https://avatars1.githubusercontent.com/u/4920551?v=4",
"profile": "https://github.com/evren217",
"contributions": ["code"]
},
{
"login": "galelis",
"name": "Gustavo André Lelis",
"avatar_url": "https://avatars3.githubusercontent.com/u/2378326?v=4",
"profile": "https://github.com/galelis",
"contributions": ["code"]
},
{
"login": "dannyrb",
"name": "Danny Brown",
"avatar_url": "https://avatars1.githubusercontent.com/u/5797588?v=4",
"profile": "http://dannyrb.com/",
"contributions": ["code", "infra"]
},
{
"login": "allcontributors",
"name": "allcontributors[bot]",
"avatar_url": "https://avatars3.githubusercontent.com/u/46843839?v=4",
"profile": "https://github.com/all-contributors/all-contributors-bot",
"contributions": ["doc"]
},
{
"login": "EsrefDurna",
"name": "Esref Durna",
"avatar_url": "https://avatars0.githubusercontent.com/u/1230575?v=4",
"profile": "https://www.linkedin.com/in/siliconvalleynextgeneration/",
"contributions": ["question"]
},
{
"login": "diego0020",
"name": "diego0020",
"avatar_url": "https://avatars3.githubusercontent.com/u/7297450?v=4",
"profile": "https://github.com/diego0020",
"contributions": ["code"]
},
{
"login": "dlwire",
"name": "David Wire",
"avatar_url": "https://avatars3.githubusercontent.com/u/1167291?v=4",
"profile": "https://github.com/dlwire",
"contributions": ["code"]
},
{
"login": "jfmedeiros1820",
"name": "João Felipe de Medeiros Moreira",
"avatar_url": "https://avatars1.githubusercontent.com/u/2211708?v=4",
"profile": "https://github.com/jfmedeiros1820",
"contributions": ["test"]
},
{
"login": "pavertomato",
"name": "Egor Lezhnin",
"avatar_url": "https://avatars0.githubusercontent.com/u/878990?v=4",
"profile": "http://egor.lezhn.in",
"contributions": ["code"]
}
],
"contributorsPerLine": 7,
"projectName": "Viewers",
"projectOwner": "OHIF",
"repoType": "github",
"repoHost": "https://github.com"
}

View File

@@ -0,0 +1,7 @@
# Browsers that we support
> 1%
IE 11
not IE < 11
not dead
not op_mini all

View File

@@ -0,0 +1,23 @@
# Output
dist/
build/
# Dependencies
node_modules/
# Root
README.md
Dockerfile
dockerfile
# Misc. Config
.git
.DS_Store
.gitignore
.vscode
.circleci
# Unnecessary things to pull into container
extensions
docs
cypress

10
platform/app/.env.example Normal file
View File

@@ -0,0 +1,10 @@
##
# EXAMPLE
#
# Read more about .env files when using create-react-app here:
# https://facebook.github.io/create-react-app/docs/adding-custom-environment-variables#adding-development-environment-variables-in-env
#
PUBLIC_URL=/demo/
APP_CONFIG=config/netlify.js
USE_HASH_ROUTER=false

View File

@@ -0,0 +1,3 @@
config/**
docs/**
img/**

3
platform/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.vercel
test-results
store.json

View File

@@ -0,0 +1,6 @@
logs/*
volumes/*
config/letsencrypt/*
config/certbot/*
!config/letsencrypt/.gitkeep
!config/certbot/.gitkeep

View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Start oauth2-proxy
oauth2-proxy --config=/etc/oauth2-proxy/oauth2-proxy.cfg &
# Start nginx
nginx -g "daemon off;"

View File

@@ -0,0 +1,240 @@
worker_processes auto;
error_log /var/logs/nginx/error.log debug;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include '/etc/nginx/mime.types';
default_type application/octet-stream;
keepalive_timeout 65;
keepalive_requests 100000;
tcp_nopush on;
tcp_nodelay on;
proxy_buffers 16 16k;
proxy_buffer_size 32k;
proxy_busy_buffers_size 64k;
proxy_max_temp_file_size 128k;
gzip on;
gzip_disable "msie6";
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
server {
listen 80;
server_name YOUR_DOMAIN;
client_max_body_size 0;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name YOUR_DOMAIN;
ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
root /var/www/html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_comp_level 9;
etag on;
location /sw.js {
add_header Cache-Control "no-cache";
proxy_cache_bypass $http_pragma;
proxy_cache_revalidate on;
expires off;
access_log off;
}
location /oauth2 {
expires -1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Auth-Request-Redirect $request_uri;
proxy_pass http://localhost:4180$uri$is_args$args;
}
location /oauth2/callback {
proxy_pass http://localhost:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /oauth2/sign_out {
expires -1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Auth-Request-Redirect /oauth2/sign_in;
proxy_pass http://localhost:4180;
}
location /pacs/ {
auth_request /oauth2/auth;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 0;
add_header Cache-Control private;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always;
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
rewrite ^/pacs/(.*) /dcm4chee-arc/aets/DCM4CHEE/rs/$1 break;
proxy_pass http://arc:8080;
}
location /pacs-admin {
return 301 /pacs-admin/;
}
# Redirect /pacs-admin to /dcm4chee-arc/ui2/
location = /pacs-admin {
return 301 $scheme://$host/dcm4chee-arc/ui2/;
}
# Handle /pacs-admin/ requests
location /pacs-admin/ {
return 301 $scheme://$host/dcm4chee-arc/ui2/;
}
# Proxy pass for /dcm4chee-arc/ui2/
location /dcm4chee-arc/ui2/ {
error_page 401 = /oauth2/sign_in?rd=$scheme://$host$request_uri;
auth_request /oauth2/auth?allowed_groups=pacsadmin;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $token $upstream_http_x_auth_request_access_token;
auth_request_set $auth_cookie $upstream_http_set_cookie;
proxy_set_header X-User $user;
proxy_set_header X-Access-Token $token;
add_header Set-Cookie $auth_cookie;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 0;
add_header Cache-Control private;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always;
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://arc:8080;
}
# Proxy pass for other /dcm4chee-arc/ requests
location /dcm4chee-arc/ {
proxy_pass http://arc:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /pacs {
return 301 /pacs/;
}
location /ohif-viewer/ {
expires -1;
error_page 401 = /oauth2/sign_in?rd=$scheme://$host$request_uri;
auth_request /oauth2/auth;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $token $upstream_http_x_auth_request_access_token;
auth_request_set $auth_cookie $upstream_http_set_cookie;
proxy_set_header X-User $user;
proxy_set_header X-Access-Token $token;
add_header Set-Cookie $auth_cookie;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Proto $scheme;
index index.html;
try_files $uri $uri/ /index.html;
}
location /ohif-viewer {
return 301 /ohif-viewer/;
}
location = / {
return 301 /ohif-viewer/;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
}
location /keycloak/ {
proxy_pass http://keycloak:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /keycloak {
return 301 /keycloak/;
}
}
}

View File

@@ -0,0 +1,22 @@
http_address="0.0.0.0:4180"
cookie_secret="GENERATEACOOKIESECRET----------------------="
email_domains=["*"]
cookie_secure="false"
cookie_expire="9m30s"
cookie_refresh="5m"
client_secret="2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg"
client_id="ohif_viewer"
redirect_url="http://YOUR_DOMAIN/oauth2/callback"
ssl_insecure_skip_verify = true
insecure_oidc_allow_unverified_email = true
pass_access_token = true
provider="keycloak-oidc"
provider_display_name="Keycloak"
user_id_claim="oid"
oidc_email_claim="sub"
scope="openid"
pass_host_header=true
code_challenge_method="S256"
oidc_issuer_url="http://YOUR_DOMAIN/keycloak/realms/ohif"
insecure_oidc_skip_issuer_verification = true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
STORAGE_DIR=/storage/fs1
POSTGRES_DB=pacsdb
POSTGRES_USER=pacs
POSTGRES_PASSWORD=pacs

View File

@@ -0,0 +1,161 @@
version: '3.8'
services:
ldap:
image: dcm4che/slapd-dcm4chee:2.6.3-29.0
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "389:389"
env_file: docker-compose.env
volumes:
- ~/dcm4chee-arc/ldap:/var/lib/ldap
- ~/dcm4chee-arc/slapd.d:/etc/ldap/slapd.d
db:
image: dcm4che/postgres-dcm4chee:14.5-29
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "5432:5432"
env_file: docker-compose.env
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ~/dcm4chee-arc/db:/var/lib/postgresql/data
arc:
image: dcm4che/dcm4chee-arc-psql:5.29.0
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "8080:8080"
- "8443:8443"
- "9990:9990"
- "9993:9993"
- "11112:11112"
- "2762:2762"
- "2575:2575"
- "12575:12575"
env_file: docker-compose.env
environment:
WILDFLY_CHOWN: /opt/wildfly/standalone /storage
WILDFLY_WAIT_FOR: ldap:389 db:5432
depends_on:
- ldap
- db
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ~/dcm4chee-arc/wildfly:/opt/wildfly/standalone
- ~/dcm4chee-arc/storage:/storage
ohif_viewer:
build:
context: ./../../../../
dockerfile: ./platform/app/.recipes/Nginx-Dcm4chee-Keycloak/dockerfile
image: webapp:latest
container_name: webapp
ports:
- '443:443' # SSL
- '80:80' # Web
depends_on:
keycloak:
condition: service_healthy
restart: on-failure
networks:
- default
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
- OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
volumes:
- ./config/nginx.conf:/etc/nginx/nginx.conf
- ./config/oauth2-proxy.cfg:/etc/oauth2-proxy/oauth2-proxy.cfg
- ./config/letsencrypt:/etc/letsencrypt
- ./config/certbot:/var/www/certbot
keycloak:
image: quay.io/keycloak/keycloak:24.0.5
command: 'start-dev --import-realm'
hostname: keycloak
container_name: keycloak
volumes:
- ./config/ohif-keycloak-realm.json:/opt/keycloak/data/import/ohif-keycloak-realm.json
environment:
KC_DB_URL_HOST: postgres
KC_DB: postgres
KC_DB_URL: 'jdbc:postgresql://postgres:5432/keycloak'
KC_DB_SCHEMA: public
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: password
KC_HOSTNAME_ADMIN_URL: http://YOUR_DOMAIN/keycloak/
KC_HOSTNAME_URL: http://YOUR_DOMAIN/keycloak/
KC_HOSTNAME_STRICT_BACKCHANNEL: true
KC_HOSTNAME_STRICT_HTTPS: false
KC_HTTP_ENABLED: true
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: true
KC_METRICS_ENABLED: true
KC_PROXY: edge
KC_PROXY_HEADERS: xforwarded
KEYCLOAK_JDBC_PARAMS: connectTimeout=40000
KC_LOG_LEVEL: INFO
KC_HOSTNAME_DEBUG: true
PROXY_ADDRESS_FORWARDING: true
ports:
- 8081:8080
depends_on:
- postgres
restart: unless-stopped
networks:
- default
extra_hosts:
- 'host.docker.internal:host-gateway'
healthcheck:
test:
[
"CMD-SHELL",
"exec 3<>/dev/tcp/YOUR_DOMAIN/8080;echo -e \"GET /health/ready HTTP/1.1\r\nhost: http://localhost\r\nConnection: close\r\n\r\n\" >&3;grep \"HTTP/1.1 200 OK\" <&3"
]
interval: 1s
timeout: 5s
retries: 10
start_period: 60s
postgres:
image: postgres:15
hostname: postgres
container_name: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: password
restart: unless-stopped
networks:
- default
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- ./config/letsencrypt:/etc/letsencrypt
- ./config/certbot:/var/www/certbot
entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;"
volumes:
postgres_data:
driver: local
networks:
default:
driver: bridge

View File

@@ -0,0 +1,50 @@
# Stage 1: Build the application
FROM node:18.16.1-slim as builder
# Setup the working directory
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# Install dependencies
RUN apt-get update && apt-get install -y build-essential python3
# Copy the entire project
COPY ./ /usr/src/app/
# Install node dependencies
RUN yarn config set workspaces-experimental true
RUN yarn install
# Set the environment for the build
ENV APP_CONFIG=config/docker-nginx-dcm4chee-keycloak.js
# Build the application
RUN yarn run build
# Stage 2: Setup the NGINX environment with OAuth2 Proxy
FROM nginx:alpine
# Install dependencies for oauth2-proxy
RUN apk add --no-cache curl
# Create necessary directories
RUN mkdir -p /var/logs/nginx /var/www/html /etc/oauth2-proxy
# Download and install oauth2-proxy
RUN curl -L https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.4.0/oauth2-proxy-v7.4.0.linux-amd64.tar.gz -o oauth2-proxy.tar.gz && \
tar -xvzf oauth2-proxy.tar.gz && \
mv oauth2-proxy-v7.4.0.linux-amd64/oauth2-proxy /usr/local/bin/ && \
rm -rf oauth2-proxy-v7.4.0.linux-amd64 oauth2-proxy.tar.gz
# Copy the built application
COPY --from=builder /usr/src/app/platform/app/dist /var/www/html
# Copy the entrypoint script
COPY ./platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/entrypoint.sh /entrypoint.sh
# Expose necessary ports
EXPOSE 80 443 4180
# Set the entrypoint script as the entrypoint
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1 @@
America/New_York

View File

@@ -0,0 +1,86 @@
worker_processes auto;
error_log /var/logs/nginx/error.log debug;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/logs/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_disable "msie6";
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 0;
# Handle /pacs requests and rewrite them to the correct dcm4chee-arc UI path
# This allows accessing the dcm4chee-arc UI through the /pacs URL
location /pacs {
rewrite ^/pacs(.*)$ /dcm4chee-arc/ui2$1 break;
proxy_pass http://arc:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
expires 0;
add_header Cache-Control private;
}
# Proxy all dcm4chee-arc requests
# This block handles all API requests and general dcm4chee-arc paths
location /dcm4chee-arc/ {
proxy_pass http://arc:8080/dcm4chee-arc/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
}
location /sw.js {
add_header Cache-Control "no-cache";
proxy_cache_bypass $http_pragma;
proxy_cache_revalidate on;
expires off;
access_log off;
}
location / {
root /var/www/html;
index index.html;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Cross-Origin-Opener-Policy 'same-origin' always;
add_header Cross-Origin-Embedder-Policy 'require-corp' always;
}
}
}

View File

@@ -0,0 +1,4 @@
STORAGE_DIR=/storage/fs1
POSTGRES_DB=pacsdb
POSTGRES_USER=pacs
POSTGRES_PASSWORD=pacs

View File

@@ -0,0 +1,73 @@
services:
ldap:
image: dcm4che/slapd-dcm4chee:2.6.3-29.0
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "389:389"
env_file: docker-compose.env
volumes:
- ~/dcm4chee-arc/ldap:/var/lib/ldap
- ~/dcm4chee-arc/slapd.d:/etc/ldap/slapd.d
db:
image: dcm4che/postgres-dcm4chee:14.5-29
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "5432:5432"
env_file: docker-compose.env
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ~/dcm4chee-arc/db:/var/lib/postgresql/data
arc:
image: dcm4che/dcm4chee-arc-psql:5.29.0
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "8080:8080"
- "8443:8443"
- "9990:9990"
- "9993:9993"
- "11112:11112"
- "2762:2762"
- "2575:2575"
- "12575:12575"
env_file: docker-compose.env
environment:
WILDFLY_CHOWN: /opt/wildfly/standalone /storage
WILDFLY_WAIT_FOR: ldap:389 db:5432
depends_on:
- ldap
- db
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ~/dcm4chee-arc/wildfly:/opt/wildfly/standalone
- ~/dcm4chee-arc/storage:/storage
ohif_viewer:
build:
# Project root
context: ./../../../../
# Relative to context
dockerfile: ./platform/app/.recipes/Nginx-Dcm4chee/dockerfile
image: webapp:latest
container_name: ohif_dcm4chee
volumes:
# Nginx config
- ./config/nginx.conf:/etc/nginx/nginx.conf
# Logs
- ./logs/nginx:/var/logs/nginx
# Let's Encrypt
# - letsencrypt_certificates:/etc/letsencrypt
# - letsencrypt_challenges:/var/www/letsencrypt
ports:
- '443:443' # SSL
- '80:80' # Web
restart: on-failure

View File

@@ -0,0 +1,43 @@
# Stage 1: Build the application
FROM node:18.16.1-slim as builder
# Setup the working directory
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# Install dependencies
# apt-get update is combined with apt-get install to avoid using outdated packages
RUN apt-get update && apt-get install -y build-essential python3
# Copy package.json and other dependency-related files first
# Assuming your package.json and yarn.lock or similar are located in the project root
COPY ./ /usr/src/app/
# Install node dependencies
RUN yarn config set workspaces-experimental true
RUN yarn install
# Copy the rest of the application code
# set QUICK_BUILD to true to make the build faster for dev
ENV APP_CONFIG=config/docker-nginx-dcm4chee.js
# Build the application
RUN yarn run build
# # Stage 2: Bundle the built application into a Docker container which runs NGINX using Alpine Linux
FROM nginx:alpine
# # Create directories for logs and html content if they don't already exist
RUN mkdir -p /var/log/nginx /var/www/html
# # Copy build output to serve static files
COPY --from=builder /usr/src/app/platform/app/dist /var/www/html
# # Expose HTTP and HTTPS ports
EXPOSE 80 443
# # Start NGINX
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1 @@
America/New_York

View File

@@ -0,0 +1,6 @@
logs/*
volumes/*
config/letsencrypt/*
config/certbot/*
!config/letsencrypt/.gitkeep
!config/certbot/.gitkeep

View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Start oauth2-proxy
oauth2-proxy --config=/etc/oauth2-proxy/oauth2-proxy.cfg &
# Start nginx
nginx -g "daemon off;"

View File

@@ -0,0 +1,210 @@
worker_processes 2;
error_log /var/logs/nginx/mydomain.error.log;
pid /var/run/nginx.pid;
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include '/etc/nginx/mime.types';
default_type application/octet-stream;
keepalive_timeout 65;
keepalive_requests 100000;
tcp_nopush on;
tcp_nodelay on;
proxy_buffers 16 16k;
proxy_buffer_size 32k;
proxy_busy_buffers_size 64k;
proxy_max_temp_file_size 128k;
server {
listen 80;
server_name YOUR_DOMAIN;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name YOUR_DOMAIN;
ssl_certificate /etc/letsencrypt/live/ohifviewer.duckdns.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ohifviewer.duckdns.org/privkey.pem;
root /var/www/html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_comp_level 9;
etag on;
location /sw.js {
add_header Cache-Control "no-cache";
proxy_cache_bypass $http_pragma;
proxy_cache_revalidate on;
expires off;
access_log off;
}
location /oauth2 {
expires -1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Auth-Request-Redirect $request_uri;
proxy_pass http://localhost:4180$uri$is_args$args;
}
location /oauth2/callback {
proxy_pass http://localhost:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /oauth2/sign_out {
expires -1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Auth-Request-Redirect /oauth2/sign_in;
proxy_pass http://localhost:4180;
}
location /pacs-admin/ {
error_page 401 = /oauth2/sign_in?rd=$scheme://$host$request_uri;
auth_request /oauth2/auth?allowed_groups=pacsadmin;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $token $upstream_http_x_auth_request_access_token;
auth_request_set $auth_cookie $upstream_http_set_cookie;
proxy_set_header X-User $user;
proxy_set_header X-Access-Token $token;
add_header Set-Cookie $auth_cookie;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 0;
add_header Cache-Control private;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always;
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://orthanc:8042/;
}
location /pacs-admin {
return 301 /pacs-admin/;
}
location /pacs/ {
auth_request /oauth2/auth;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 0;
add_header Cache-Control private;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always;
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://orthanc:8042/dicom-web/;
}
location /pacs {
return 301 /pacs/;
}
location /ohif-viewer/ {
expires -1;
error_page 401 = /oauth2/sign_in?rd=$scheme://$host$request_uri;
auth_request /oauth2/auth;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $token $upstream_http_x_auth_request_access_token;
auth_request_set $auth_cookie $upstream_http_set_cookie;
proxy_set_header X-User $user;
proxy_set_header X-Access-Token $token;
add_header Set-Cookie $auth_cookie;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Proto $scheme;
index index.html;
try_files $uri $uri/ /index.html;
}
location /ohif-viewer {
return 301 /ohif-viewer/;
}
location = / {
return 301 /ohif-viewer/;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
}
location /keycloak/ {
proxy_pass http://keycloak:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /keycloak {
return 301 /keycloak/;
}
}
}

View File

@@ -0,0 +1,22 @@
http_address="0.0.0.0:4180"
cookie_secret="GENERATEACOOKIESECRET----------------------="
email_domains=["*"]
cookie_secure="false"
cookie_expire="9m30s"
cookie_refresh="5m"
client_secret="2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg"
client_id="ohif_viewer"
redirect_url="http://YOUR_DOMAIN/oauth2/callback"
ssl_insecure_skip_verify = true
insecure_oidc_allow_unverified_email = true
pass_access_token = true
provider="keycloak-oidc"
provider_display_name="Keycloak"
user_id_claim="oid"
oidc_email_claim="sub"
scope="openid"
pass_host_header=true
code_challenge_method="S256"
oidc_issuer_url="http://YOUR_DOMAIN/keycloak/realms/ohif"
insecure_oidc_skip_issuer_verification = true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
{
"Name": "Orthanc inside Docker",
"StorageDirectory": "/var/lib/orthanc/db",
"IndexDirectory": "/var/lib/orthanc/db",
"StorageCompression": false,
"MaximumStorageSize": 0,
"MaximumPatientCount": 0,
"LuaScripts": [],
"Plugins": ["/usr/share/orthanc/plugins", "/usr/local/share/orthanc/plugins"],
"ConcurrentJobs": 2,
"HttpServerEnabled": true,
"HttpPort": 8042,
"HttpDescribeErrors": true,
"HttpCompressionEnabled": true,
"DicomServerEnabled": true,
"DicomAet": "ORTHANC",
"DicomCheckCalledAet": false,
"DicomPort": 4242,
"DefaultEncoding": "Latin1",
"DeflatedTransferSyntaxAccepted": true,
"JpegTransferSyntaxAccepted": true,
"Jpeg2000TransferSyntaxAccepted": true,
"JpegLosslessTransferSyntaxAccepted": true,
"JpipTransferSyntaxAccepted": true,
"Mpeg2TransferSyntaxAccepted": true,
"RleTransferSyntaxAccepted": true,
"UnknownSopClassAccepted": false,
"DicomScpTimeout": 30,
"RemoteAccessAllowed": true,
"SslEnabled": false,
"SslCertificate": "certificate.pem",
"AuthenticationEnabled": false,
"RegisteredUsers": {
"test": "test"
},
"DicomModalities": {},
"DicomModalitiesInDatabase": false,
"DicomAlwaysAllowEcho": true,
"DicomAlwaysAllowStore": true,
"DicomCheckModalityHost": false,
"DicomScuTimeout": 10,
"OrthancPeers": {},
"OrthancPeersInDatabase": false,
"HttpProxy": "",
"HttpVerbose": true,
"HttpTimeout": 10,
"HttpsVerifyPeers": true,
"HttpsCACertificates": "",
"UserMetadata": {},
"UserContentType": {},
"StableAge": 60,
"StrictAetComparison": false,
"StoreMD5ForAttachments": true,
"LimitFindResults": 0,
"LimitFindInstances": 0,
"LimitJobs": 10,
"LogExportedResources": false,
"KeepAlive": true,
"TcpNoDelay": true,
"HttpThreadsCount": 50,
"StoreDicom": true,
"DicomAssociationCloseDelay": 5,
"QueryRetrieveSize": 10,
"CaseSensitivePN": false,
"LoadPrivateDictionary": true,
"Dictionary": {},
"SynchronousCMove": true,
"JobsHistorySize": 10,
"SaveJobs": true,
"OverwriteInstances": false,
"MediaArchiveSize": 1,
"StorageAccessOnFind": "Always",
"MetricsEnabled": true,
"DicomWeb": {
"Enable": true,
"Root": "/dicom-web/",
"EnableWado": true,
"WadoRoot": "/wado",
"Host": "127.0.0.1",
"Ssl": false,
"StowMaxInstances": 10,
"StowMaxSize": 10,
"QidoCaseSensitive": false
}
}

View File

@@ -0,0 +1,126 @@
services:
ohif_viewer:
build:
context: ./../../../../
dockerfile: ./platform/app/.recipes/Nginx-Orthanc-Keycloak/dockerfile
image: webapp:latest
container_name: webapp
ports:
- '443:443' # SSL
- '80:80' # Web
depends_on:
keycloak:
condition: service_healthy
restart: on-failure
networks:
- default
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
- OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
volumes:
# - ../../app/dist /var/www/html
- ./config/nginx.conf:/etc/nginx/nginx.conf
- ./config/oauth2-proxy.cfg:/etc/oauth2-proxy/oauth2-proxy.cfg
- ./config/letsencrypt:/etc/letsencrypt
- ./config/certbot:/var/www/certbot
orthanc:
image: jodogne/orthanc-plugins
hostname: orthanc
container_name: orthanc
volumes:
- ./config/orthanc.json:/etc/orthanc/orthanc.json:ro
- ./volumes/orthanc-db/:/var/lib/orthanc/db/
restart: unless-stopped
networks:
- default
keycloak:
image: quay.io/keycloak/keycloak:24.0.5
command: 'start-dev --import-realm'
hostname: keycloak
container_name: keycloak
volumes:
- ./config/ohif-keycloak-realm.json:/opt/keycloak/data/import/ohif-keycloak-realm.json
environment:
# Database
KC_DB_URL_HOST: postgres
KC_DB: postgres
KC_DB_URL: 'jdbc:postgresql://postgres:5432/keycloak'
KC_DB_SCHEMA: public
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: password
KC_HOSTNAME_ADMIN_URL: http://YOUR_DOMAIN/keycloak/
KC_HOSTNAME_URL: http://YOUR_DOMAIN/keycloak/
KC_HOSTNAME_STRICT_BACKCHANNEL: true
KC_HOSTNAME_STRICT_HTTPS: false
KC_HTTP_ENABLED: true
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: true
KC_METRICS_ENABLED: true
KC_PROXY: edge
KC_PROXY_HEADERS: xforwarded
KEYCLOAK_JDBC_PARAMS: connectTimeout=40000
KC_LOG_LEVEL: INFO
KC_HOSTNAME_DEBUG: true
# added later
PROXY_ADDRESS_FORWARDING: true
ports:
- 8080:8080
depends_on:
- postgres
restart: unless-stopped
networks:
- default
extra_hosts:
- 'host.docker.internal:host-gateway'
healthcheck:
test: [
'CMD-SHELL',
"exec 3<>/dev/tcp/YOUR_DOMAIN/8080;echo -e \"GET /health/ready HTTP/1.1\r
host: http://localhost\r
Connection: close\r
\r
\" >&3;grep \"HTTP/1.1 200 OK\" <&3",
]
interval: 1s
timeout: 5s
retries: 10
start_period: 60s
postgres:
image: postgres:15
hostname: postgres
container_name: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: password
restart: unless-stopped
networks:
- default
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- ./config/letsencrypt:/etc/letsencrypt
- ./config/certbot:/var/www/certbot
entrypoint:
/bin/sh -c "trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;"
volumes:
postgres_data:
driver: local
networks:
default:
driver: bridge

View File

@@ -0,0 +1,57 @@
# Stage 1: Build the application
FROM node:18.16.1-slim as builder
# Setup the working directory
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# Install dependencies
# apt-get update is combined with apt-get install to avoid using outdated packages
RUN apt-get update && apt-get install -y build-essential python3
# Copy package.json and other dependency-related files first
# Assuming your package.json and yarn.lock or similar are located in the project root
# Todo: this probably can get improved by copying
# only the package json files and running yarn install before
# copying the rest of the files but having a monorepo setup
# makes this a bit more complicated, i wasn't able to get it working
COPY ./ /usr/src/app/
# Install node dependencies
RUN yarn config set workspaces-experimental true
RUN yarn install
# Copy the rest of the application code
# set QUICK_BUILD to true to make the build faster for dev
ENV APP_CONFIG=config/docker-nginx-orthanc-keycloak.js
# Build the application
RUN yarn run build
# Use nginx as the base image
FROM nginx:alpine
# Install dependencies for oauth2-proxy
RUN apk add --no-cache curl
# Create necessary directories
RUN mkdir -p /var/logs/nginx /var/www/html /etc/oauth2-proxy
# Download and install oauth2-proxy
RUN curl -L https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.4.0/oauth2-proxy-v7.4.0.linux-amd64.tar.gz -o oauth2-proxy.tar.gz && \
tar -xvzf oauth2-proxy.tar.gz && \
mv oauth2-proxy-v7.4.0.linux-amd64/oauth2-proxy /usr/local/bin/ && \
rm -rf oauth2-proxy-v7.4.0.linux-amd64 oauth2-proxy.tar.gz
COPY --from=builder /usr/src/app/platform/app/dist /var/www/html
# Copy the entrypoint script
COPY ./platform/app/.recipes/Nginx-Orthanc-Keycloak/config/entrypoint.sh /entrypoint.sh
# Expose necessary ports
EXPOSE 80 443 4180
# Set the entrypoint script as the entrypoint
RUN chmod +x entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,2 @@
logs/*
volumes/*

View File

@@ -0,0 +1,26 @@
# Docker compose files
# Build
Using docker compose you can build the image with the following command:
```bash
docker-compose build
```
# Run
To run the container use the following command:
```bash
docker-compose up
```
# Routes
http://localhost/ -> OHIF
localhost/pacs -> Orthanc
See [here](../../../docs/docs/deployment/nginx--image-archive.md) for more information about this recipe.

View File

@@ -0,0 +1,86 @@
worker_processes 2;
error_log /var/logs/nginx/mydomain.error.log;
pid /var/run/nginx.pid;
include /usr/share/nginx/modules/*.conf; # See /usr/share/doc/nginx/README.dynamic.
events {
worker_connections 1024; ## Default: 1024
use epoll; # http://nginx.org/en/docs/events.html
multi_accept on; # http://nginx.org/en/docs/ngx_core_module.html#multi_accept
}
# Core Modules Docs:
# http://nginx.org/en/docs/http/ngx_http_core_module.html
http {
include '/etc/nginx/mime.types';
default_type application/octet-stream;
keepalive_timeout 65;
keepalive_requests 100000;
tcp_nopush on;
tcp_nodelay on;
# Nginx `listener` block
server {
listen [::]:80 default_server;
listen 80;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_comp_level 9;
etag on;
# Reverse Proxy for `orthanc` APIs (including DICOMWeb)
#
location /pacs/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 0;
add_header Cache-Control private;
# Add CORS headers
# Note: uncomment the following line to allow all domains to access the Orthanc APIs
# You should actually only allow the domains you trust to access the APIs
# add_header 'Access-Control-Allow-Origin' '*' always;
proxy_pass http://orthanc:8042/;
# By default, this endpoint is protected by CORS (cross-origin-resource-sharing)
# You can add headers to allow other domains to request this resource.
# See the "Updating CORS Settings" example below
}
# Do not cache sw.js, required for offline-first updates.
location /sw.js {
add_header Cache-Control "no-cache";
proxy_cache_bypass $http_pragma;
proxy_cache_revalidate on;
expires off;
access_log off;
}
# Single Page App
# Try files, fallback to index.html
#
location / {
root /var/www/html;
index index.html;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
}
}
}

View File

@@ -0,0 +1,89 @@
{
"Name": "Orthanc inside Docker",
"StorageDirectory": "/var/lib/orthanc/db",
"IndexDirectory": "/var/lib/orthanc/db",
"StorageCompression": false,
"MaximumStorageSize": 0,
"MaximumPatientCount": 0,
"LuaScripts": [],
"Plugins": ["/usr/share/orthanc/plugins", "/usr/local/share/orthanc/plugins"],
"ConcurrentJobs": 2,
"HttpServerEnabled": true,
"HttpPort": 8042,
"HttpDescribeErrors": true,
"HttpCompressionEnabled": true,
"DicomServerEnabled": true,
"DicomAet": "ORTHANC",
"DicomCheckCalledAet": false,
"DicomPort": 4242,
"DefaultEncoding": "Latin1",
"DeflatedTransferSyntaxAccepted": true,
"JpegTransferSyntaxAccepted": true,
"Jpeg2000TransferSyntaxAccepted": true,
"JpegLosslessTransferSyntaxAccepted": true,
"JpipTransferSyntaxAccepted": true,
"Mpeg2TransferSyntaxAccepted": true,
"RleTransferSyntaxAccepted": true,
"UnknownSopClassAccepted": false,
"DicomScpTimeout": 30,
"RemoteAccessAllowed": true,
"SslEnabled": false,
"SslCertificate": "certificate.pem",
"AuthenticationEnabled": false,
"RegisteredUsers": {
"test": "test"
},
"DicomModalities": {},
"DicomModalitiesInDatabase": false,
"DicomAlwaysAllowEcho": true,
"DicomAlwaysAllowStore": true,
"DicomCheckModalityHost": false,
"DicomScuTimeout": 10,
"OrthancPeers": {},
"OrthancPeersInDatabase": false,
"HttpProxy": "",
"HttpVerbose": true,
"HttpTimeout": 10,
"HttpsVerifyPeers": true,
"HttpsCACertificates": "",
"UserMetadata": {},
"UserContentType": {},
"StableAge": 60,
"StrictAetComparison": false,
"StoreMD5ForAttachments": true,
"LimitFindResults": 0,
"LimitFindInstances": 0,
"LimitJobs": 10,
"LogExportedResources": false,
"KeepAlive": true,
"TcpNoDelay": true,
"HttpThreadsCount": 50,
"StoreDicom": true,
"DicomAssociationCloseDelay": 5,
"QueryRetrieveSize": 10,
"CaseSensitivePN": false,
"LoadPrivateDictionary": true,
"Dictionary": {},
"SynchronousCMove": true,
"JobsHistorySize": 10,
"SaveJobs": true,
"OverwriteInstances": false,
"MediaArchiveSize": 1,
"StorageAccessOnFind": "Always",
"MetricsEnabled": true,
"DicomWeb": {
"Enable": true,
"Root": "/dicom-web/",
"EnableWado": true,
"WadoRoot": "/wado",
"Host": "127.0.0.1",
"Ssl": false,
"StowMaxInstances": 10,
"StowMaxSize": 10,
"QidoCaseSensitive": false
}
}

View File

@@ -0,0 +1,46 @@
# Reference:
# - https://docs.docker.com/compose/compose-file
# - https://eclipsesource.com/blogs/2018/01/11/authenticating-reverse-proxy-with-keycloak/
services:
# Exposed server that's handling incoming web requests
ohif_viewer:
build:
# Project root
context: ./../../../../
# Relative to context
dockerfile: ./platform/app/.recipes/Nginx-Orthanc/dockerfile
image: webapp:latest
container_name: ohif_orthanc
volumes:
# Nginx config
- ./config/nginx.conf:/etc/nginx/nginx.conf
# Logs
- ./logs/nginx:/var/logs/nginx
# Let's Encrypt
# - letsencrypt_certificates:/etc/letsencrypt
# - letsencrypt_challenges:/var/www/letsencrypt
ports:
- '443:443' # SSL
- '80:80' # Web
depends_on:
# - keycloak
- orthanc
restart: on-failure
# LINK: https://hub.docker.com/r/jodogne/orthanc-plugins/
# TODO: Update to use Postgres
# https://github.com/mrts/docker-postgresql-multiple-databases
orthanc:
image: jodogne/orthanc-plugins
hostname: orthanc
container_name: orthancPACS
volumes:
# Config
- ./config/orthanc.json:/etc/orthanc/orthanc.json:ro
# Persist data
- ./volumes/orthanc-db/:/var/lib/orthanc/db/
restart: unless-stopped
ports:
- '4242:4242' # Orthanc REST API
- '8042:8042' # Orthanc HTTP

View File

@@ -0,0 +1,43 @@
# Stage 1: Build the application
FROM node:18.16.1-slim as builder
# Setup the working directory
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# Install dependencies
# apt-get update is combined with apt-get install to avoid using outdated packages
RUN apt-get update && apt-get install -y build-essential python3
# Copy package.json and other dependency-related files first
# Assuming your package.json and yarn.lock or similar are located in the project root
COPY ./ /usr/src/app/
# Install node dependencies
RUN yarn config set workspaces-experimental true
RUN yarn install
# Copy the rest of the application code
# set QUICK_BUILD to true to make the build faster for dev
ENV APP_CONFIG=config/docker-nginx-orthanc.js
# Build the application
RUN yarn run build
# # Stage 2: Bundle the built application into a Docker container which runs NGINX using Alpine Linux
FROM nginx:alpine
# # Create directories for logs and html content if they don't already exist
RUN mkdir -p /var/log/nginx /var/www/html
# # Copy build output to serve static files
COPY --from=builder /usr/src/app/platform/app/dist /var/www/html
# # Expose HTTP and HTTPS ports
EXPOSE 80 443
# # Start NGINX
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,261 @@
worker_processes 2;
error_log /var/logs/nginx/mydomain.error.log;
pid /var/run/nginx.pid;
include /usr/share/nginx/modules/*.conf; # See /usr/share/doc/nginx/README.dynamic.
events {
worker_connections 1024; ## Default: 1024
use epoll; # http://nginx.org/en/docs/events.html
multi_accept on; # http://nginx.org/en/docs/ngx_core_module.html#multi_accept
}
# Core Modules Docs:
# http://nginx.org/en/docs/http/ngx_http_core_module.html
http {
include '/usr/local/openresty/nginx/conf/mime.types';
default_type application/octet-stream;
keepalive_timeout 65;
keepalive_requests 100000;
tcp_nopush on;
tcp_nodelay on;
# lua_ settings
#
lua_package_path '/usr/local/openresty/lualib/?.lua;;/usr/local/share/lua/5.4/?.lua;;';
lua_shared_dict discovery 1m; # cache for discovery metadata documents
lua_shared_dict jwks 1m; # cache for JWKs
# lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
variables_hash_max_size 2048;
server_names_hash_bucket_size 128;
server_tokens off;
resolver 8.8.8.8 valid=30s ipv6=off;
resolver_timeout 11s;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
# No idea what this is doing
# https://stackoverflow.com/a/5877989/1867984
# upstream upstream_server {
# # server 10.100.4.200:1010 max_fails=3 fail_timeout=30s;
# server 127.0.0.1:
# }
# Nginx `listener` block
server {
listen [::]:80 default_server;
listen 80;
# listen 443 ssl;
access_log /var/logs/nginx/mydomain.access.log;
# Domain to protect
server_name 127.0.0.1 localhost; # mydomain.com;
proxy_intercept_errors off;
# ssl_certificate /etc/letsencrypt/live/mydomain.co.uk/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/mydomain.co.uk/privkey.pem;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_comp_level 9;
etag on;
# https://github.com/bungle/lua-resty-session/issues/15
set $session_check_ssi off;
lua_code_cache off;
set $session_secret Eeko7aeb6iu5Wohch9Loo1aitha0ahd1;
set $session_storage cookie;
server_tokens off; # Hides server version num
# [PROTECTED] Reverse Proxy for `orthanc` admin
#
location /pacs-admin/ {
access_by_lua_block {
local opts = {
redirect_uri = "http://127.0.0.1/pacs-admin/admin",
discovery = "http://127.0.0.1/auth/realms/ohif/.well-known/openid-configuration",
token_endpoint_auth_method = "client_secret_basic",
client_id = "pacs",
client_secret = "66279641-eba6-47f5-9fdb-70c4ac74d548",
client_jwt_assertion_expires_in = 60 * 60,
ssl_verify = "no",
scope = "openid email profile",
refresh_session_interval = 900,
redirect_uri_scheme = "http",
redirect_after_logout_uri = "/",
session_contents = {id_token=true}
}
-- call authenticate for OpenID Connect user authentication
local res, err = require("resty.openidc").authenticate(opts)
if err or not res then
ngx.print(err)
ngx.status = 200
ngx.say(err and err or "no access_token provided")
ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Or set cookie?
-- ngx.req.set_header("Authorization", "Bearer " .. res.access_token)
ngx.req.set_header("X-USER", res.id_token.sub)
}
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 0;
add_header Cache-Control private;
proxy_pass http://orthanc:8042/;
}
# [PROTECTED] Reverse Proxy for `orthanc` APIs (including DICOMWeb)
#
location /pacs/ {
access_by_lua_block {
local opts = {
discovery = "http://127.0.0.1/auth/realms/ohif/.well-known/openid-configuration",
}
-- call bearer_jwt_verify for OAuth 2.0 JWT validation
local res, err = require("resty.openidc").bearer_jwt_verify(opts)
if err or not res then
ngx.status = 403
ngx.say(err and err or "no access_token provided")
ngx.exit(ngx.HTTP_FORBIDDEN)
end
}
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
expires 0;
add_header Cache-Control private;
proxy_pass http://orthanc:8042/;
# By default, this endpoint is protected by CORS (cross-origin-resource-sharing)
# You can add headers to allow other domains to request this resource.
# See the "Updating CORS Settings" example below
}
# Keycloak
#
location /auth/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_pass http://keycloak:8080/auth/;
}
# Do not cache sw.js, required for offline-first updates.
location /sw.js {
add_header Cache-Control "no-cache";
proxy_cache_bypass $http_pragma;
proxy_cache_revalidate on;
expires off;
access_log off;
}
# Single Page App
# Try files, fallback to index.html
#
location / {
alias /var/www/html/;
index index.html;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
}
# EXAMPLE: Reverse Proxy, no auth
# [UNPROTECTED] reverse proxy for `orthanc`
#
# location /pacs/ {
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $remote_addr;
# proxy_set_header Host $host;
#
# proxy_pass http://orthanc:8042/;
#
# # OR
# # rewrite ^/pacs(.*) /$1 break;
# # proxy_pass http://orthanc:8042;
# }
# EXAMPLE: Modifying headers to allow requests from other domains
# IE. Updating CORS settings
#
# location / {
# if ($request_method = 'OPTIONS') {
# add_header 'Access-Control-Allow-Origin' '*';
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# #
# # Custom headers and headers various browsers *should* be OK with but aren't
# #
# add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
# #
# # Tell client that this pre-flight info is valid for 20 days
# #
# add_header 'Access-Control-Allow-Headers' 'Authorization';
# add_header 'Access-Control-Allow-Credentials' true;
# add_header 'Access-Control-Max-Age' 1728000;
# add_header 'Content-Length' 0;
# return 204;
# }
# if ($request_method = 'POST') {
# add_header 'Access-Control-Allow-Origin' '*';
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
# add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
# }
# if ($request_method = 'GET') {
# add_header 'Access-Control-Allow-Origin' '*';
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
# add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
# add_header 'Access-Control-Allow-Headers' 'Authorization';
# add_header 'Access-Control-Allow-Credentials' true;
# }
#
# # proxy_http_version 1.1;
#
# # proxy_set_header Host $host;
# # proxy_set_header X-Real-IP $remote_addr;
# # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# # proxy_set_header X-Forwarded-Proto $scheme;
#
# proxy_pass http://orthanc:8042;
# }
# EXAMPLE: Redirect server error pages to the static page /40x.html
#
# error_page 404 /404.html;
# location = /40x.html {
# }
# EXAMPLE: Redirect server error pages to the static page /50x.html
#
# error_page 500 502 503 504 /50x.html;
# location = /50x.html {
# }
}
}

View File

@@ -0,0 +1,89 @@
{
"Name": "Orthanc inside Docker",
"StorageDirectory": "/var/lib/orthanc/db",
"IndexDirectory": "/var/lib/orthanc/db",
"StorageCompression": false,
"MaximumStorageSize": 0,
"MaximumPatientCount": 0,
"LuaScripts": [],
"Plugins": ["/usr/share/orthanc/plugins", "/usr/local/share/orthanc/plugins"],
"ConcurrentJobs": 2,
"HttpServerEnabled": true,
"HttpPort": 8042,
"HttpDescribeErrors": true,
"HttpCompressionEnabled": true,
"DicomServerEnabled": true,
"DicomAet": "ORTHANC",
"DicomCheckCalledAet": false,
"DicomPort": 4242,
"DefaultEncoding": "Latin1",
"DeflatedTransferSyntaxAccepted": true,
"JpegTransferSyntaxAccepted": true,
"Jpeg2000TransferSyntaxAccepted": true,
"JpegLosslessTransferSyntaxAccepted": true,
"JpipTransferSyntaxAccepted": true,
"Mpeg2TransferSyntaxAccepted": true,
"RleTransferSyntaxAccepted": true,
"UnknownSopClassAccepted": false,
"DicomScpTimeout": 30,
"RemoteAccessAllowed": true,
"SslEnabled": false,
"SslCertificate": "certificate.pem",
"AuthenticationEnabled": false,
"RegisteredUsers": {
"test": "test"
},
"DicomModalities": {},
"DicomModalitiesInDatabase": false,
"DicomAlwaysAllowEcho": true,
"DicomAlwaysAllowStore": true,
"DicomCheckModalityHost": false,
"DicomScuTimeout": 10,
"OrthancPeers": {},
"OrthancPeersInDatabase": false,
"HttpProxy": "",
"HttpVerbose": true,
"HttpTimeout": 10,
"HttpsVerifyPeers": true,
"HttpsCACertificates": "",
"UserMetadata": {},
"UserContentType": {},
"StableAge": 60,
"StrictAetComparison": false,
"StoreMD5ForAttachments": true,
"LimitFindResults": 0,
"LimitFindInstances": 0,
"LimitJobs": 10,
"LogExportedResources": false,
"KeepAlive": true,
"TcpNoDelay": true,
"HttpThreadsCount": 50,
"StoreDicom": true,
"DicomAssociationCloseDelay": 5,
"QueryRetrieveSize": 10,
"CaseSensitivePN": false,
"LoadPrivateDictionary": true,
"Dictionary": {},
"SynchronousCMove": true,
"JobsHistorySize": 10,
"SaveJobs": true,
"OverwriteInstances": false,
"MediaArchiveSize": 1,
"StorageAccessOnFind": "Always",
"MetricsEnabled": true,
"DicomWeb": {
"Enable": true,
"Root": "/dicom-web/",
"EnableWado": true,
"WadoRoot": "/wado",
"Host": "127.0.0.1",
"Ssl": false,
"StowMaxInstances": 10,
"StowMaxSize": 10,
"QidoCaseSensitive": false
}
}

View File

@@ -0,0 +1,95 @@
# Reference:
# - https://docs.docker.com/compose/compose-file
# - https://eclipsesource.com/blogs/2018/01/11/authenticating-reverse-proxy-with-keycloak/
version: '3.5'
services:
# Exposed server that's handling incoming web requests
# Underlying image: openresty/openresty:alpine-fat
ohif_viewer:
build:
# Project root
context: ./../../../../
# Relative to context
dockerfile: ./platform/app/.recipes/OpenResty-Orthanc-Keycloak/dockerfile
image: webapp:latest
container_name: webapp
volumes:
# Nginx config
- ./config/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro
# Logs
- ./logs/nginx:/var/logs/nginx
# Let's Encrypt
# - letsencrypt_certificates:/etc/letsencrypt
# - letsencrypt_challenges:/var/www/letsencrypt
ports:
- '443:443' # SSL
- '80:80' # Web
depends_on:
- keycloak
- orthanc
restart: on-failure
# LINK: https://hub.docker.com/r/jodogne/orthanc-plugins/
# TODO: Update to use Postgres
# https://github.com/mrts/docker-postgresql-multiple-databases
orthanc:
image: jodogne/orthanc-plugins
hostname: orthanc
container_name: orthanc
volumes:
# Config
- ./config/orthanc.json:/etc/orthanc/orthanc.json:ro
# Persist data
- ./volumes/orthanc-db/:/var/lib/orthanc/db/
restart: unless-stopped
# LINK: https://hub.docker.com/r/jboss/keycloak
keycloak:
image: quay.io/keycloak/keycloak:6.0.0
hostname: keycloak
container_name: keycloak
volumes:
# Theme: https://www.keycloak.org/docs/latest/server_development/index.html#_themes
- ./volumes/keycloak-themes/ohif:/opt/jboss/keycloak/themes/ohif
# Previous Realm Config
- ./config/ohif-keycloak-realm.json:/tmp/ohif-keycloak-realm.json
environment:
# Database
DB_VENDOR: postgres
DB_ADDR: postgres
DB_PORT: 5432
DB_DATABASE: keycloak
DB_SCHEMA: public
DB_USER: keycloak
DB_PASSWORD: password
# Keycloak
KEYCLOAK_USER: admin
KEYCLOAK_PASSWORD: password
KEYCLOAK_IMPORT: /tmp/ohif-keycloak-realm.json
# KEYCLOAK_WELCOME_THEME: <theme-name>
# KEYCLOAK_DEFAULT_THEME: <theme-name>
# KEYCLOAK_HOSTNAME: (recommended in prod)
# KEYCLOAK_LOGLEVEL: DEBUG
PROXY_ADDRESS_FORWARDING: 'true'
depends_on:
- postgres
restart: unless-stopped
# LINK: https://hub.docker.com/_/postgres/
postgres:
image: postgres:11.2
hostname: postgres
container_name: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: password
restart: unless-stopped
volumes:
postgres_data:
driver: local

View File

@@ -0,0 +1,85 @@
# docker-compose
# --------------
# This dockerfile is used by the `docker-compose.yml` adjacent file. When
# running `docker compose build`, this dockerfile helps build the "webapp" image.
# All paths are relative to the `context`, which is the project root directory.
#
# docker build
# --------------
# If you would like to use this dockerfile to build and tag an image, make sure
# you set the context to the project's root directory:
# https://docs.docker.com/engine/reference/commandline/build/
#
#
# SUMMARY
# --------------
# This dockerfile has two stages:
#
# 1. Building the React application for production
# 2. Setting up our Nginx (OpenResty*) image w/ step one's output
#
# * OpenResty is functionally identical to Nginx with the addition of Lua out of
# the box.
# Stage 1: Build the application
FROM node:18.16.1-slim as builder
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y build-essential python3
ENV APP_CONFIG=config/docker_openresty-orthanc-keycloak.js
ENV PATH /usr/src/app/node_modules/.bin:$PATH
# Copy all files from the root of the OHIF source and note
# that the Docker ignore file at the root (i.e. ./dockerignore) will filter
# out files and directories that are not needed.
COPY ./ /usr/src/app/
ADD . /usr/src/app/
RUN yarn config set workspaces-experimental true
RUN yarn install
RUN yarn run build
# Stage 2: Bundle the built application into a Docker container
# which runs openresty (nginx) using Alpine Linux
# LINK: https://hub.docker.com/r/openresty/openresty
FROM openresty/openresty:1.21.4.2-0-bullseye-fat
RUN mkdir /var/log/nginx
RUN apt-get update && \
apt-get install -y openssl libssl-dev git gcc wget unzip make&& \
apt-get clean
RUN apt-get install --assume-yes lua5.4 libzmq3-dev lua5.4-dev
RUN cd /tmp && \
wget http://luarocks.org/releases/luarocks-3.9.2.tar.gz && \
tar zxpf luarocks-3.9.2.tar.gz && \
cd luarocks-3.9.2 && \
./configure && \
make && \
make install
# !!!
RUN luarocks install lua-resty-http
# RUN luarocks install lua-nginx-module
RUN luarocks install lua-cjson
RUN luarocks install lua-resty-string
RUN luarocks install lua-resty-session
RUN luarocks install lua-resty-jwt
RUN luarocks install lua-resty-openidc
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
#
RUN luarocks install lua-resty-http
# !!!
RUN luarocks install lua-resty-auto-ssl
# Copy build output to image
COPY --from=builder /usr/src/app/platform/app/dist /var/www/html
ENTRYPOINT ["/usr/local/openresty/nginx/sbin/nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,196 @@
body {
background-color: #040507;
background-image: url('../img/background.jpg');
background-size: cover;
background-repeat: no-repeat;
width: 100vw;
height: 100vh;
overflow: hidden;
color: #fff;
font-family: sans-serif;
text-shadow: 0px 0px 10px #000;
}
a {
color: #fff;
}
div#kc-content {
position: absolute;
top: 20%;
left: 50%;
width: 550px;
margin-left: -180px;
}
div#kc-form {
float: left;
width: 350px;
}
div#kc-form label {
display: block;
font-size: 16px;
}
div#info-area {
position: fixed;
bottom: 0;
left: 0;
margin-top: 40px;
background-color: rgba(0, 0, 0, 0.4);
padding: 20px;
width: 100%;
}
div#info-area p {
margin-right: 30px;
display: inline;
text-shadow: none;
}
input[type='text'],
input[type='password'] {
color: #333;
font-size: 18px;
margin-bottom: 20px;
background-color: rgba(256, 256, 256, 0.7);
border: 0px solid rgba(0, 0, 0, 0.2);
box-shadow: inset 0 0 2px 2px rgba(0, 0, 0, 0.15);
padding: 10px;
width: 296px;
}
input[type='text']:hover,
input[type='password']:hover {
background-color: rgba(256, 256, 256, 0.9);
}
input[type='submit'] {
border: none;
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.1));
background: -moz-linear-gradient(top, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.1));
background: -ms-linear-gradient(top, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.1));
background: -o-linear-gradient(top, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.1));
box-shadow: 0px 0px 6px rgba(0, 0, 0, 0.5);
color: rgba(0, 0, 0, 0.6);
font-size: 14px;
font-weight: bold;
padding: 10px;
margin-top: 20px;
margin-right: 10px;
width: 150px;
}
input[type='submit']:hover {
background-color: rgba(255, 255, 255, 0.8);
}
div#kc-form-options div {
display: inline-block;
margin-right: 20px;
font-size: 12px;
}
div#kc-form-options div label {
font-size: 12px;
}
div#kc-feedback {
box-shadow: 0px 0px 6px rgba(0, 0, 0, 0.5);
position: fixed;
top: 0;
left: 0;
width: 100%;
text-align: center;
}
div#kc-feedback-wrapper {
padding: 1em;
}
div.feedback-success {
background-color: rgba(155, 155, 255, 0.1);
}
div.feedback-warning {
background-color: rgba(255, 175, 0, 0.1);
}
div.feedback-error {
background-color: rgba(255, 0, 0, 0.1);
}
div#kc-header {
display: none;
}
div#kc-registration {
margin-bottom: 20px;
}
div#social-login {
border-left: 1px solid rgba(255, 255, 255, 0.2);
float: right;
width: 150px;
padding: 20px 0 200px 40px;
}
div.social-login span {
display: none;
}
div#kc-social-providers ul {
list-style: none;
margin: 0;
padding: 0;
}
div#kc-social-providers ul li {
margin-bottom: 20px;
}
div#kc-social-providers ul li span {
display: inline;
width: 100px;
}
a.zocial {
border: none;
background: -webkit-linear-gradient(
top,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.1)
) !important;
background: -moz-linear-gradient(
top,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.1)
) !important;
background: -ms-linear-gradient(
top,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.1)
) !important;
background: -o-linear-gradient(
top,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.1)
) !important;
box-shadow: 0px 0px 6px rgba(0, 0, 0, 0.5);
color: rgba(0, 0, 0, 0.6);
width: 130px;
text-shadow: none;
-webkit-border-radius: 0;
-moz-border-radius: 0;
border-radius: 0;
padding-top: 0.2em;
padding-bottom: 0.2em;
}

View File

@@ -0,0 +1,3 @@
parent=base
import=common/keycloak
styles=lib/zocial/zocial.css css/styles.css

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,137 @@
worker_processes 2;
error_log /var/logs/nginx/mydomain.error.log;
pid /var/run/nginx.pid;
include /usr/share/nginx/modules/*.conf; # See /usr/share/doc/nginx/README.dynamic.
events {
worker_connections 1024; ## Default: 1024
use epoll; # http://nginx.org/en/docs/events.html
multi_accept on; # http://nginx.org/en/docs/ngx_core_module.html#multi_accept
}
# Core Modules Docs:
# http://nginx.org/en/docs/http/ngx_http_core_module.html
http {
include '/usr/local/openresty/nginx/conf/mime.types';
default_type application/octet-stream;
keepalive_timeout 65;
keepalive_requests 100000;
tcp_nopush on;
tcp_nodelay on;
# lua_ settings
#
lua_package_path '/usr/local/openresty/lualib/?.lua;;';
lua_shared_dict discovery 1m; # cache for discovery metadata documents
lua_shared_dict jwks 1m; # cache for JWKs
# lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
variables_hash_max_size 2048;
server_names_hash_bucket_size 128;
server_tokens off;
resolver 8.8.8.8 valid=30s ipv6=off;
resolver_timeout 11s;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
# Nginx `listener` block
server {
listen [::]:80 default_server;
listen 80;
# listen 443 ssl;
access_log /var/logs/nginx/mydomain.access.log;
# Domain to protect
server_name 127.0.0.1 localhost; # mydomain.com;
proxy_intercept_errors off;
# ssl_certificate /etc/letsencrypt/live/mydomain.co.uk/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/mydomain.co.uk/privkey.pem;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_comp_level 9;
etag on;
# https://github.com/bungle/lua-resty-session/issues/15
set $session_check_ssi off;
lua_code_cache off;
set $session_secret Eeko7aeb6iu5Wohch9Loo1aitha0ahd1;
set $session_storage cookie;
server_tokens off; # Hides server version num
# Reverse Proxy for `orthanc` admin
#
location /pacs-admin/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 0;
add_header Cache-Control private;
proxy_pass http://orthanc:8042/;
}
# Reverse Proxy for `orthanc` APIs (including DICOMWeb)
#
location /pacs/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 0;
add_header Cache-Control private;
proxy_pass http://orthanc:8042/;
# By default, this endpoint is protected by CORS (cross-origin-resource-sharing)
# You can add headers to allow other domains to request this resource.
# See the "Updating CORS Settings" example below
}
# Do not cache sw.js, required for offline-first updates.
location /sw.js {
add_header Cache-Control "no-cache";
proxy_cache_bypass $http_pragma;
proxy_cache_revalidate on;
expires off;
access_log off;
}
# Single Page App
# Try files, fallback to index.html
#
location / {
alias /var/www/html/;
index index.html;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
}
# EXAMPLE: Redirect server error pages to the static page /40x.html
#
# error_page 404 /404.html;
# location = /40x.html {
# }
# EXAMPLE: Redirect server error pages to the static page /50x.html
#
# error_page 500 502 503 504 /50x.html;
# location = /50x.html {
# }
}
}

View File

@@ -0,0 +1,89 @@
{
"Name": "Orthanc inside Docker",
"StorageDirectory": "/var/lib/orthanc/db",
"IndexDirectory": "/var/lib/orthanc/db",
"StorageCompression": false,
"MaximumStorageSize": 0,
"MaximumPatientCount": 0,
"LuaScripts": [],
"Plugins": ["/usr/share/orthanc/plugins", "/usr/local/share/orthanc/plugins"],
"ConcurrentJobs": 2,
"HttpServerEnabled": true,
"HttpPort": 8042,
"HttpDescribeErrors": true,
"HttpCompressionEnabled": true,
"DicomServerEnabled": true,
"DicomAet": "ORTHANC",
"DicomCheckCalledAet": false,
"DicomPort": 4242,
"DefaultEncoding": "Latin1",
"DeflatedTransferSyntaxAccepted": true,
"JpegTransferSyntaxAccepted": true,
"Jpeg2000TransferSyntaxAccepted": true,
"JpegLosslessTransferSyntaxAccepted": true,
"JpipTransferSyntaxAccepted": true,
"Mpeg2TransferSyntaxAccepted": true,
"RleTransferSyntaxAccepted": true,
"UnknownSopClassAccepted": false,
"DicomScpTimeout": 30,
"RemoteAccessAllowed": true,
"SslEnabled": false,
"SslCertificate": "certificate.pem",
"AuthenticationEnabled": false,
"RegisteredUsers": {
"test": "test"
},
"DicomModalities": {},
"DicomModalitiesInDatabase": false,
"DicomAlwaysAllowEcho": true,
"DicomAlwaysAllowStore": true,
"DicomCheckModalityHost": false,
"DicomScuTimeout": 10,
"OrthancPeers": {},
"OrthancPeersInDatabase": false,
"HttpProxy": "",
"HttpVerbose": true,
"HttpTimeout": 10,
"HttpsVerifyPeers": true,
"HttpsCACertificates": "",
"UserMetadata": {},
"UserContentType": {},
"StableAge": 60,
"StrictAetComparison": false,
"StoreMD5ForAttachments": true,
"LimitFindResults": 0,
"LimitFindInstances": 0,
"LimitJobs": 10,
"LogExportedResources": false,
"KeepAlive": true,
"TcpNoDelay": true,
"HttpThreadsCount": 50,
"StoreDicom": true,
"DicomAssociationCloseDelay": 5,
"QueryRetrieveSize": 10,
"CaseSensitivePN": false,
"LoadPrivateDictionary": true,
"Dictionary": {},
"SynchronousCMove": true,
"JobsHistorySize": 10,
"SaveJobs": true,
"OverwriteInstances": false,
"MediaArchiveSize": 1,
"StorageAccessOnFind": "Always",
"MetricsEnabled": true,
"DicomWeb": {
"Enable": true,
"Root": "/dicom-web/",
"EnableWado": true,
"WadoRoot": "/wado",
"Host": "127.0.0.1",
"Ssl": false,
"StowMaxInstances": 10,
"StowMaxSize": 10,
"QidoCaseSensitive": false
}
}

View File

@@ -0,0 +1,47 @@
# Reference:
# - https://docs.docker.com/compose/compose-file
# - https://eclipsesource.com/blogs/2018/01/11/authenticating-reverse-proxy-with-keycloak/
version: '3.5'
services:
# Exposed server that's handling incoming web requests
# Underlying image: openresty/openresty:alpine-fat
ohif_viewer:
build:
# Project root
context: ./../../../../
# Relative to context
dockerfile: ./platform/app/.recipes/OpenResty-Orthanc/dockerfile
image: webapp:latest
container_name: webapp
volumes:
# Nginx config
- ./config/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro
# Logs
- ./logs/nginx:/var/logs/nginx
# Let's Encrypt
# - letsencrypt_certificates:/etc/letsencrypt
# - letsencrypt_challenges:/var/www/letsencrypt
ports:
- '443:443' # SSL
- '80:80' # Web
depends_on:
- orthanc
restart: on-failure
# LINK: https://hub.docker.com/r/jodogne/orthanc-plugins/
# TODO: Update to use Postgres
# https://github.com/mrts/docker-postgresql-multiple-databases
orthanc:
image: jodogne/orthanc-plugins
hostname: orthanc
container_name: orthanc
volumes:
# Config
- ./config/orthanc.json:/etc/orthanc/orthanc.json:ro
# Persist data
- ./volumes/orthanc-db/:/var/lib/orthanc/db/
restart: unless-stopped
ports:
- '4242:4242' # DIMSE

View File

@@ -0,0 +1,79 @@
# docker-compose
# --------------
# This dockerfile is used by the `docker-compose.yml` adjacent file. When
# running `docker compose build`, this dockerfile helps build the "webapp" image.
# All paths are relative to the `context`, which is the project root directory.
#
# docker build
# --------------
# If you would like to use this dockerfile to build and tag an image, make sure
# you set the context to the project's root directory:
# https://docs.docker.com/engine/reference/commandline/build/
#
#
# SUMMARY
# --------------
# This dockerfile has two stages:
#
# 1. Building the React application for production
# 2. Setting up our Nginx (OpenResty*) image w/ step one's output
#
# * OpenResty is functionally identical to Nginx with the addition of Lua out of
# the box.
# Stage 1: Build the application
FROM node:18.16.1-slim as builder
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# Copy all files from the root of the OHIF source and note
# that the Docker ignore file at the root (i.e. ./dockerignore) will filter
# out files and directories that are not needed.
COPY ./ /usr/src/app/
# For arm builds since parcel doesn't have prebuilt binaries for arm yet
RUN apt-get update && apt-get install -y build-essential python3
# ADD . /usr/src/app/
RUN yarn config set workspaces-experimental true
RUN yarn install
ENV APP_CONFIG=config/docker_openresty-orthanc.js
ENV PATH /usr/src/app/node_modules/.bin:$PATH
ENV QUICK_BUILD true
RUN yarn run build
# ADD . /usr/src/app/
# RUN yarn install
# RUN yarn run build:web
# Stage 2: Bundle the built application into a Docker container
# which runs openresty (nginx) using Alpine Linux
# LINK: https://hub.docker.com/r/openresty/openresty
FROM openresty/openresty:1.15.8.1rc1-0-alpine-fat
RUN mkdir /var/log/nginx
RUN apk add --no-cache openssl
RUN apk add --no-cache openssl-dev
RUN apk add --no-cache git
RUN apk add --no-cache gcc
# !!!
RUN luarocks install lua-resty-openidc
#
RUN luarocks install lua-resty-jwt
RUN luarocks install lua-resty-session
RUN luarocks install lua-resty-http
# !!!
RUN luarocks install lua-resty-openidc
RUN luarocks install luacrypto
# Copy build output to image
COPY --from=builder /usr/src/app/platform/app/dist /var/www/html
ENTRYPOINT ["/usr/local/openresty/nginx/sbin/nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,35 @@
const ExtractCssChunksPlugin = require('extract-css-chunks-webpack-plugin');
function extractStyleChunks(isProdBuild) {
return [
// If you are using the old stylus, you should uncomment this
// {
// test: /\.styl$/,
// use: [
// {
// loader: ExtractCssChunksPlugin.loader,
// options: {
// hot: !isProdBuild,
// },
// },
// { loader: 'css-loader' },
// { loader: 'stylus-loader' },
// ],
// },
{
test: /\.(sa|sc|c)ss$/,
use: [
{
loader: ExtractCssChunksPlugin.loader,
options: {
hot: !isProdBuild,
},
},
'css-loader',
'postcss-loader',
],
},
];
}
module.exports = extractStyleChunks;

View File

@@ -0,0 +1,20 @@
/**
* For CommonJS, we want to bundle whatever font we've landed on. This allows
* us to reduce the number of script-tags we need to specify for simple use.
*
* PWA will grab these externally to reduce bundle size (think code split),
* and cache the grab using service-worker.
*/
const fontsToJavaScript = {
test: /\.(ttf|eot|woff|woff2)$/i,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
},
},
],
};
module.exports = fontsToJavaScript;

View File

@@ -0,0 +1,210 @@
// https://developers.google.com/web/tools/workbox/guides/codelabs/webpack
// ~~ WebPack
const path = require('path');
const { merge } = require('webpack-merge');
const webpack = require('webpack');
const webpackBase = require('./../../../.webpack/webpack.base.js');
// ~~ Plugins
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { InjectManifest } = require('workbox-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
// ~~ Directories
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const PUBLIC_DIR = path.join(__dirname, '../public');
// ~~ Env Vars
const HTML_TEMPLATE = process.env.HTML_TEMPLATE || 'index.html';
const PUBLIC_URL = process.env.PUBLIC_URL || '/';
const APP_CONFIG = process.env.APP_CONFIG || 'config/default.js';
// proxy settings
const PROXY_TARGET = process.env.PROXY_TARGET;
const PROXY_DOMAIN = process.env.PROXY_DOMAIN;
const PROXY_PATH_REWRITE_FROM = process.env.PROXY_PATH_REWRITE_FROM;
const PROXY_PATH_REWRITE_TO = process.env.PROXY_PATH_REWRITE_TO;
const OHIF_PORT = Number(process.env.OHIF_PORT || 3000);
const ENTRY_TARGET = process.env.ENTRY_TARGET || `${SRC_DIR}/index.js`;
const Dotenv = require('dotenv-webpack');
const writePluginImportFile = require('./writePluginImportsFile.js');
// const MillionLint = require('@million/lint');
const copyPluginFromExtensions = writePluginImportFile(SRC_DIR, DIST_DIR);
const setHeaders = (res, path) => {
if (path.indexOf('.gz') !== -1) {
res.setHeader('Content-Encoding', 'gzip');
} else if (path.indexOf('.br') !== -1) {
res.setHeader('Content-Encoding', 'br');
}
if (path.indexOf('.pdf') !== -1) {
res.setHeader('Content-Type', 'application/pdf');
} else if (path.indexOf('mp4') !== -1) {
res.setHeader('Content-Type', 'video/mp4');
} else if (path.indexOf('frames') !== -1) {
res.setHeader('Content-Type', 'multipart/related');
} else {
res.setHeader('Content-Type', 'application/json');
}
};
module.exports = (env, argv) => {
const baseConfig = webpackBase(env, argv, { SRC_DIR, DIST_DIR });
const isProdBuild = process.env.NODE_ENV === 'production';
const hasProxy = PROXY_TARGET && PROXY_DOMAIN;
const mergedConfig = merge(baseConfig, {
entry: {
app: ENTRY_TARGET,
},
output: {
path: DIST_DIR,
filename: isProdBuild ? '[name].bundle.[chunkhash].js' : '[name].js',
publicPath: PUBLIC_URL, // Used by HtmlWebPackPlugin for asset prefix
devtoolModuleFilenameTemplate: function (info) {
if (isProdBuild) {
return `webpack:///${info.resourcePath}`;
} else {
return 'file:///' + encodeURI(info.absoluteResourcePath);
}
},
},
resolve: {
modules: [
// Modules specific to this package
path.resolve(__dirname, '../node_modules'),
// Hoisted Yarn Workspace Modules
path.resolve(__dirname, '../../../node_modules'),
SRC_DIR,
],
},
plugins: [
// For debugging re-renders
// MillionLint.webpack(),
new Dotenv(),
// Clean output.path
new CleanWebpackPlugin(),
// Copy "Public" Folder to Dist
new CopyWebpackPlugin({
patterns: [
...copyPluginFromExtensions,
{
from: PUBLIC_DIR,
to: DIST_DIR,
toType: 'dir',
globOptions: {
// Ignore our HtmlWebpackPlugin template file
// Ignore our configuration files
ignore: ['**/config/**', '**/html-templates/**', '.DS_Store'],
},
},
// Short term solution to make sure GCloud config is available in output
// for our docker implementation
{
from: `${PUBLIC_DIR}/config/google.js`,
to: `${DIST_DIR}/google.js`,
},
// Copy over and rename our target app config file
{
from: `${PUBLIC_DIR}/${APP_CONFIG}`,
to: `${DIST_DIR}/app-config.js`,
},
// Copy Dicom Microscopy Viewer build files
{
from: '../../../node_modules/dicom-microscopy-viewer/dist/dynamic-import',
to: DIST_DIR,
globOptions: {
ignore: ['**/*.min.js.map'],
},
},
],
}),
// Generate "index.html" w/ correct includes/imports
new HtmlWebpackPlugin({
template: `${PUBLIC_DIR}/html-templates/${HTML_TEMPLATE}`,
filename: 'index.html',
templateParameters: {
PUBLIC_URL: PUBLIC_URL,
},
}),
// Generate a service worker for fast local loads
new InjectManifest({
swDest: 'sw.js',
swSrc: path.join(SRC_DIR, 'service-worker.js'),
// Increase the limit to 4mb:
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
// Need to exclude the theme as it is updated independently
exclude: [/theme/],
// Cache large files for the manifests to avoid warning messages
maximumFileSizeToCacheInBytes: 1024 * 1024 * 50,
}),
],
// https://webpack.js.org/configuration/dev-server/
devServer: {
// gzip compression of everything served
// Causes Cypress: `wait-on` issue in CI
// compress: true,
// http2: true,
// https: true,
open: true,
port: OHIF_PORT,
client: {
overlay: { errors: true, warnings: false },
},
proxy: {
'/dicomweb': 'http://localhost:5000',
},
static: [
{
directory: '../../testdata',
staticOptions: {
extensions: ['gz', 'br', 'mht'],
index: ['index.json.gz', 'index.mht.gz'],
redirect: true,
setHeaders,
},
publicPath: '/viewer-testdata',
},
],
//public: 'http://localhost:' + 3000,
//writeToDisk: true,
historyApiFallback: {
disableDotRule: true,
index: PUBLIC_URL + 'index.html',
},
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
devMiddleware: {
writeToDisk: true,
},
},
});
if (hasProxy) {
mergedConfig.devServer.proxy = mergedConfig.devServer.proxy || {};
mergedConfig.devServer.proxy = {
[PROXY_TARGET]: {
target: PROXY_DOMAIN,
changeOrigin: true,
pathRewrite: {
[`^${PROXY_PATH_REWRITE_FROM}`]: PROXY_PATH_REWRITE_TO,
},
},
};
}
if (isProdBuild) {
mergedConfig.plugins.push(
new MiniCssExtractPlugin({
filename: '[name].bundle.css',
chunkFilename: '[id].css',
})
);
}
return mergedConfig;
};

View File

@@ -0,0 +1,218 @@
const pluginConfig = require('../pluginConfig.json');
const fs = require('fs');
const os = require('os');
const glob = require('glob');
const autogenerationDisclaimer = `
// THIS FILE IS AUTOGENERATED AS PART OF THE EXTENSION AND MODE PLUGIN PROCESS.
// IT SHOULD NOT BE MODIFIED MANUALLY \n`;
const extractName = val => (typeof val === 'string' ? val : val.packageName);
function constructLines(input, categoryName) {
let pluginCount = 0;
const lines = {
importLines: [],
addToWindowLines: [],
};
if (!input) return lines;
input.forEach(entry => {
if (entry.default === false) return;
const packageName = extractName(entry);
lines.addToWindowLines.push(`${categoryName}.push("${packageName}");\n`);
pluginCount++;
});
return lines;
}
function getFormattedImportBlock(importLines) {
let content = '';
// Imports
importLines.forEach(importLine => {
content += importLine;
});
return content;
}
function getFormattedWindowBlock(addToWindowLines) {
let content =
'const extensions = [];\n' +
'const modes = [];\n' +
'\n// Not required any longer\n' +
'window.extensions = extensions;\n' +
'window.modes = modes;\n\n';
addToWindowLines.forEach(addToWindowLine => {
content += addToWindowLine;
});
return content;
}
function getRuntimeLoadModesExtensions(modules) {
const dynamicLoad = [];
dynamicLoad.push(
'\n\n// Add a dynamic runtime loader',
'async function loadModule(module) {',
" if (typeof module !== 'string') return module;"
);
modules.forEach(module => {
const packageName = extractName(module);
if (!packageName) {
return;
}
if (module.importPath) {
dynamicLoad.push(
` if( module==="${packageName}") {`,
` const imported = await window.browserImportFunction('${module.importPath}');`,
' return ' + (module.globalName ? `window["${module.globalName}"];` : `imported["${module.importName || 'default'}"];`),
' }'
);
return;
}
dynamicLoad.push(
` if( module==="${packageName}") {`,
` const imported = await import("${packageName}");`,
' return imported.default;',
' }'
);
});
// TODO - handle more cases for import than just default
dynamicLoad.push(
' return (await window.browserImportFunction(module)).default;',
'}\n',
'// Import a list of items (modules or string names)',
'// @return a Promise evaluating to a list of modules',
'export default function importItems(modules) {',
' return Promise.all(modules.map(loadModule));',
'}\n',
'export { loadModule, modes, extensions, importItems };\n\n'
);
return dynamicLoad.join('\n');
}
const fromDirectory = (srcDir, path) => {
if (!path) return;
if (path[0] === '.') return srcDir + '/../../..' + path.substring(1);
if (path[0] === '~') return os.homedir() + path.substring(1);
return path;
};
const createCopyPluginToDistForLink = (srcDir, distDir, plugins, folderName) => {
return plugins
.map(plugin => {
const fromDir = fromDirectory(srcDir, plugin.directory);
const from = fromDir || `${srcDir}/../node_modules/${plugin.packageName}/${folderName}/`;
const exists = fs.existsSync(from);
return exists
? {
from,
to: distDir,
toType: 'dir',
}
: undefined;
})
.filter(x => !!x);
};
const createCopyPluginToDistForBuild = (SRC_DIR, DIST_DIR, plugins, folderName) => {
return plugins
.map(plugin => {
const from = `${SRC_DIR}/../../../node_modules/${plugin.packageName}/${folderName}/`;
const exists = fs.existsSync(from);
return exists
? {
from,
to: DIST_DIR,
toType: 'dir',
}
: undefined;
})
.filter(x => !!x);
};
function writePluginImportsFile(SRC_DIR, DIST_DIR) {
let pluginImportsJsContent = autogenerationDisclaimer;
const extensionLines = constructLines(pluginConfig.extensions, 'extensions');
const modeLines = constructLines(pluginConfig.modes, 'modes');
pluginImportsJsContent += getFormattedImportBlock([
...extensionLines.importLines,
...modeLines.importLines,
]);
pluginImportsJsContent += getFormattedWindowBlock([
...extensionLines.addToWindowLines,
...modeLines.addToWindowLines,
]);
pluginImportsJsContent += getRuntimeLoadModesExtensions([
...pluginConfig.extensions,
...pluginConfig.modes,
...pluginConfig.public,
]);
fs.writeFileSync(`${SRC_DIR}/pluginImports.js`, pluginImportsJsContent, { flag: 'w+' }, err => {
if (err) {
console.error(err);
return;
}
});
// Build packages using cli add-mode and add-extension
// will get added to the root node_modules, but the linked packages
// will be hosted at the viewer node_modules.
const copyPluginPublicToDistBuild = createCopyPluginToDistForBuild(
SRC_DIR,
DIST_DIR,
[...pluginConfig.modes, ...pluginConfig.extensions],
'public'
);
const copyPluginPublicToDistLink = createCopyPluginToDistForLink(
SRC_DIR,
DIST_DIR,
[...pluginConfig.modes, ...pluginConfig.extensions, ...pluginConfig.public],
'public'
);
// Temporary way to copy chunks from the dist folder so that the become
// available
const copyPluginDistToDistBuild = createCopyPluginToDistForBuild(
SRC_DIR,
DIST_DIR,
[...pluginConfig.modes, ...pluginConfig.extensions],
'dist'
);
const copyPluginDistToDistLink = createCopyPluginToDistForLink(
SRC_DIR,
DIST_DIR,
[...pluginConfig.modes, ...pluginConfig.extensions],
'dist'
);
console.warn('copy plugins', [
...copyPluginPublicToDistBuild,
...copyPluginPublicToDistLink,
...copyPluginDistToDistBuild,
...copyPluginDistToDistLink,
]);
return [
...copyPluginPublicToDistBuild,
...copyPluginPublicToDistLink,
...copyPluginDistToDistBuild,
...copyPluginDistToDistLink,
];
}
module.exports = writePluginImportsFile;

4354
platform/app/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

21
platform/app/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

207
platform/app/README.md Normal file
View File

@@ -0,0 +1,207 @@
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<div align="center">
<h1>@ohif/app</h1>
<p><strong>@ohif/app</strong> is a zero-footprint medical image viewer provided by the <a href="https://ohif.org/">Open Health Imaging Foundation (OHIF)</a>. It is a configurable and extensible progressive web application with out-of-the-box support for image archives which support <a href="https://www.dicomstandard.org/dicomweb/">DICOMweb</a>.</p>
</div>
<div align="center">
<a href="https://docs.ohif.org/"><strong>Read The Docs</strong></a> |
<a href="https://github.com/OHIF/Viewers/tree/master/docs/latest">Edit the docs</a>
</div>
<div align="center">
<a href="https://docs.ohif.org/demo">Demo</a> |
<a href="https://react.ohif.org/">Component Library</a>
</div>
<hr />
[![NPM version][npm-version-image]][npm-url]
[![NPM downloads][npm-downloads-image]][npm-url]
[![Pulls][docker-pulls-img]][docker-image-url]
[![All Contributors](https://img.shields.io/badge/all_contributors-9-orange.svg?style=flat-square)](#contributors)
[![MIT License][license-image]][license-url]
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
> ATTENTION: If you are looking for Version 1 (the Meteor Version) of this
> repository, it lives on
> [the `v1.x` branch](https://github.com/OHIF/Viewers/tree/v1.x)
## Why?
Building a web based medical imaging viewer from scratch is time intensive, hard
to get right, and expensive. Instead of re-inventing the wheel, you can use the
OHIF Viewer as a rock solid platform to build on top of. The Viewer is a
[React][react-url] [Progressive Web Application][pwa-url] that can be embedded
in existing applications via it's [packaged source
(ohif-viewer)][ohif-viewer-url] or hosted stand-alone. The Viewer exposes
[configuration][configuration-url] and [extensions][extensions-url] to support
workflow customization and advanced functionality at common integration points.
If you're interested in using the OHIF Viewer, but you're not sure it supports
your use case [check out our docs](https://docs.ohif.org/). Still not sure, or
you would like to propose new features? Don't hesitate to
[create an issue](https://github.com/OHIF/Viewers/issues) or open a pull
request.
## Getting Started
This readme is specific to testing and developing locally. If you're more
interested in production deployment strategies,
[you can check out our documentation on publishing](https://docs.ohif.org/).
Want to play around before you dig in?
[Check out our LIVE Demo](https://viewer.ohif.org/)
### Setup
_Requirements:_
- [NodeJS & NPM](https://nodejs.org/en/download/)
- [Yarn](https://yarnpkg.com/lang/en/docs/install/)
_Steps:_
1. Fork this repository
2. Clone your forked repository (your `origin`)
- `git clone git@github.com:YOUR_GITHUB_USERNAME/Viewers.git`
3. Add `OHIF/Viewers` as a `remote` repository (the `upstream`)
- `git remote add upstream git@github.com:OHIF/Viewers.git`
### Developing Locally
In your cloned repository's root folder, run:
```js
// Restore dependencies
yarn install
// Stands up local server to host Viewer.
// Viewer connects to our public cloud PACS by default
yarn start
```
For more advanced local development scenarios, like using your own locally
hosted PACS and test data,
[check out our Essential: Getting Started](https://docs.ohif.org/getting-started.html)
guide.
### E2E Tests
Using [Cypress](https://www.cypress.io/) to create End-to-End tests and check
whether the application flow is performing correctly, ensuring that the
integrated components are working as expected.
#### Why Cypress?
Cypress is a next generation front end testing tool built for the modern web.
With Cypress is easy to set up, write, run and debug tests
It allow us to write different types of tests:
- End-to-End tests
- Integration tests
- Unit tets
All tests must be in `./cypress/integration` folder.
Commands to run the tests:
```js
// Open Cypress Dashboard that provides insight into what happened when your tests ran
yarn run cy
// Run all tests using Electron browser headless
yarn run cy:run
// Run all tests in CI mode
yarn run cy:run:ci
```
### Contributing
> Large portions of the Viewer's functionality are maintained in other
> repositories. To get a better understanding of the Viewer's architecture and
> "where things live", read
> [our docs on the Viewer's architecture](https://docs.ohif.org/architecture/index.html#overview)
It is notoriously difficult to setup multiple dependent repositories for
end-to-end testing and development. That's why we recommend writing and running
unit tests when adding and modifying features. This allows us to program in
isolation without a complex setup, and has the added benefit of producing
well-tested business logic.
1. Clone this repository
2. Navigate to the project directory, and `yarn install`
3. To begin making changes, `yarn run dev`
4. To commit changes, run `yarn run cm`
When creating tests, place the test file "next to" the file you're testing.
[For example](https://github.com/OHIF/Viewers/blob/master/src/utils/index.test.js):
```js
// File
index.js;
// Test for file
index.test.js;
```
As you add and modify code, `jest` will watch for uncommitted changes and run
your tests, reporting the results to your terminal. Make a pull request with
your changes to `master`, and a core team member will review your work. If you
have any questions, please don't hesitate to reach out via a GitHub issue.
## Contributors
Thanks goes to these wonderful people
([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore -->
<table><tr><td align="center"><a href="https://github.com/swederik"><img src="https://avatars3.githubusercontent.com/u/607793?v=4" width="100px;" alt="Erik Ziegler"/><br /><sub><b>Erik Ziegler</b></sub></a><br /><a href="https://github.com/OHIF/Viewers/commits?author=swederik" title="Code">💻</a> <a href="#infra-swederik" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td><td align="center"><a href="https://github.com/evren217"><img src="https://avatars1.githubusercontent.com/u/4920551?v=4" width="100px;" alt="Evren Ozkan"/><br /><sub><b>Evren Ozkan</b></sub></a><br /><a href="https://github.com/OHIF/Viewers/commits?author=evren217" title="Code">💻</a></td><td align="center"><a href="https://github.com/galelis"><img src="https://avatars3.githubusercontent.com/u/2378326?v=4" width="100px;" alt="Gustavo André Lelis"/><br /><sub><b>Gustavo André Lelis</b></sub></a><br /><a href="https://github.com/OHIF/Viewers/commits?author=galelis" title="Code">💻</a></td><td align="center"><a href="http://dannyrb.com/"><img src="https://avatars1.githubusercontent.com/u/5797588?v=4" width="100px;" alt="Danny Brown"/><br /><sub><b>Danny Brown</b></sub></a><br /><a href="https://github.com/OHIF/Viewers/commits?author=dannyrb" title="Code">💻</a> <a href="#infra-dannyrb" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td><td align="center"><a href="https://github.com/all-contributors/all-contributors-bot"><img src="https://avatars3.githubusercontent.com/u/46843839?v=4" width="100px;" alt="allcontributors[bot]"/><br /><sub><b>allcontributors[bot]</b></sub></a><br /><a href="https://github.com/OHIF/Viewers/commits?author=allcontributors" title="Documentation">📖</a></td><td align="center"><a href="https://www.linkedin.com/in/siliconvalleynextgeneration/"><img src="https://avatars0.githubusercontent.com/u/1230575?v=4" width="100px;" alt="Esref Durna"/><br /><sub><b>Esref Durna</b></sub></a><br /><a href="#question-EsrefDurna" title="Answering Questions">💬</a></td><td align="center"><a href="https://github.com/diego0020"><img src="https://avatars3.githubusercontent.com/u/7297450?v=4" width="100px;" alt="diego0020"/><br /><sub><b>diego0020</b></sub></a><br /><a href="https://github.com/OHIF/Viewers/commits?author=diego0020" title="Code">💻</a></td></tr><tr><td align="center"><a href="https://github.com/dlwire"><img src="https://avatars3.githubusercontent.com/u/1167291?v=4" width="100px;" alt="David Wire"/><br /><sub><b>David Wire</b></sub></a><br /><a href="https://github.com/OHIF/Viewers/commits?author=dlwire" title="Code">💻</a></td><td align="center"><a href="https://github.com/jfmedeiros1820"><img src="https://avatars1.githubusercontent.com/u/2211708?v=4" width="100px;" alt="João Felipe de Medeiros Moreira"/><br /><sub><b>João Felipe de Medeiros Moreira</b></sub></a><br /><a href="https://github.com/OHIF/Viewers/commits?author=jfmedeiros1820" title="Tests">⚠️</a></td></tr></table>
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the
[all-contributors](https://github.com/all-contributors/all-contributors)
specification. Contributions of any kind welcome!
## License
MIT © [OHIF](https://github.com/OHIF)
<!--
Links:
-->
<!-- prettier-ignore-start -->
[npm-url]: https://npmjs.org/package/ohif-viewer
[npm-downloads-image]: https://img.shields.io/npm/dm/ohif-viewer.svg?style=flat-square
[npm-version-image]: https://img.shields.io/npm/v/ohif-viewer.svg?style=flat-square
[docker-pulls-img]: https://img.shields.io/docker/pulls/ohif/viewer.svg?style=flat-square
[docker-image-url]: https://hub.docker.com/r/ohif/viewer
[all-contributors-image]: https://img.shields.io/badge/all_contributors-0-orange.svg?style=flat-square
[license-image]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square
[license-url]: LICENSE
<!-- DOCS -->
[react-url]: https://reactjs.org/
[pwa-url]: https://developers.google.com/web/progressive-web-apps/
[ohif-viewer-url]: https://www.npmjs.com/package/ohif-viewer
[configuration-url]: https://docs.ohif.org/configuring/
[extensions-url]: https://docs.ohif.org/extensions
<!-- Misc. -->
[react-viewer]: https://github.com/OHIF/Viewers/tree/react
<!-- Issue Boilerplate -->
[bugs]: https://github.com/OHIF/Viewers/labels/bug
[requests-feature]: https://github.com/OHIF/Viewers/labels/enhancement
[good-first-issue]: https://github.com/OHIF/Viewers/labels/good%20first%20issue
[google-group]: https://groups.google.com/forum/#!forum/cornerstone-platform
<!-- prettier-ignore-end -->

Binary file not shown.

View File

@@ -0,0 +1 @@
module.exports = require('../../babel.config.js');

View File

@@ -0,0 +1,37 @@
import { defineConfig } from 'cypress';
export default defineConfig({
chromeWebSecurity: false,
e2e: {
experimentalRunAllSpecs: true,
supportFile: 'cypress/support/index.js',
setupNodeEvents(on, config) {
on('before:browser:launch', (browser, launchOptions) => {
// `args` is an array of all the arguments that will
// be passed to browsers when it launches
console.log(launchOptions.args); // print all current args
console.log('***', browser.family, browser.name, '***');
// whatever you return here becomes the launchOptions
return launchOptions;
});
},
baseUrl: 'http://localhost:3000',
waitForAnimations: true,
chromeWebSecurity: false,
defaultCommandTimeout: 30000,
requestTimeout: 30000,
responseTimeout: 30000,
pageLoadTimeout: 30000,
specPattern: 'cypress/integration/**/*.spec.[jt]s',
projectId: '4oe38f',
video: true,
reporter: 'junit',
reporterOptions: {
mochaFile: 'cypress/results/test-output.xml',
toConsole: true,
},
},
});

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: ['cypress'],
env: {
'cypress/globals': true,
},
};

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,92 @@
/**
* Add tests to ensure image consistency and quality
*/
const testPixel = (dx, dy, expectedPixel) => {
cy.get('.cornerstone-canvas').then(v => {
const canvas = v[0];
cy.log(
'testPixel canvas',
dx,
dy,
expectedPixel,
canvas.width,
canvas.height,
canvas.style.width,
canvas.style.height
);
const ctx = canvas.getContext('2d');
cy.window()
.its('cornerstone')
.then(cornerstone => {
const { viewport } = cornerstone.getEnabledElements()[0];
const imageData = viewport.getImageData();
// cy.log("imageData", imageData);
const origin = viewport.worldToCanvas(imageData?.origin);
const orX = origin[0] * devicePixelRatio;
const orY = origin[1] * devicePixelRatio;
const x = Math.round(orX + dx);
const y = Math.round(orY + dy);
cy.log('testPixel origin x,y point x,y', orX, orY, x, y);
// cy.log('world origin', imageData.origin);
// cy.log('focal', viewport.getCamera().focalPoint,
// viewport.worldToCanvas(viewport.getCamera().focalPoint));
const pixelData = ctx.getImageData(x, y, 1, 1);
expect(pixelData.data[0]).closeTo(expectedPixel, 1);
});
});
};
describe('CS3D Image Consistency and Quality', () => {
const setupStudySeries = (studyUID, seriesUID) => {
cy.checkStudyRouteInViewer(
studyUID,
`&seriesInstanceUID=${seriesUID}&hangingProtocolId=@ohif/hpScale`
);
cy.initCornerstoneToolsAliases();
const skipMarkers = true;
cy.initCommonElementsAliases(skipMarkers);
};
it('TG18 Resolution Test Displayed 1:1', () => {
setupStudySeries(
'2.16.124.113543.6004.101.103.20021117.061159.1',
'2.16.124.113543.6004.101.103.20021117.061159.1.004'
);
cy.wait(2000);
testPixel(1018, 1028, 255);
// Horizontal and vertical delta from this should not be contaminated
// by values from center
testPixel(1019, 1028, 0);
testPixel(1018, 1029, 0);
testPixel(1017, 1028, 0);
testPixel(1018, 1027, 0);
});
// Missing test data - todo
it.skip('8 bit image displayable', () => {
setupStudySeries('1.3.46.670589.17.1.7.1.1.7', '1.3.46.670589.17.1.7.2.1.7');
cy.wait(1000);
// Compare with dcm2jpg generated values or by manually computing WL values
testPixel(258, 257, 171);
testPixel(259, 257, 166);
});
it.skip('12 bit image displayable and zoom with pixel spacing', () => {
setupStudySeries(
'1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1',
'1.3.6.1.4.1.25403.345050719074.3824.20170125113608.5'
);
cy.wait(1000);
// Compare with dcm2jpg generated values or by manually computing WL values
testPixel(258, 277, 120);
testPixel(259, 277, 122);
});
});

View File

@@ -0,0 +1,26 @@
describe('OHIF Multi Study', () => {
const beforeSetup = () => {
cy.initViewer(
'1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1,1.2.840.113619.2.5.1762583153.215519.978957063.78',
{
params: '&hangingProtocolId=@ohif/hpCompare',
minimumThumbnails: 3,
}
);
};
it('Should display 2 comparison up', () => {
beforeSetup();
cy.get('[data-cy="viewport-pane"]').as('viewportPane');
cy.get('@viewportPane').its('length').should('be.eq', 4);
cy.get('[data-cy="viewport-overlay-top-left"] [title="Study date"]').as('studyDate');
cy.get('@studyDate').should(studyDate => {
expect(studyDate.length).to.be.eq(4);
expect(studyDate.text()).to.contain('2014').contain('2001');
expect(studyDate.text().indexOf('2014')).to.be.lessThan(studyDate.text().indexOf('2001'));
});
});
});

View File

@@ -0,0 +1,9 @@
describe('OHIF PDF Display', function () {
beforeEach(function () {
cy.openStudyInViewer('2.25.317377619501274872606137091638706705333');
});
it('checks if series thumbnails are being displayed', function () {
cy.get('[data-cy="study-browser-thumbnail-no-image"]').its('length').should('be.gt', 0);
});
});

View File

@@ -0,0 +1,16 @@
describe('OHIF Video Display', function () {
beforeEach(function () {
cy.openStudyInViewer('2.25.96975534054447904995905761963464388233');
});
it('checks if series thumbnails are being displayed', function () {
cy.get('[data-cy="study-browser-thumbnail-no-image"]').its('length').should('be.gt', 1);
});
it('performs double-click to load thumbnail in active viewport', () => {
cy.get('[data-cy="study-browser-thumbnail-no-image"]:nth-child(2)').dblclick();
//const expectedText = 'Ser: 3';
//cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText);
});
});

View File

@@ -0,0 +1,42 @@
describe('OHIF HP', () => {
beforeEach(() => {
cy.checkStudyRouteInViewer(
'1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1',
'&hangingProtocolId=@ohif/mnGrid'
);
cy.expectMinimumThumbnails(3);
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
cy.waitDicomImage();
});
it('Should display 3 up', () => {
cy.get('[data-cy="viewport-pane"]').its('length').should('be.eq', 4);
});
it('Should navigate next/previous stage', () => {
cy.get('body').type(',');
cy.wait(250);
cy.get('[data-cy="viewport-pane"]').its('length').should('be.eq', 4);
cy.get('body').type('..');
cy.wait(250);
cy.get('[data-cy="viewport-pane"]').its('length').should('be.eq', 2);
});
it('Should navigate to display set specified', () => {
Cypress.on('uncaught:exception', () => false);
// This filters by series instance UID, meaning there will only be 1 thumbnail
// It applies the initial SOP instance, navigating to that image
cy.checkStudyRouteInViewer(
'1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1',
'&SeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113545.4&initialSopInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113546.1'
);
cy.expectMinimumThumbnails(1);
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
// The specified series/sop UID's are index 101, so ensure that image is displayed
cy.get('@viewportInfoBottomRight').should('contains.text', 'I:6');
});
});

View File

@@ -0,0 +1,54 @@
describe('OHIF Double Click', () => {
beforeEach(() => {
cy.checkStudyRouteInViewer(
'1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1',
'&hangingProtocolId=@ohif/mnGrid'
);
cy.expectMinimumThumbnails(3);
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
});
it('Should double click each viewport to one up and back', () => {
const numExpectedViewports = 4;
cy.get('[data-cy="viewport-pane"]').its('length').should('be.eq', numExpectedViewports);
for (let i = 0; i < 3; i += 1) {
cy.wait(1000);
// For whatever reason, with Cypress tests, we have to activate the
// viewport we are double clicking first.
cy.get('[data-cy="viewport-pane"]')
.eq(i)
.trigger('mousedown', 'center', {
force: true,
})
.trigger('mouseup', 'center', {
force: true,
});
// Wait for the viewport to be 'active'.
// TODO Is there a better way to do this?
cy.get('[data-cy="viewport-pane"]')
.eq(i)
.parent()
.find('[data-cy="viewport-pane"]')
.not('.pointer-events-none');
// The actual double click.
cy.get('[data-cy="viewport-pane"]').eq(i).trigger('dblclick', 'center');
cy.get('[data-cy="viewport-pane"]').its('length').should('be.eq', 1);
cy.get('[data-cy="viewport-pane"]')
.trigger('mousedown', 'center', {
force: true,
})
.trigger('mouseup', 'center', {
force: true,
});
cy.get('[data-cy="viewport-pane"]').eq(0).trigger('dblclick', 'center');
}
});
});

View File

@@ -0,0 +1,35 @@
describe('OHIF Context Menu', function () {
beforeEach(function () {
cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78');
cy.expectMinimumThumbnails(3);
cy.initCommonElementsAliases();
cy.initCornerstoneToolsAliases();
cy.waitDicomImage();
});
it('checks context menu customization', function () {
// Add length measurement
cy.addLengthMeasurement();
cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').as('yesBtn').click();
cy.get('[data-cy="data-row"]').as('measurementItem').click();
const [x1, y1] = [150, 100];
cy.get('@viewport')
.trigger('mousedown', x1, y1, {
which: 3,
})
.trigger('mouseup', x1, y1, {
which: 3,
});
// Contextmenu is visible
cy.get('[data-cy="context-menu"]').as('contextMenu').should('be.visible');
// Click "Finding" subMenu
cy.get('[data-cy="context-menu-item"]').as('item').contains('Finding').click();
// Click "Finding" subMenu
cy.get('[data-cy="context-menu-item"]').as('item').contains('Aortic insufficiency').click();
cy.get('[data-cy="data-row"]').as('measure-item').contains('Aortic insufficiency');
});
});

View File

@@ -0,0 +1,154 @@
describe('OHIF Cornerstone Hotkeys', () => {
beforeEach(() => {
cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78');
cy.window()
.its('cornerstone')
.then(cornerstone => {
// For debugging issues where tests pass locally but fail on CI
// - Sometimes Cypress orb seems to use CPU rendering pathway
cy.log(`Cornerstone using CPU Rendering?: ${cornerstone.getShouldUseCPURendering()}`);
});
cy.expectMinimumThumbnails(3);
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
cy.waitDicomImage();
});
it('checks if hotkeys "R" and "L" can rotate the image', () => {
cy.get('body').type('R');
cy.get('@viewportInfoMidLeft').should('contains.text', 'P');
cy.get('@viewportInfoMidTop').should('contains.text', 'R');
// Hotkey L
cy.get('body').type('L');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
});
it('checks if hotkeys "ArrowUp" and "ArrowDown" can navigate in the stack', () => {
// Hotkey ArrowDown
cy.get('body').type('{downarrow}');
cy.get('@viewportInfoBottomRight').should('contains.text', 'I:2 (2/26)');
// Hotkey ArrowUp
cy.get('body').type('{uparrow}');
cy.get('@viewportInfoBottomRight').should('contains.text', 'I:1 (1/26)');
});
it('checks if hotkeys "V" and "H" can flip the image', () => {
// Hotkey H
cy.get('body').type('h');
cy.get('@viewportInfoMidLeft').should('contains.text', 'L');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
// Hotkey V
cy.get('body').type('v');
cy.get('@viewportInfoMidLeft').should('contains.text', 'L');
cy.get('@viewportInfoMidTop').should('contains.text', 'P');
});
// it('checks if hotkeys "+", "-" and "=" can zoom in, out and fit to viewport', () => {
// //Click on button and verify if icon is active on toolbar
// cy.get('@zoomBtn')
// .click()
// .then($zoomBtn => {
// cy.wrap($zoomBtn).should('have.class', 'active');
// });
// // Hotkey +
// cy.get('body').type('+++'); // Press hotkey 3 times
// cy.get('@viewportInfoTopLeft').should('contains.text', 'Zoom:2.30x');
// // Hotkey -
// cy.get('body').type('-');
// cy.get('@viewportInfoTopLeft').should('contains.text', 'Zoom:2.09x');
// // Hotkey =
// cy.get('body').type('=');
// cy.get('@viewportInfoTopLeft').should('contains.text', 'Zoom:1.67x');
// });
it('checks if hotkey "SPACEBAR" can reset the image', () => {
// Press multiples hotkeys
cy.get('body').type('v+++i');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'P');
// Hotkey SPACEBAR
cy.get('body').type(' ');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
});
/*
// TODO: Pretty sure this is not implemented yet
// it('uses hotkeys "RightArrow" and "LeftArrow" to navigate between multiple viewports', () => {
//Select viewport layout (3,1)
cy.setLayout(3, 1);
cy.waitViewportImageLoading();
// Press multiples hotkeys on viewport #1
cy.get('body').type('VL+++I');
cy.get('@viewportInfoMidLeft').should('contains.text', 'A');
cy.get('@viewportInfoMidTop').should('contains.text', 'R');
cy.get('@viewportInfoBottomRight').should('contains.text', 'Zoom: 134%');
// Hotkey RightArrow: Move to next viewport
cy.get('body').type('{rightarrow}');
// Get overlay information from viewport #2
cy.get(
':nth-child(2) > .viewport-wrapper > .viewport-element > .ViewportOrientationMarkers.noselect > .top-mid.orientation-marker'
).as('viewport2InfoMidTop');
cy.get(
':nth-child(2) > .viewport-wrapper > .viewport-element > .ViewportOrientationMarkers.noselect > .left-mid.orientation-marker'
).as('viewport2InfoMidLeft');
cy.get(
':nth-child(2) > .viewport-wrapper > .viewport-element > .ViewportOverlay > div.bottom-right.overlay-element > div'
).as('viewport2InfoBottomRight');
// Press multiples hotkeys on viewport #2
cy.get('body').type('RR++H+++I');
cy.get('@viewport2InfoMidLeft').should('contains.text', 'P');
cy.get('@viewport2InfoMidTop').should('contains.text', 'H');
cy.get('@viewport2InfoBottomRight').should('contains.text', 'Zoom: 120%');
// Hotkey LeftArrow: Move to previous viewport
cy.get('body').type('{leftarrow}');
// Hotkey SPACEBAR: Reset viewport #1
cy.get('body').type(' ');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
cy.get('@viewportInfoBottomRight').should('contains.text', 'Zoom: 89%');
// Hotkey RightArrow: Move to next viewport
cy.get('body').type('{rightarrow}');
// Hotkey SPACEBAR: Reset viewport #2
cy.get('body').type(' ');
cy.get('@viewport2InfoMidLeft').should('contains.text', 'A');
cy.get('@viewport2InfoMidTop').should('contains.text', 'H');
cy.get('@viewport2InfoBottomRight').should('contains.text', 'Zoom: 45%');
//Select viewport layout (1,1)
cy.setLayout(1, 1);
});*/
//TO-DO: This test is blocked by issue #1095 (https://github.com/OHIF/Viewers/issues/1095)
//Once issue is fixed, this test can be uncommented
// it('checks if hotkey "Z" activates zoom tool', () => {
// // Hotkey Z
// cy.get('body').type('Z');
// // Verify if icon is active on toolbar
// cy.get('@zoomBtn').should('have.class', 'active');
// });
//TO-DO: This test is blocked by issue #1095 (https://github.com/OHIF/Viewers/issues/1095)
//Once issue is fixed, this test can be uncommented
// it('checks if hotkeys "PageDown" and "PageUp" can navigate in the series thumbnails', () => {
// // Hotkey PageDown
// cy.get('body').type('{pagedown}{pagedown}'); // press hotkey twice
// cy.get('@viewportInfoBottomLeft').should('contains.text', 'Ser: 3');
// // Hotkey PageUp
// cy.get('body').type('{pageup}');
// cy.get('@viewportInfoBottomLeft').should('contains.text', 'Ser: 2');
// });
});

View File

@@ -0,0 +1,462 @@
describe('OHIF Cornerstone Toolbar', () => {
beforeEach(() => {
cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78');
cy.expectMinimumThumbnails(3);
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
cy.get('[data-cy="study-browser-thumbnail"]').eq(1).click();
//const expectedText = 'Ser: 1';
//cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText);
cy.waitDicomImage();
});
it('checks if all primary buttons are being displayed', () => {
cy.get('@zoomBtn').should('be.visible');
cy.get('@wwwcBtnPrimary').should('be.visible');
cy.get('@wwwcBtnSecondary').should('be.visible');
cy.get('@panBtn').should('be.visible');
cy.get('@measurementToolsBtnPrimary').should('be.visible');
cy.get('@measurementToolsBtnSecondary').should('be.visible');
cy.get('@moreBtnPrimary').should('be.visible');
cy.get('@moreBtnSecondary').should('be.visible');
cy.get('@layoutBtn').should('be.visible');
});
/*it('checks if Stack Scroll tool will navigate across all series in the viewport', () => {
//Click on button and verify if icon is active on toolbar
cy.get('@stackScrollBtn')
.click()
.then($stackScrollBtn => {
cy.wrap($stackScrollBtn).should('have.class', 'active');
});
//drags the mouse inside the viewport to be able to interact with series
cy.get('@viewport')
.trigger('mousedown', 'center', { buttons: 1 })
.trigger('mousemove', 'top', { buttons: 1 })
.trigger('mouseup');
const expectedText =
'Ser: 1Img: 1 1/26256 x 256Loc: -30.00 mm Thick: 5.00 mm';
cy.get('@viewportInfoBottomLeft').should('have.text', expectedText);
});*/
// it('checks if Zoom tool will zoom in/out an image in the viewport', () => {
// //Click on button and verify if icon is active on toolbar
// cy.get('@zoomBtn')
// .click()
// .then($zoomBtn => {
// cy.wrap($zoomBtn).should('have.class', 'active');
// });
// // IMPORTANT: Cypress sends out a mouseEvent which doesn't have the buttons
// // property. This is a workaround to simulate a mouseEvent with the buttons property
// // which is consumed by cornerstone
// cy.get('@viewport')
// .trigger('mousedown', 'center', { buttons: 1 })
// .trigger('mousemove', 'top', {
// buttons: 1,
// })
// .trigger('mouseup', {
// buttons: 1,
// });
// const expectedText = 'Zoom:0.96x';
// cy.get('@viewportInfoTopLeft').should('have.text', expectedText);
// });
it('checks if Levels tool will change the window width and center of an image', () => {
// Wait for the DICOM image to load
// Assign an alias to the button element
cy.get('@wwwcBtnPrimary').as('wwwcButton');
cy.get('@wwwcButton').click();
cy.get('@wwwcButton').should('have.class', 'bg-primary-light');
//drags the mouse inside the viewport to be able to interact with series
cy.get('@viewport')
.trigger('mousedown', 'center', { buttons: 1 })
// Since we have scrollbar on the right side of the viewport, we need to
// force the mousemove since it goes to another element
.trigger('mousemove', 'right', { buttons: 1, force: true })
.trigger('mouseup', { buttons: 1 });
// The exact text is slightly dependent on the viewport resolution, so leave a range
cy.get('@viewportInfoBottomLeft').should($txt => {
const text = $txt.text();
expect(text).to.include('L:479');
});
});
it('checks if Pan tool will move the image inside the viewport', () => {
// Assign an alias to the button element
cy.get('@panBtn').as('panButton');
// Click on the button
cy.get('@panButton').click();
// Assert that the button has the 'active' class
cy.get('@panButton').should('have.class', 'bg-primary-light');
// Trigger the pan actions on the viewport
cy.get('@viewport')
.trigger('mousedown', 'center', { buttons: 1 })
.trigger('mousemove', 'bottom', { buttons: 1 })
.trigger('mouseup', 'bottom');
});
it('checks if Length annotation can be added to viewport and shows up in the measurements panel', () => {
//Click on button and verify if icon is active on toolbar
cy.addLengthMeasurement();
cy.get('[data-cy="viewport-notification"]').as('notif').should('exist');
// cy.get('[data-cy="viewport-notification"]').as('notif').should('be.visible');
cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').as('yesBtn').click();
//Verify the measurement exists in the table
cy.get('@measurementsPanel').should('be.visible');
cy.get('[data-cy="data-row"]').as('measure').its('length').should('be.at.least', 1);
});
/*it('checks if angle annotation can be added on viewport without causing any errors', () => {
//Click on button and verify if icon is active on toolbar
cy.get('@angleBtn')
.click()
.then($angleBtn => {
cy.wrap($angleBtn).should('have.class', 'active'); // TODO: should we just add the 'active' class back? Or use a data property?
});
//Add annotation on the viewport
const initPos = [180, 390];
const midPos = [300, 410];
const finalPos = [180, 450];
cy.addAngle('@viewport', initPos, midPos, finalPos);
});*/
it('checks if Reset tool will reset all changes made on the image', () => {
//Make some changes by zooming in and rotating the image
cy.imageZoomIn();
cy.imageContrast();
//Click on reset button
cy.resetViewport();
const expectedText = 'W:958L:479';
cy.get('@viewportInfoBottomLeft').should('have.text', expectedText);
});
/*it('checks if CINE tool will prompt a modal with working controls', () => {
cy.server();
cy.route('GET', '/!**!/studies/!**!/').as('studies');
//Click on button
cy.get('@cineBtn').click();
// Verify if cine control overlay is being displayed
cy.get('.cine-controls')
.as('cineControls')
.should('be.visible');
//Test PLAY button
cy.get('[title="Play / Stop"]').then($btn => {
$btn.click();
cy.wait(100);
$btn.click();
});
let expectedText = 'Img: 1 1/26';
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'not.have.text',
expectedText
);
//Test SKIP TO FIRST IMAGE button
cy.get('[title="Skip to first Image"]')
.click()
.wait(1000);
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'contain.text',
expectedText
);
//Test NEXT IMAGE button
cy.get('[title="Next Image"]')
.click()
.wait(1000);
expectedText = 'Img: 2 2/26';
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'contain.text',
expectedText
);
//Test SKIP TO LAST IMAGE button
cy.get('[title="Skip to last Image"]')
.click()
.wait(2000);
expectedText = 'Img: 27 26/26';
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'contain.text',
expectedText
);
//Test PREVIOUS IMAGE button
cy.get('[title="Previous Image"]')
.click()
.wait(1000);
expectedText = 'Img: 26 25/26';
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'contain.text',
expectedText
);
//Click on Cine button
cy.get('@cineBtn')
.click()
.then(() => {
// Verify that cine control overlay is hidden
cy.get('@cineControls').should('not.exist');
});
});*/
/**
it('checks if More button will prompt a modal with secondary tools', () => {
//Click on More button
cy.get('@moreBtnSecondary').click();
//Verify if overlay is displayed
cy.get('[data-cy="MoreTools-list-menu"]')
.as('toolbarOverlay')
.should('be.visible');
// Click on one of the secondary tools from the overlay
cy.get('[data-cy="Magnify"]').click();
// Check if More button is active and if it has same icon as the secondary tool selected
cy.get('@moreBtnPrimary').then($moreBtn => {
cy.wrap($moreBtn)
.should('have.class', 'active')
.should('have.attr', 'data-tool', 'Magnify');
});
// Verify if overlay is hidden
cy.get('@toolbarOverlay').should('not.be.visible');
});
*/
/*it('checks if Layout tool will multiply the number of viewports displayed', () => {
//Click on Layout button and verify if overlay is displayed
cy.get('@layoutBtn')
.click()
.then(() => {
cy.get('.layoutChooser')
.as('layoutChooser')
.should('be.visible')
.find('td')
.its('length')
.should('be.eq', 9);
cy.get('@layoutBtn').click();
});
//verify if layout has changed to 2 viewports
cy.setLayout(1, 2);
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 2);
});
cy.setLayout(2, 1);
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 2);
});
//verify if layout has changed to 3 viewports
cy.setLayout(1, 3);
cy.get('.viewport-container').then($viewport => {
cy.wait(1000);
cy.wrap($viewport)
.its('length')
.should('be.eq', 3);
});
cy.setLayout(3, 1);
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 3);
});
//verify if layout has changed to 4 viewports
cy.setLayout(2, 2);
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 4);
});
//verify if layout has changed to 6 viewports
cy.setLayout(2, 3);
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 6);
});
cy.setLayout(3, 2);
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 6);
});
//verify if layout has changed to 9 viewports
cy.setLayout(3, 3);
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 9);
});
//verify if layout has changed to 1 viewport
cy.setLayout(1, 1);
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 1);
});
});
it('checks if the available viewport was set to active when layout is decreased', () => {
cy.setLayout(3, 3);
// activate the ninth viewport
cy.get('[data-cy=viewport-container-8]')
.click()
.should('have.class', 'active');
cy.setLayout(1, 1);
// first viewport should be active
cy.get('[data-cy=viewport-container-0]').should('have.class', 'active');
});
it('checks if Clear tool will delete all measurements added in the viewport', () => {
//Add measurements in the viewport
cy.addLengthMeasurement();
cy.addAngleMeasurement();
//Verify if measurement annotation was added into the measurements panel
cy.get('@measurementsBtn').click();
cy.get('[data-cy="data-row"]')
.its('length')
.should('be.at.least', 2);
//Click on More button
cy.get('@moreBtn').click();
//Verify if overlay is displayed
cy.get('.tooltip-toolbar-overlay')
.as('toolbarOverlay')
.should('be.visible');
//Click on Clear button
cy.get('[data-cy="clear"]').click();
//Verify if measurements were removed from the measurements panel
//cy.get('.measurementItem'); //.should('not.exist');
//Close More button overlay
cy.get('@moreBtn').click();
//Close the measurements panel
cy.get('@measurementsBtn').then($btn => {
$btn.click();
cy.get('@measurementsPanel').should('not.be.enabled');
});
});
it('check if Rotate tool will change the image orientation in the viewport', () => {
//Click on More button
cy.get('@moreBtn').click();
//Verify if overlay is displayed
cy.get('.tooltip-toolbar-overlay')
.should('be.visible')
.then(() => {
//Click on Rotate button
cy.get('[data-cy="rotate right"]').click({ force: true });
cy.get('@viewportInfoMidLeft').should('contains.text', 'F');
cy.get('@viewportInfoMidTop').should('contains.text', 'R');
});
//Click on More button to close it
cy.get('@moreBtn').click();
});
it('check if Flip H tool will flip the image horizontally in the viewport', () => {
//Click on More button
cy.get('@moreBtn').click();
//Verify if overlay is displayed
cy.get('.tooltip-toolbar-overlay').should('be.visible');
//Click on Flip H button
cy.get('[data-cy="flip h"]').click();
cy.get('@viewportInfoMidLeft').should('contains.text', 'L');
cy.get('@viewportInfoMidTop').should('contains.text', 'H');
//Click on More button to close it
cy.get('@moreBtn').click();
cy.get('.tooltip-toolbar-overlay').should('not.exist');
});
*/
it('check if Flip tool will flip the image in the viewport', () => {
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
//Click on More button
cy.get('@moreBtnSecondary').click();
//Click on Flip button
cy.get('[data-cy="flipHorizontal"]').click();
cy.waitDicomImage();
cy.get('@viewportInfoMidLeft').should('contains.text', 'L');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
});
// it('checks if stack sync is preserved on new display set and uses FOR', () => {
// // Active stack image sync and reference lines
// cy.get('[data-cy="MoreTools-split-button-secondary"]').click();
// cy.get('[data-cy="ImageSliceSync"]').click();
// // Add reference lines as that sometimes throws an exception
// cy.get('[data-cy="MoreTools-split-button-secondary"]').click();
// cy.get('[data-cy="ReferenceLines"]').click();
// cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
// cy.get('body').type('{downarrow}{downarrow}');
// // Change the layout and double load the first
// cy.setLayout(2, 1);
// cy.get('body').type('{rightarrow}');
// cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
// cy.waitDicomImage();
// // Now navigate down once and check that the left hand pane navigated
// cy.get('body').focus().type('{downarrow}');
// // The following lines assist in troubleshooting when/if this test were to fail.
// cy.get('[data-cy="viewport-pane"]')
// .eq(0)
// .find('[data-cy="viewport-overlay-top-right"]')
// .should('contains.text', 'I:2 (2/20)');
// cy.get('[data-cy="viewport-pane"]')
// .eq(1)
// .find('[data-cy="viewport-overlay-top-right"]')
// .should('contains.text', 'I:2 (2/20)');
// cy.get('body').type('{leftarrow}');
// cy.setLayout(1, 1);
// cy.get('@viewportInfoTopRight').should('contains.text', 'I:2 (2/20)');
// });
});

View File

@@ -0,0 +1,108 @@
describe('OHIF Download Snapshot File', () => {
beforeEach(() => {
cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78');
cy.expectMinimumThumbnails(3);
cy.openDownloadImageModal();
});
it('checks displayed information for Desktop experience', function () {
// Set Desktop resolution
// cy.viewport(1750, 720);
// Visual comparison
// cy.screenshot('Download Image Modal - Desktop experience');
//Check if all elements are displayed
// TODO: need to add this attribute to the modal
cy.get('[data-cy=modal-header]')
.as('downloadImageModal')
.should('contain.text', 'Download High Quality Image');
// Check input fields
// TODO: select2
// cy.get('[data-cy="file-type"]')
// .select('png')
// .should('have.value', 'png')
// .select('jpg')
// .should('have.value', 'jpg');
// Check image preview
cy.get('[data-cy="image-preview"]').should('contain.text', 'Image preview');
//TODO: This is a canvas now, not an img with src
// cy.get('[data-cy="viewport-preview-img"]')
// .should('have.attr', 'src')
// .and('include', 'data:image');
// Check buttons
cy.get('[data-cy="cancel-btn"]').scrollIntoView().should('be.visible');
cy.get('[data-cy="download-btn"]').scrollIntoView().should('be.visible');
cy.get('[data-cy="cancel-btn"]').click();
});
/*it('cancel changes on download modal', function() {
//Change Image Width, Filename and File Type
cy.get('[data-cy="image-width"]')
.clear()
.type('300');
cy.get('[data-cy="image-height"]') //Image Height should be the same as width
.should('have.value', '300');
cy.get('[data-cy="file-name"]')
.clear()
.type('new-filename');
cy.get('[data-cy="file-type"]').select('png');
//Click on Cancel button
cy.get('[data-cy="cancel-btn"]')
.scrollIntoView()
.click();
//Check modal is closed
cy.get('[data-cy="modal"]').should('not.exist');
//Open Modal
cy.openDownloadImageModal();
//Verify default values was restored
cy.get('[data-cy="image-width"]').should('have.value', '512');
cy.get('[data-cy="file-name"]').should('have.value', 'image');
cy.get('[data-cy=file-type]').should('have.value', 'jpg');
});*/
// TO-DO once issue is fixed: https://github.com/OHIF/Viewers/issues/1217
// it('checks error messages for empty fields', function() {
// //Clear fields Image Width and Filename
// cy.get('[data-cy="image-width"]').clear();
// cy.get('[data-cy="file-name"]').clear();
// //Click on Download button
// cy.get('[data-cy="download-btn"]')
// .scrollIntoView()
// .click();
// //Check error message
// });
/*it('checks if "Show Annotations" checkbox will display annotations', function() {
// Close modal that is initially opened
cy.get('[data-cy="close-button"]').click();
// Add measurements in the viewport
cy.addLengthMeasurement();
cy.addAngleMeasurement();
// Open Modal
cy.openDownloadImageModal();
// Select "Show Annotations" option
cy.get('[data-cy="show-annotations"]').check();
// Check image preview
cy.get('[data-cy="image-preview"]').scrollIntoView();
//Compare classes that exists on Image Preview with Annotations and Without Annotation
cy.get('[data-cy="modal-content"]')
.find('canvas')
.should('have.class', 'magnifyTool'); //Class "MagnifyTool" exists with annotations displayed on Image preview
// Uncheck "Show Annotations" option
cy.get('[data-cy="show-annotations"]')
.uncheck()
.wait(300);
// Check that class "MagnifyTool" should not exist
cy.get('[data-cy="modal-content"]')
.find('canvas')
.should('not.have.class', 'magnifyTool');
});*/
});

View File

@@ -0,0 +1,92 @@
describe('OHIF General Viewer', function () {
beforeEach(() =>
cy.initViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78', {
minimumThumbnails: 3,
})
);
it('scrolls series stack using scrollbar', function () {
cy.scrollToIndex(13);
cy.get('@viewportInfoBottomRight').should('contains.text', '14');
});
it('performs right click to zoom', function () {
// This is not used to activate the tool, it is used to ensure the
// top left viewport info shows the zoom values (it only shows up
// when the zoom tool is active)
cy.get('@zoomBtn')
.click()
.then($zoomBtn => {
cy.wrap($zoomBtn).should('have.class', 'bg-primary-light');
});
const zoomLevelInitial = cy.get('@viewportInfoTopLeft').then($viewportInfo => {
return $viewportInfo.text().substring(6, 9);
});
//Right click on viewport
cy.get('@viewport')
.trigger('mousedown', 'top', { buttons: 2 })
.trigger('mousemove', 'center', { buttons: 2 })
.trigger('mouseup');
// make sure the new zoom level is less than the initial
cy.get('@viewportInfoBottomLeft').then($viewportInfo => {
const zoomLevelFinal = $viewportInfo.text().substring(6, 9);
expect(zoomLevelFinal < zoomLevelInitial).to.eq(true);
});
});
/*it('performs middle click to pan', function() {
//Get image position from cornerstone and check if y axis was modified
let cornerstone;
let currentPan;
// TO DO: Replace the cornerstone pan check by Percy snapshot comparison
cy.window()
.its('cornerstone')
.then(c => {
cornerstone = c;
currentPan = () =>
cornerstone.getEnabledElements()[0].viewport.translation;
});
//pan image with middle click
cy.get('@viewport')
.trigger('mousedown', 'center', { buttons: 3 })
.trigger('mousemove', 'bottom', { buttons: 3 })
.trigger('mouseup', 'bottom')
.then(() => {
expect(currentPan().y > 0).to.eq(true);
});
});*/
/*it('opens About modal and verify the displayed information', function() {
cy.get('[data-cy="options-dropdown"]')
.first()
.click();
cy.get('[data-cy="about-modal"]')
.as('aboutOverlay')
.should('be.visible');
//check buttons and links
cy.get('[data-cy="about-modal"]')
.should('contains.text', 'Visit the forum')
.and('contains.text', 'Report an issue')
.and('contains.text', 'https://github.com/OHIF/Viewers/');
//check version number
cy.get('[data-cy="about-modal"]').then($modal => {
cy.get('[data-cy="header-version-info"]').should($headerVersionNumber => {
$headerVersionNumber = $headerVersionNumber.text().substring(1);
expect($modal).to.contain($headerVersionNumber);
});
});
//close modal
cy.get('[data-cy="close-button"]').click();
cy.get('@aboutOverlay').should('not.exist');
});
*/
});

View File

@@ -0,0 +1,218 @@
describe('OHIF Measurement Panel', function () {
beforeEach(function () {
cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78');
cy.expectMinimumThumbnails(3);
cy.initCommonElementsAliases();
cy.initCornerstoneToolsAliases();
cy.waitDicomImage();
});
it('checks if Measurements right panel can be hidden/displayed', function () {
cy.get('@measurementsPanel').should('exist');
cy.get('@measurementsPanel').should('be.visible');
cy.get('@RightCollapseBtn').click();
cy.get('@measurementsPanel').should('not.exist');
cy.get('@RightCollapseBtn').click();
// segmentation panel should be visible
cy.get('@segmentationPanel').should('be.visible');
// measurements panel should be clickable
cy.get('@measurementsBtn').click();
cy.get('@measurementsPanel').should('be.visible');
});
it('checks if measurement item can be Relabeled under Measurements panel', function () {
// Add length measurement
cy.addLengthMeasurement();
cy.get('[data-cy="viewport-notification"]').as('viewportNotification').should('exist');
cy.get('[data-cy="viewport-notification"]').as('viewportNotification').should('be.visible');
cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').as('yesBtn').click();
cy.get('[data-cy="data-row"]').as('measurementItem').click();
cy.get('[data-cy="data-row"]').find('svg').eq(0).as('measurementItemSvg').click();
// enter Bone label
// Todo: move it to the new annotation input with drop down
// cy.get('[data-cy="input-annotation"]').should('exist');
// cy.get('[data-cy="input-annotation"]').should('be.visible');
// cy.get('[data-cy="input-annotation"]').type('Bone{enter}');
// cy.get('[data-cy="data-row"]').as('measurementItem').should('contain.text', 'Bone');
});
it('checks if image would jump when clicked on a measurement item', function () {
cy.get('[data-cy="study-browser-thumbnail"][data-series="1"]').dblclick();
cy.wait(250);
cy.scrollToIndex(0);
// Add length measurement
cy.addLengthMeasurement().wait(250);
cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').as('yesBtn').click();
cy.scrollToIndex(13);
// Reset to default tool so that the new add length works
cy.addLengthMeasurement([100, 100], [200, 200]); //Adding measurement in the viewport
cy.get('@viewportInfoBottomRight').should('contains.text', '(14/');
// Click on first measurement item
cy.get('[data-cy="data-row"]').eq(0).click();
cy.get('@viewportInfoBottomRight').should('contains.text', '(1/');
cy.get('@viewportInfoBottomRight').should('not.contains.text', '(14/');
});
/*
TODO: Not sure why this is failing
it('checks if Description can be added to measurement item under Measurements panel', () => {
cy.addLengthMeasurement(); //Adding measurement in the viewport
cy.get('@measurementsBtn').click();
cy.get('.measurementItem').click();
// Click "Description"
cy.get('.btnAction')
.contains('Description')
.click();
// Enter description text
const descriptionText = 'Adding text for description test';
cy.get('#description').type(descriptionText);
// Confirm
cy.get('.btn-confirm').click();
//Verify if descriptionText was added
cy.get('.measurementLocation').should('contain.text', descriptionText);
// Remove the measurement we just added
cy.get('.btnAction')
.last()
.contains('Delete')
.click()
// Close panel
cy.get('@measurementsBtn').click();
cy.get('@measurementsPanel').should('not.be.enabled');
});
*/
/*it('checks if measurement item can be deleted through the context menu on the viewport', function() {
cy.addLengthMeasurement([100, 100], [200, 100]); //Adding measurement in the viewport
//Right click on measurement annotation
const [x1, y1] = [150, 100];
cy.get('@viewport')
.trigger('mousedown', x1, y1, {
which: 3,
})
.trigger('mouseup', x1, y1, {
which: 3,
})
.wait(300)
.then(() => {
//Contextmenu is visible
cy.get('.ToolContextMenu').should('be.visible');
});
//Click "Delete measurement"
cy.get('.form-action')
.contains('Delete measurement')
.click();
//Open measurements menu
cy.get('@measurementsBtn').click();
//Verify measurements was removed from panel
cy.get('.measurementItem')
.should('not.exist')
.log('Annotation successfully removed');
//Close panel
cy.get('@measurementsBtn').click();
cy.get('@measurementsPanel').should('not.exist');
});*/
/*it('adds relabel and description to measurement item through the context menu on the viewport', function() {
cy.addLengthMeasurement([100, 100], [200, 100]); //Adding measurement in the viewport
// Relabel
// Right click on measurement annotation
const [x1, y1] = [150, 100];
cy.get('@viewport')
.trigger('mousedown', x1, y1, {
which: 3,
})
.trigger('mouseup', x1, y1, {
which: 3,
});
// Contextmenu is visible
cy.get('.ToolContextMenu').should('be.visible');
// Click "Relabel"
cy.get('.form-action')
.contains('Relabel')
.click();
// Search for "Brain"
cy.get('.searchInput').type('Brain');
// Select "Brain" Result
cy.get('.treeInputs > .wrapperLabel')
.contains('Brain')
.click();
// Confirm Selection
cy.get('.checkIconWrapper').click();
// Description
// Right click on measurement annotation
cy.get('@viewport')
.trigger('mousedown', x1, y1, {
which: 3,
})
.trigger('mouseup', x1, y1, {
which: 3,
});
// Contextmenu is visible
cy.get('.ToolContextMenu').should('be.visible');
// Click "Description"
cy.get('.form-action')
.contains('Add Description')
.click();
// Enter description text
const descriptionText = 'Adding text for description test';
cy.get('#description').type(descriptionText);
// Confirm
cy.get('.btn-confirm').click();
//Open measurements menu
cy.get('@measurementsBtn').click();
// Verify if label was added
cy.get('.measurementLocation')
.should('contain.text', 'Brain')
.log('Relabel added with success');
//Verify if descriptionText was added
cy.get('.measurementLocation')
.should('contain.text', descriptionText)
.log('Description added with success');
// Close panel
cy.get('@measurementsBtn').click();
cy.get('@measurementsPanel').should('not.exist');
});*/
});

View File

@@ -0,0 +1,150 @@
/*describe('OHIF Save Measurements', function() {
before(() => {
cy.checkStudyRouteInViewer(
'1.2.840.113619.2.5.1762583153.215519.978957063.78'
);
cy.expectMinimumThumbnails(3);
});
beforeEach(() => {
// Wait image to load on viewport
cy.wait(2000);
cy.resetViewport();
cy.initCommonElementsAliases();
});
it('saves new measurement annotation', function() {
// Add measurement in the viewport
cy.addLengthMeasurement();
// Verify if measurement annotation was added into the measurements panel
cy.get('@measurementsBtn').click();
cy.get('.measurementItem')
.its('length')
.should('be.at.least', 1);
// TODO: Don't save until we're using in-memory data store
// Save new measurement
// cy.get('[data-cy="save-measurements-btn"]').click();
// Verify that success message overlay is displayed
// cy.get('.sb-success')
// .should('be.visible')
// .and('contains.text', 'Measurements saved successfully');
// Visual test comparison
cy.screenshot('Save Measurements - new measurement added');
cy.percyCanvasSnapshot('Save Measurements - new measurement added');
});
// it('retrieves saved measurements', function() {
// // Add measurement in the viewport
// cy.addLengthMeasurement();
// // Verify if measurement annotation was added into the measurements panel
// cy.get('@measurementsBtn').click();
// cy.get('.measurementDisplayText') // Get label size of the recently added measurement
// .last()
// .then($measurementSizeLabel => {
// // Save new measurement
// // TODO: Do not save
// cy.get('[data-cy="save-measurements-btn"]')
// .click()
// .then(() => {
// // Verify that success message overlay is displayed
// cy.get('.sb-success').should('be.visible');
// });
// // Reload the page
// cy.reload()
// .wait(1000) //Wait page to load
// .expectMinimumThumbnails(2); //wait all thumbnails to load
// // Verify that recently added measurement was retrieved
// cy.get('@measurementsBtn').click();
// cy.get('.measurementDisplayText') // Get label size of the recently added measurement
// .last()
// .then($retrivedMeasurementSizeLabel => {
// expect($retrivedMeasurementSizeLabel.textContent).to.eq(
// $measurementSizeLabel.textContent
// );
// });
// });
// });
// it('checks error message when saving without any measurement', function() {
// // Checks that measurement list is empty
// cy.get('.numberOfItems').should('have.text', '0');
// // Click on Save Measurement button
// cy.get('[data-cy="save-measurements-btn"]').click();
// // Verify that error message overlay is displayed
// cy.get('.sb-error')
// .should('be.visible')
// .and('contains.text', 'Error while saving the measurements');
// // Close message overlay
// cy.get('.sb-closeIcon').click();
// });
it('checks if warning message is displayed on measurements of unsupported tools', function() {
// Add measurement for unsupported tool in the viewport
cy.addAngleMeasurement();
// Verify if measurement annotation was added into the measurements panel
cy.get('@measurementsBtn').click();
cy.get('.measurementItem')
.its('length')
.should('be.at.least', 1);
// Check that warning is displayed for unsupported tool
cy.get('.hasWarnings').should('be.visible');
// // Save new measurement
// cy.get('[data-cy="save-measurements-btn"]').click();
// // Verify that error message overlay is displayed
// cy.get('.sb-error')
// .should('be.visible')
// .and('contains.text', 'Error while saving the measurements');
// Close Measurements panel
cy.get('@measurementsBtn').click();
});
/*it('checks if measurements of unsupported tools were not saved', function() {
// Add measurement for supported tool in the viewport
cy.addLengthMeasurement();
// Add measurement for unsupported tool in the viewport
cy.addAngleMeasurement();
// Verify if measurement annotation was added into the measurements panel
cy.get('@measurementsBtn').click();
cy.get('.measurementItem')
.its('length')
.should('be.eq', 2);
// Check that warning is displayed for unsupported tool
cy.get('.hasWarnings').should('be.visible');
// Save new measurement
cy.get('[data-cy="save-measurements-btn"]').click();
// Verify that success message overlay is displayed
cy.get('.sb-success')
.should('be.visible')
.and('contains.text', 'Measurements saved successfully');
// Reload the page
cy.reload()
.wait(1000) //Wait page to load
.expectMinimumThumbnails(2); //wait all thumbnails to load
//Verify that measurement for unsupported tool was not saved
cy.get('@measurementsBtn').click();
cy.get('.measurementItem')
.its('length')
.should('be.eq', 1);
// Close Measurements panel
cy.get('@measurementsBtn').click();
});
});*/

View File

@@ -0,0 +1,60 @@
describe('OHIF Study Browser', function () {
beforeEach(function () {
cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78');
cy.expectMinimumThumbnails(3);
cy.initCommonElementsAliases();
cy.initCornerstoneToolsAliases();
});
it('checks if series thumbnails are being displayed', function () {
cy.get('[data-cy="study-browser-thumbnail"]').its('length').should('be.gt', 1);
});
it('drags and drop a series thumbnail into viewport', function () {
// Can't use the native drag version as the element should be rerendered
// cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)') //element to be dragged
// .drag('.cornerstone-canvas'); //dropzone element
const dataTransfer = new DataTransfer();
cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').as('seriesThumbnail');
cy.get('@seriesThumbnail')
.first()
.trigger('mousedown', { which: 1, button: 0 })
.trigger('dragstart', { dataTransfer })
.trigger('drag', {});
cy.get('.cornerstone-canvas').as('viewport');
cy.get('@viewport')
.trigger('mousemove', 'center')
.trigger('dragover', { dataTransfer, force: true })
.trigger('drop', { dataTransfer, force: true });
//const expectedText =
// 'Ser: 2Img: 1 1/13512 x 512Loc: -17.60 mm Thick: 3.00 mm';
//cy.get('@viewportInfoBottomLeft').should('contain.text', expectedText);
});
it('checks if Series left panel can be hidden/displayed', function () {
cy.get('@seriesPanel').should('exist');
cy.get('@seriesPanel').should('be.visible');
cy.get('@seriesBtn').click();
cy.get('@seriesPanel').should('not.exist');
cy.get('@seriesBtn').click();
cy.get('@seriesPanel').should('exist');
cy.get('@seriesPanel').should('be.visible');
});
it('performs double-click to load thumbnail in active viewport', () => {
// Have to finish rendering the image before this works
cy.wait(350);
cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
//cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText);
});
});

View File

@@ -0,0 +1,184 @@
//We are keeping the hardcoded results values for the study list tests
//this is intended to be running in a controlled docker environment with test data.
describe('OHIF Study List', function () {
context('Desktop resolution', function () {
beforeEach(function () {
Cypress.on('uncaught:exception', () => false);
cy.window().then(win => win.sessionStorage.clear());
cy.openStudyList();
cy.viewport(1750, 720);
cy.initStudyListAliasesOnDesktop();
//Clear all text fields
cy.get('@PatientName').clear();
cy.get('@MRN').clear();
cy.get('@AccessionNumber').clear();
cy.get('@StudyDescription').clear();
});
afterEach(function () {
cy.window().then(win => win.sessionStorage.clear());
});
it('Displays several studies initially', function () {
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.greaterThan(1);
expect($list).to.contain('Juno');
expect($list).to.contain('832040');
});
});
it('searches Patient Name with exact string', function () {
cy.get('@PatientName').type('Juno');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('Juno');
});
});
it('maintains Patient Name filter upon return from viewer', function () {
cy.get('@PatientName').type('Juno');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('Juno');
});
});
it('searches MRN with exact string', function () {
cy.get('@MRN').type('0000003');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('0000003');
});
});
it('maintains MRN filter upon return from viewer', function () {
cy.get('@MRN').type('0000003');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('0000003');
});
});
it('searches Accession with exact string', function () {
cy.get('@AccessionNumber').type('321');
//Wait result list to be displayed
cy.waitStudyList();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('321');
});
});
it('maintains Accession filter upon return from viewer', function () {
cy.get('@AccessionNumber').type('0000155811');
//Wait result list to be displayed
cy.waitStudyList();
cy.wait(2000);
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('0000155811');
});
});
it('searches Description with exact string', function () {
cy.get('@StudyDescription').type('PETCT');
//Wait result list to be displayed
cy.waitStudyList();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('PETCT');
});
});
it('maintains Description filter upon return from viewer', function () {
cy.get('@StudyDescription').type('PETCT');
//Wait result list to be displayed
cy.waitStudyList();
cy.wait(2000);
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.wait(2000);
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('PETCT');
});
});
/* Todo: fix react select
it('searches Modality with camel case', function() {
cy.get('@modalities').type('Ct');
// Wait result list to be displayed
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.greaterThan(1);
expect($list).to.contain('CT');
});
});
it('changes Rows per page and checks the study count', function() {
//Show Rows per page options
const pageRows = [25, 50, 100];
//Check all options of Rows
pageRows.forEach(numRows => {
cy.get('select').select(numRows.toString()); //Select Rows per page option
//Wait result list to be displayed
cy.waitStudyList().then(() => {
//Compare the search result with the Study Count on the table header
cy.get('@numStudies')
.should(numStudies => {
expect(parseInt(numStudies.text())).to.be.at.most(numRows); //less than or equals to
})
.then(numStudies => {
//Compare to the number of Rows in the search result
cy.get('@searchResult2').then($searchResult => {
let countResults = $searchResult.length;
expect(numStudies.text()).to.be.eq(countResults.toString());
});
});
});
});
});
*/
});
});

View File

@@ -0,0 +1,757 @@
/*describe('OHIF User Preferences', () => {
context('Study List Page', function() {
before(() => {
cy.visit('/');
});
beforeEach(() => {
// Open User Preferences modal
cy.openPreferences();
});
it('checks displayed information on User Preferences modal', function() {
cy.initPreferencesModalAliases();
//Check Title
cy.get('@preferencesModal').should('contain.text', 'User Preferences');
//Check tabs
cy.get('@userPreferencesHotkeysTab')
.should('have.text', 'Hotkeys')
.and('have.class', 'active');
cy.get('@userPreferencesGeneralTab').should('have.text', 'General');
cy.get('@userPreferencesWindowLevelTab').should(
'have.text',
'Window Level'
);
//Check buttons
cy.get('@restoreBtn')
.scrollIntoView()
.should('have.text', 'Reset to Defaults');
cy.get('@cancelBtn').should('have.text', 'Cancel');
cy.get('@saveBtn').should('have.text', 'Save');
cy.get('[data-cy="close-button"]').click();
});
it('checks translation by selecting Spanish language', function() {
cy.selectPreferencesTab('@userPreferencesGeneralTab');
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
// Set language to Spanish and save
cy.setLanguage('Spanish');
// Header should be translated to Spanish
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'SOLO USO PARA INVESTIGACIÓN');
// Options menu should be translated
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Opciones')
.click();
cy.get('[data-cy="dd-item-menu"]')
.first()
.should('contain.text', 'Acerca de');
cy.get('[data-cy="dd-item-menu"]')
.last()
.should('contain.text', 'Preferencias');
// Close Options menu
cy.get('[data-cy="options-menu"]').click();
});
it('checks if user can cancel the language selection and application will be in "English (USA)"', function() {
// Set language to English and save
cy.setLanguage('English (USA)');
// Set language to Spanish and cancel
cy.setLanguage('Spanish', false);
// Header should be kept in "English (USA)"
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'INVESTIGATIONAL USE ONLY');
// Options menu should be translated
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Options')
.click();
cy.get('[data-cy="dd-item-menu"]')
.first()
.should('contain.text', 'About');
cy.get('[data-cy="dd-item-menu"]')
.last()
.should('contain.text', 'Preferences');
// Close Options menu
cy.get('[data-cy="options-menu"]').click();
});
it('checks if user can restore to default the language selection and application will be in "English (USA)"', function() {
// Set language to Spanish
cy.setLanguage('Spanish');
//Open Preferences again
cy.openPreferences();
// Go to general tab
cy.selectPreferencesTab('@userPreferencesGeneralTab');
cy.get('@restoreBtn')
.scrollIntoView()
.click();
// Close Success Message overlay (if displayed)
cy.get('body').then(body => {
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').click({ force: true });
}
// click on save button
cy.get('@saveBtn')
.scrollIntoView()
.click();
});
// Header should be in "English (USA)"
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'INVESTIGATIONAL USE ONLY');
// Options menu should be in "English (USA)"
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Options')
.click();
cy.get('[data-cy="dd-item-menu"]')
.first()
.should('contain.text', 'About');
cy.get('[data-cy="dd-item-menu"]')
.last()
.should('contain.text', 'Preferences');
// Close options Menu
cy.get('[data-cy="options-menu"]').click();
});
it('checks if W/L Preferences table is being displayed in the Window Level tab', function() {
//Navigate to Window Level tab
cy.selectPreferencesTab('@userPreferencesWindowLevelTab');
//Check table header
cy.get('.wlRow.header')
.should('contains.text', 'Preset')
.and('contains.text', 'Description')
.and('contains.text', 'Window')
.and('contains.text', 'Level');
//Check table has more than 1 row (more than header)
cy.get('.wlRow')
.its('length')
.should('be.greaterThan', 1);
});
it('checks if Preferences set in Study List Page will be consistent on Viewer Page', function() {
// Go go hotkeys tab
cy.selectPreferencesTab('@userPreferencesHotkeysTab');
// Set new hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal('Rotate Right', '{shift}Q');
// Close Success Message overlay (if displayed)
cy.get('body').then(body => {
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').click({ force: true });
}
// click on save button
cy.get('@saveBtn')
.scrollIntoView()
.click();
});
// Open User Preferences modal again
cy.openPreferences();
// Go to General tab
cy.selectPreferencesTab('@userPreferencesGeneralTab');
// Set language to Spanish
cy.setLanguage('Spanish');
// Go to Study Viewer page
cy.checkStudyRouteInViewer(
'1.2.840.113619.2.5.1762583153.215519.978957063.78'
);
cy.expectMinimumThumbnails(3);
cy.initCommonElementsAliases();
// Check if application is in Spanish
// Header should be translated to Spanish
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'SOLO USO PARA INVESTIGACIÓN');
// Options menu should be translated
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Opciones')
.click();
cy.get('[data-cy="dd-item-menu"]')
.first()
.should('contain.text', 'Acerca de');
cy.get('[data-cy="dd-item-menu"]')
.last()
.should('contain.text', 'Preferencias');
// Check if new hotkey is working on viewport
cy.get('body').type('{shift}Q', {
release: false,
});
cy.get('@viewportInfoMidTop').should('contains.text', 'R');
});
});
context('Study Viewer Page', function() {
before(() => {
cy.checkStudyRouteInViewer(
'1.2.840.113619.2.5.1762583153.215519.978957063.78'
);
cy.expectMinimumThumbnails(3);
});
beforeEach(() => {
cy.initCommonElementsAliases();
cy.resetViewport();
cy.resetUserHotkeyPreferences();
cy.resetUserGeneralPreferences();
// Open User Preferences modal
cy.openPreferences();
});
afterEach(() => {
// Close User Preferences Modal (if displayed)
cy.get('body').then(body => {
if (body.find('.OHIFModal__header').length > 0) {
cy.get('[data-cy="close-button"]').click({ force: true });
}
});
});
it('checks displayed information on User Preferences modal', function() {
cy.get('@preferencesModal').should('contain.text', 'User Preferences');
cy.get('@userPreferencesHotkeysTab')
.should('have.text', 'Hotkeys')
.and('have.class', 'active');
cy.get('@userPreferencesGeneralTab').should('have.text', 'General');
cy.get('@userPreferencesWindowLevelTab').should(
'have.text',
'Window Level'
);
cy.get('@restoreBtn')
.scrollIntoView()
.should('have.text', 'Reset to Defaults');
cy.get('@cancelBtn').should('have.text', 'Cancel');
cy.get('@saveBtn').should('have.text', 'Save');
});
it('checks translation by selecting Spanish language', function() {
cy.selectPreferencesTab('@userPreferencesGeneralTab');
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
// Set language to Spanish
cy.setLanguage('Spanish');
// Header should be translated to Spanish
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'SOLO USO PARA INVESTIGACIÓN');
// Options menu should be translated
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Opciones')
.click();
cy.get('[data-cy="dd-item-menu"]')
.first()
.should('contain.text', 'Acerca de');
cy.get('[data-cy="dd-item-menu"]')
.last()
.should('contain.text', 'Preferencias');
});
it('checks if user can cancel the language selection and application will be in "English (USA)"', function() {
// Set language to English and save
cy.setLanguage('English (USA)');
// Set language to Spanish and cancel
cy.setLanguage('Spanish', false);
// Header should be kept in "English (USA)"
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'INVESTIGATIONAL USE ONLY');
// Options menu should be translated
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Options')
.click();
cy.get('[data-cy="dd-item-menu"]')
.first()
.should('contain.text', 'About');
cy.get('[data-cy="dd-item-menu"]')
.last()
.should('contain.text', 'Preferences');
});
it('checks if user can restore to default the language selection and application will be in "English (USA)', function() {
cy.selectPreferencesTab('@userPreferencesGeneralTab');
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
// Set language to Spanish
cy.setLanguage('Spanish');
// Open User Preferences modal
cy.openPreferences();
// Go to general tab
cy.selectPreferencesTab('@userPreferencesGeneralTab');
// click on restore button
cy.get('@restoreBtn')
.scrollIntoView()
.click();
// click on save button
cy.get('@saveBtn')
.scrollIntoView()
.click();
// Header should be in "English (USA)""
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'INVESTIGATIONAL USE ONLY');
// Options menu should be in "English (USA)"
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Options')
.click();
cy.get('[data-cy="dd-item-menu"]')
.first()
.should('contain.text', 'About');
cy.get('[data-cy="dd-item-menu"]')
.last()
.should('contain.text', 'Preferences');
});
it('checks new hotkeys for "Rotate Right" and "Rotate Left"', function() {
// Go go hotkeys tab
cy.selectPreferencesTab('@userPreferencesHotkeysTab');
// Set new hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Rotate Right',
'{shift}{rightarrow}'
);
// Set new hotkey for 'Rotate Left' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Rotate Left',
'{shift}{leftarrow}'
);
// Close Success Message overlay (if displayed)
cy.get('body').then(body => {
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').click({ force: true });
}
// click on save button
cy.get('@saveBtn')
.scrollIntoView()
.click();
});
//Rotate Right with new Hotkey
cy.get('body').type('{shift}{rightarrow}');
cy.get('@viewportInfoMidTop').should('contains.text', 'R');
//Rotate Left with new Hotkey
cy.get('body').type('{shift}{leftarrow}');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
});
it('checks new hotkeys for "Next" and "Previous" Image on Viewport', function() {
// Update hotkeys for 'Next/Previous Viewport'
cy.selectPreferencesTab('@userPreferencesHotkeysTab');
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Next Viewport',
'{shift}{rightarrow}'
);
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Previous Viewport',
'{shift}{leftarrow}'
);
// Close Success Message overlay (if displayed)
cy.get('body').then(body => {
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').click({ force: true });
}
// click on save button
cy.get('@saveBtn')
.scrollIntoView()
.click();
});
// Set 3 viewports layout
cy.setLayout(3, 1);
cy.waitViewportImageLoading();
// Reset, Rotate Right and Invert colors on Viewport #1
cy.get('body').type(' ');
cy.get('body').type('r');
cy.get('body').type('i');
// Shift active viewport to next
// Reset, Rotate Left and Invert colors on Viewport #2
cy.get('body').type('{shift}{rightarrow}');
cy.get('body').type(' ');
cy.get('body').type('l');
cy.get('body').type('i');
// Verify 1st viewport was rotated
cy.get('@viewportInfoMidTop').should('contains.text', 'R');
// Verify 2nd viewport was rotated
cy.get(
':nth-child(2) > .viewport-wrapper > .viewport-element > .ViewportOrientationMarkers.noselect > .top-mid.orientation-marker'
).as('viewport2InfoMidTop');
cy.get('@viewport2InfoMidTop').should('contains.text', 'P');
//Move to Previous Viewport
cy.get('body').type('{shift}{leftarrow}');
// Reset viewport #1 with spacebar hotkey
cy.get('body').type(' ');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
// Set 1 viewport layout
cy.setLayout(1, 1);
});
it('checks error message when duplicated hotkeys are inserted', function() {
// Go go hotkeys tab
cy.selectPreferencesTab('@userPreferencesHotkeysTab');
// Set duplicated hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal('Rotate Right', '{i}');
// Check error message
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Rotate Right') // label we're looking for
.parent()
.find('.preferencesInputErrorMessage')
.as('errorMsg')
.should('have.text', '"Invert" is already using the "i" shortcut.');
});
});
it('checks error message when invalid hotkey is inserted', function() {
// Go go hotkeys tab
cy.selectPreferencesTab('@userPreferencesHotkeysTab');
// Set invalid hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal('Rotate Right', '{ctrl}Z');
// Check error message
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Rotate Right') // label we're looking for
.parent()
.find('.preferencesInputErrorMessage')
.as('errorMsg')
.should('have.text', '"ctrl+z" shortcut combination is not allowed');
});
});
it('checks error message when only modifier keys are inserted', function() {
// Go go hotkeys tab
cy.selectPreferencesTab('@userPreferencesHotkeysTab');
// Set invalid modifier key: ctrl
cy.setNewHotkeyShortcutOnUserPreferencesModal('Zoom Out', '{ctrl}');
// Check error message
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Zoom Out') // label we're looking for
.parent()
.find('.preferencesInputErrorMessage')
.as('errorMsg')
.should(
'have.text',
"It's not possible to define only modifier keys (ctrl, alt and shift) as a shortcut"
);
});
// Set invalid modifier key: shift
cy.setNewHotkeyShortcutOnUserPreferencesModal('Zoom Out', '{shift}');
// Check error message
cy.get('@errorMsg').should(
'have.text',
"It's not possible to define only modifier keys (ctrl, alt and shift) as a shortcut"
);
// Set invalid modifier key: alt
cy.setNewHotkeyShortcutOnUserPreferencesModal('Zoom Out', '{alt}');
// Check error message
cy.get('@errorMsg').should(
'have.text',
"It's not possible to define only modifier keys (ctrl, alt and shift) as a shortcut"
);
});
it('checks if user can cancel changes made on User Preferences Hotkeys tab', function() {
// Go go hotkeys tab
cy.selectPreferencesTab('@userPreferencesHotkeysTab');
// Set new hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Rotate Right',
'{ctrl}{shift}S'
);
// Close Success Message overlay (if displayed)
cy.get('body').then(body => {
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').click({ force: true });
}
//Cancel hotkeys
cy.get('@cancelBtn')
.scrollIntoView()
.click();
});
// Open User Preferences modal again
cy.openPreferences();
//Check that hotkey for 'Rotate Right' function was not changed
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Rotate Right') // label we're looking for
.parent()
.find('input')
.should('have.value', 'r');
});
});
it('checks if user can reset to default values on User Preferences Hotkeys tab', function() {
// Go go hotkeys tab
cy.selectPreferencesTab('@userPreferencesHotkeysTab');
// Set new hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Rotate Right',
'{ctrl}{shift}S'
);
// click on save button
cy.get('@saveBtn')
.scrollIntoView()
.click();
// Open User Preferences modal again
cy.openPreferences();
//Restore Default hotkeys
cy.get('@restoreBtn')
.scrollIntoView()
.click();
//Check that hotkey for 'Rotate Right' function was not changed
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Rotate Right') // label we're looking for
.parent()
.find('input')
.should('have.value', 'r');
});
});
});
context('W/L Preset Preferences', function() {
before(() => {
cy.checkStudyRouteInViewer(
'1.2.840.113619.2.5.1762583153.215519.978957063.78'
);
cy.expectMinimumThumbnails(3);
});
beforeEach(() => {
cy.initCommonElementsAliases();
// Open User Preferences modal
cy.openPreferences();
// Navigate to Window Level tab
cy.selectPreferencesTab('@userPreferencesWindowLevelTab');
});
it('checks if W/L Preferences table is being displayed in the Window Level tab', function() {
//Check table header
cy.get('.wlRow.header')
.should('contains.text', 'Preset')
.and('contains.text', 'Description')
.and('contains.text', 'Window')
.and('contains.text', 'Level');
//Check table has more than 1 row (more than header)
cy.get('.wlRow')
.its('length')
.should('be.greaterThan', 1);
});
// //TODO: Test blocked by issue #1551: https://github.com/OHIF/Viewers/issues/1551
// it('checks if user can add a new W/L preset', function() {
// let description = ':nth-child(8) > .description > .preferencesInput';
// let window = ':nth-child(8) > .window > .preferencesInput';
// let level = ':nth-child(8) > .level > .preferencesInput';
// let new_window_value = 150;
// let new_level_value = -600;
// // Check existing preset values
// cy.get(description).should('have.value', '');
// cy.get(window).should('have.value', '');
// cy.get(level).should('have.value', '');
// // Set new preset value
// cy.setWindowLevelPreset(
// 7,
// 'New Description',
// new_window_value,
// new_level_value
// );
// cy.get('@saveBtn').click();
// // Open User Preferences modal
// cy.openPreferences();
// // Navigate to Window Level tab
// cy.selectPreferencesTab('@userPreferencesWindowLevelTab');
// // Check recently added preset values
// cy.get(description).should('have.value', 'New Description');
// cy.get(window).should('have.value', new_window_value);
// cy.get(level).should('have.value', new_level_value);
// // Close User Preferences modal
// cy.get('[data-cy="close-button"]').click();
// // Check if new hotkey preset is working on viewport
// cy.get('body').type('8');
// cy.get('@viewportInfoBottomRight').should(
// 'contains.text',
// 'W: ' + new_window_value + ' L: ' + new_level_value
// );
// });
it('checks if user can remove an existing W/L preset', function() {
let description = ':nth-child(3) > .description > .preferencesInput';
let window = ':nth-child(3) > .window > .preferencesInput';
let level = ':nth-child(3) > .level > .preferencesInput';
// Check existing preset values
cy.get(description)
.should('not.have.value', '')
.clear();
cy.get(window)
.should('not.have.value', '')
.clear();
cy.get(level)
.should('not.have.value', '')
.clear();
// Save changes
cy.get('@saveBtn').click();
// Open User Preferences modal
cy.openPreferences();
// Navigate to Window Level tab
cy.selectPreferencesTab('@userPreferencesWindowLevelTab');
// Check recently added preset values
cy.get(description).should('have.value', '');
cy.get(window).should('have.value', '');
cy.get(level).should('have.value', '');
// Close User Preferences modal
cy.get('[data-cy="close-button"]').click();
});
// //TODO: Test blocked by issue #1551: https://github.com/OHIF/Viewers/issues/1551
// it('checks if user can edit an existing W/L preset', function() {
// let description = ':nth-child(2) > .description > .preferencesInput';
// let window = ':nth-child(2) > .window > .preferencesInput';
// let level = ':nth-child(2) > .level > .preferencesInput';
// // Check existing preset values
// cy.get(description).should('have.value', 'Soft tissue');
// cy.get(window).should('have.value', '550');
// cy.get(level).should('have.value', '40');
// // Set new preset value
// cy.setWindowLevelPreset(1, 'Soft tissue New Description', 1220, 333);
// cy.get('@saveBtn').click();
// // Open User Preferences modal
// cy.openPreferences();
// // Navigate to Window Level tab
// cy.selectPreferencesTab('@userPreferencesWindowLevelTab');
// // Check recently added preset values
// cy.get(description).should('have.value', 'Soft tissue New Description');
// cy.get(window).should('have.value', '1220');
// cy.get(level).should('have.value', '333');
// });
it('checks if user can change the W/L by triggering different hotkeys with W/L presets', function() {
// Close User Preferences modal
cy.get('[data-cy="close-button"]').click();
// Check if hotkey preset is working on viewport
cy.get('body').type('3');
cy.get('@viewportInfoBottomRight').should(
'contains.text',
'W: 150 L: 90'
);
// Check if hotkey preset is working on viewport
cy.get('body').type('4');
cy.get('@viewportInfoBottomRight').should(
'contains.text',
'W: 2500 L: 480'
);
});
it('checks if user can change the W/L by triggering different hotkeys with W/L presets on multiple viewports', function() {
// Close User Preferences modal
cy.get('[data-cy="close-button"]').click();
// Set 3 viewports layout
cy.setLayout(3, 1);
cy.waitViewportImageLoading();
// Check if hotkey preset is working on viewport
cy.get('body').type('3');
cy.get('@viewportInfoBottomRight').should(
'contains.text',
'W: 150 L: 90'
);
// Overlay information from 2nd viewport
let second_viewport_overlay =
'div:nth-child(2) > div > div.viewport-element > div.ViewportOverlay > div.bottom-right.overlay-element > div';
// Shift active viewport to Viewport #2
cy.get('body').type('{rightarrow}');
// Check if hotkey preset is working on viewport #2
cy.get('body').type('4');
cy.get(second_viewport_overlay).should('contains.text', 'W: 2500 L: 480');
// Set 1 viewport layout
cy.setLayout(1, 1);
});
});
});*/

View File

@@ -0,0 +1,103 @@
describe('OHIF MPR', () => {
beforeEach(() => {
cy.checkStudyRouteInViewer('1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1');
cy.expectMinimumThumbnails(3);
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
});
it('should not go MPR for non reconstructible displaySets', () => {
cy.get('[data-cy="MPR"]').should('have.class', 'ohif-disabled');
});
it('should go MPR for reconstructible displaySets and come back', () => {
cy.wait(250);
cy.get('[data-cy="study-browser-thumbnail"][data-series="4"]').dblclick();
cy.wait(250);
cy.get('[data-cy="MPR"]').click();
cy.get('.cornerstone-canvas').should('have.length', 3);
cy.get('[data-cy="MPR"]').click();
cy.get('.cornerstone-canvas').should('have.length', 1);
});
it('should render correctly the MPR', () => {
cy.wait(250);
cy.get('[data-cy="study-browser-thumbnail"][data-series="4"]').dblclick();
cy.wait(250);
cy.get('[data-cy="MPR"]').click();
cy.get('.cornerstone-canvas').should('have.length', 3);
// check cornerstone to see if each has images
// we can later do visual testing to match the images with a baseline
cy.window()
.its('cornerstone')
.then(cornerstone => {
const viewports = cornerstone.getRenderingEngines()[0].getViewports();
// The stack viewport still exists after the changes to viewportId and inde
const imageData1 = viewports[0].getImageData();
const imageData2 = viewports[1].getImageData();
const imageData3 = viewports[2].getImageData();
// for some reason map doesn't work here
cy.wrap(imageData1).should('not.be', undefined);
cy.wrap(imageData2).should('not.be', undefined);
cy.wrap(imageData3).should('not.be', undefined);
cy.wrap(imageData1.dimensions).should('deep.equal', imageData2.dimensions);
cy.wrap(imageData1.origin).should('deep.equal', imageData2.origin);
});
cy.get('[data-cy="MPR"]').click();
cy.get('.cornerstone-canvas').should('have.length', 1);
});
it('should correctly render Crosshairs for MPR', () => {
cy.get('[data-cy="study-browser-thumbnail"][data-series="4"]').dblclick();
cy.get('[data-cy="MPR"]').click();
cy.get('[data-cy="Crosshairs"]').click();
cy.wait(250);
// check cornerstone to see if each has crosshairs
// we can later do visual testing to match the images with a baseline
cy.window()
.its('cornerstoneTools')
.then(cornerstoneTools => {
const state = cornerstoneTools.annotation.state.getAnnotationManager();
const fORMap = state.annotations;
const fOR = Object.keys(fORMap)[0];
const fORAnnotation = fORMap[fOR];
// it should have crosshairs as the only key (references lines make this 2)
expect(Object.keys(fORAnnotation)).to.have.length(2);
const crosshairs = fORAnnotation.Crosshairs;
// it should have three
expect(crosshairs).to.have.length(3);
expect(crosshairs[0].data.handles.toolCenter).to.deep.equal(
crosshairs[1].data.handles.toolCenter
);
});
});
it('should activate window level when the active Crosshairs tool for MPR is clicked', () => {
cy.get('[data-cy="study-browser-thumbnail"][data-series="4"]').dblclick();
cy.get('[data-cy="MPR"]').click();
cy.get('[data-cy="Crosshairs"]').click();
// Click the crosshairs button to deactivate it.
cy.get('[data-cy="Crosshairs"]').click();
});
});

View File

@@ -0,0 +1,21 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
};

View File

@@ -0,0 +1 @@
test-output.xml

View File

@@ -0,0 +1,70 @@
const dataTransfer = new DataTransfer();
export const DragSimulator = {
MAX_TRIES: 1,
DELAY_INTERVAL_MS: 10,
counter: 0,
rectsEqual(r1, r2) {
return (
r1.top === r2.top && r1.right === r2.right && r1.bottom === r2.bottom && r1.left === r2.left
);
},
get dropped() {
const currentSourcePosition = this.source.getBoundingClientRect();
return !this.rectsEqual(this.initialSourcePosition, currentSourcePosition);
},
get hasTriesLeft() {
return this.counter < this.MAX_TRIES;
},
dragstart() {
cy.log('**DRAG START**');
cy.wrap(this.source)
.trigger('mousedown', { which: 1, button: 0 })
.trigger('dragstart', { dataTransfer })
.trigger('drag', {});
},
drop() {
cy.log('**DROP**');
return cy
.wrap(this.target)
.trigger('mousemove', 'center')
.trigger('dragover', { dataTransfer, force: true })
.trigger('drop', { dataTransfer, force: true })
.trigger('dragend', { dataTransfer })
.trigger('mouseup', { which: 1, button: 0 });
},
dragover() {
cy.log('**DRAGOVER**');
if (!this.dropped && this.hasTriesLeft) {
this.counter += 1;
return cy
.wrap(this.target)
.trigger('mousemove', 'center')
.trigger('dragover', {
dataTransfer,
position: this.position,
})
.wait(this.DELAY_INTERVAL_MS)
.then(() => this.dragover());
}
return this.drop().then(() => true);
},
init(source, target, position) {
this.source = source;
this.target = target;
this.position = position;
this.counter = 0;
this.dragstart();
return cy.wait(this.DELAY_INTERVAL_MS).then(() => {
this.initialSourcePosition = this.source.getBoundingClientRect();
return this.dragover();
});
},
simulate(sourceWrapper, targetSelector, position = 'center') {
return cy
.get(targetSelector)
.then(targetWrapper => this.init(sourceWrapper.get(0), targetWrapper.get(0), position));
},
};

View File

@@ -0,0 +1,93 @@
//Creating aliases for Cornerstone tools buttons
export function initCornerstoneToolsAliases() {
cy.get('[data-cy="StackScroll"]').as('stackScrollBtn');
cy.get('[data-cy="Zoom"]').as('zoomBtn');
cy.get('[data-cy="WindowLevel-split-button-primary"]').as('wwwcBtnPrimary');
cy.get('[data-cy="WindowLevel-split-button-secondary"]').as('wwwcBtnSecondary');
cy.get('[data-cy="Pan"]').as('panBtn');
cy.get('[data-cy="MeasurementTools-split-button-primary"]').as('measurementToolsBtnPrimary');
cy.get('[data-cy="MeasurementTools-split-button-secondary"]').as('measurementToolsBtnSecondary');
// cy.get('[data-cy="Angle"]').as('angleBtn');
cy.get('[data-cy="MoreTools-split-button-primary"]').as('moreBtnPrimary');
cy.get('[data-cy="MoreTools-split-button-secondary"]').as('moreBtnSecondary');
cy.get('[data-cy="Layout"]').as('layoutBtn');
cy.get('.cornerstone-viewport-element').as('viewport');
}
//Creating aliases for Common page elements
export function initCommonElementsAliases(skipMarkers) {
cy.get('[data-cy="trackedMeasurements-btn"]').as('measurementsBtn');
cy.get('.cornerstone-viewport-element').as('viewport');
cy.get('[data-cy="seriesList-btn"]').as('seriesBtn');
cy.get('[data-cy="side-panel-header-right"]').as('RightCollapseBtn');
cy.get('[data-cy="side-panel-header-left"]').as('LeftCollapseBtn');
// click on the measurements button
cy.get('[data-cy="trackedMeasurements-btn"]').click();
// TODO: Panels are not in DOM when closed, move this somewhere else
cy.get('[data-cy="trackedMeasurements-panel"]').as('measurementsPanel');
cy.get('[data-cy="panelSegmentation-btn"]').as('segmentationPanel');
cy.get('[data-cy="studyBrowser-panel"]').as('seriesPanel');
cy.get('[data-cy="viewport-overlay-top-right"]').as('viewportInfoTopRight');
cy.get('[data-cy="viewport-overlay-top-left"]').as('viewportInfoTopLeft');
cy.get('[data-cy="viewport-overlay-bottom-right"]').as('viewportInfoBottomRight');
cy.get('[data-cy="viewport-overlay-bottom-left"]').as('viewportInfoBottomLeft');
console.debug('🚀 ~ skipMarkers:', skipMarkers);
if (skipMarkers) {
return;
}
try {
cy.get('.left-mid.orientation-marker')?.as('viewportInfoMidLeft');
cy.get('.top-mid.orientation-marker')?.as('viewportInfoMidTop');
} catch (error) {
console.log('Error: ', error);
}
}
//Creating aliases for Routes
export function initRouteAliases() {
cy.intercept('GET', '**/series**', { statusCode: 200, body: [] }).as('getStudySeries');
// Todo: for some reason cypress does not redirect to the correct url
// so we intercept the request and redirect it to the correct url
cy.intercept('/studies?limit*', req => {
const url = req.url.replace(/\/studies\?/, '/studies/?limit');
req.url = url;
});
}
//Creating aliases for Study List page elements on Desktop experience
export function initStudyListAliasesOnDesktop() {
cy.get('[data-cy="num-studies"]').as('numStudies');
cy.get('[data-cy="input-patientName"]').as('PatientName');
cy.get('[data-cy="input-mrn"]').as('MRN');
cy.get('[data-cy="input-accession"]').as('AccessionNumber');
cy.get('[data-cy="input-description"]').as('StudyDescription');
cy.get('[data-cy="study-list-results"]').as('searchResult');
cy.get('[data-cy="study-list-results"] > tr').as('searchResult2');
// We can't use data attributes (e.g. data--cy) for these since
// they are using third party libraries (i.e. react-dates, react-select)
cy.get('[data-cy="input-date-range-start"').as('studyListStartDate');
cy.get('[data-cy="input-date-range-end"').as('studyListEndDate');
cy.get('#input-modalities').as('modalities');
}
//Creating aliases for User Preferences modal
export function initPreferencesModalAliases() {
cy.get('.OHIFModal').as('preferencesModal');
cy.get('[data-cy="hotkeys"]').as('userPreferencesHotkeysTab');
cy.get('[data-cy="general"]').as('userPreferencesGeneralTab');
cy.get('[data-cy="window-level"]').as('userPreferencesWindowLevelTab');
initPreferencesModalFooterBtnAliases();
}
//Creating aliases for User Preferences modal
export function initPreferencesModalFooterBtnAliases() {
cy.get('.active [data-cy="reset-default-btn"]').as('restoreBtn');
cy.get('.active [data-cy="cancel-btn"]').as('cancelBtn');
cy.get('.active [data-cy="save-btn"]').as('saveBtn');
}

View File

@@ -0,0 +1,638 @@
import '@percy/cypress';
import 'cypress-file-upload';
import { DragSimulator } from './DragSimulator.js';
import {
initCornerstoneToolsAliases,
initCommonElementsAliases,
initRouteAliases,
initStudyListAliasesOnDesktop,
initPreferencesModalAliases,
initPreferencesModalFooterBtnAliases,
} from './aliases.js';
/**
* Command to select a layout preset.
* The layout preset is selected by clicking on the Layout button and then clicking on the desired preset.
* The preset name is the text that is displayed on the button.
* @param {string} presetName - The name of the layout preset that we would like to select
* @param {boolean} screenshot - If true, a screenshot will be taken when the layout tool is opened
*/
Cypress.Commands.add('selectLayoutPreset', (presetName, screenshot) => {
cy.get('[data-cy="Layout"]').click();
if (screenshot) {
cy.percyCanvasSnapshot('Layout tool opened');
}
cy.get('div').contains(presetName).should('be.visible').click();
// fixed wait time for layout changes and rendering
cy.wait(3000);
});
/**
* Command to search for a patient name and open his/her study.
*
* @param {string} PatientName - Patient name that we would like to search for
*/
Cypress.Commands.add('openStudy', PatientName => {
cy.openStudyList();
cy.get('#filter-patientNameOrId').type(PatientName);
// cy.get('@getStudies').then(() => {
// cy.waitQueryList();
cy.get('[data-cy="study-list-results"]', { timeout: 15000 })
.contains(PatientName)
.first()
.click({ force: true });
});
Cypress.Commands.add(
'checkStudyRouteInViewer',
(StudyInstanceUID, otherParams = '', mode = '/basic-test') => {
cy.location('pathname').then($url => {
cy.log($url);
if ($url === 'blank' || !$url.includes(`${mode}/${StudyInstanceUID}${otherParams}`)) {
cy.openStudyInViewer(StudyInstanceUID, otherParams, mode);
cy.waitDicomImage();
// Very short wait to ensure pending updates are handled
cy.wait(25);
}
});
}
);
Cypress.Commands.add('initViewer', (StudyInstanceUID, other = {}) => {
const { mode = '/basic-test', minimumThumbnails = 1, params = '' } = other;
cy.openStudyInViewer(StudyInstanceUID, params, mode);
cy.waitDicomImage();
// Very short wait to ensure pending updates are handled
cy.wait(25);
cy.expectMinimumThumbnails(minimumThumbnails);
cy.initCommonElementsAliases();
cy.initCornerstoneToolsAliases();
});
Cypress.Commands.add(
'openStudyInViewer',
(StudyInstanceUID, otherParams = '', mode = '/basic-test') => {
cy.visit(`${mode}?StudyInstanceUIDs=${StudyInstanceUID}${otherParams}`);
}
);
Cypress.Commands.add('waitQueryList', () => {
cy.get('[data-querying="false"]', { timeout: 15000 });
});
/**
* Command to search for a Modality and open the study.
*
* @param {string} Modality - Modality type that we would like to search for
*/
Cypress.Commands.add('openStudyModality', Modality => {
cy.initRouteAliases();
cy.visit('/');
cy.get('#filter-accessionOrModalityOrDescription').type(Modality).waitQueryList();
cy.get('[data-cy="study-list-results"]').contains(Modality).first().click();
});
/**
* Command to wait and check if a new page was loaded
*
* @param {string} url - part of the expected url. Default value is /basic-test
*/
Cypress.Commands.add('isPageLoaded', (url = '/basic-test') => {
return cy.location('pathname', { timeout: 60000 }).should('include', url);
});
Cypress.Commands.add('openStudyList', () => {
cy.initRouteAliases();
cy.visit('/', { timeout: 15000 });
// For some reason cypress 12.x does not like to stub the network request
// so we just wait here for querying to be done.
// cy.wait('@getStudies');
cy.waitQueryList();
});
Cypress.Commands.add('waitStudyList', () => {
// wait 1 second for the studies to get updated
cy.wait(1000);
cy.get('@searchResult').should($list => {
expect($list).to.not.have.class('no-hover');
});
});
Cypress.Commands.add('waitViewportImageLoading', () => {
// Wait for finish loading
cy.get('[data-cy="viewport-grid"]', { timeout: 30000 }).should($grid => {
expect($grid).not.to.contain.text('Load');
});
});
/**
* Command to perform a drag and drop action. Before using this command, we must get the element that should be dragged first.
* Example of usage: cy.get(element-to-be-dragged).drag(dropzone-element)
*
* @param {*} element - Selector for element that we want to use as dropzone
*/
Cypress.Commands.add('drag', { prevSubject: 'element' }, (...args) =>
DragSimulator.simulate(...args)
);
/**
* Command to perform three clicks into three different positions. Each position must be [x, y].
* The positions are considering the element as reference, therefore, top-left of the element will be (0, 0).
*
* @param {*} viewport - Selector for viewport we would like to interact with
* @param {number[]} firstClick - Click position [x, y]
* @param {number[]} secondClick - Click position [x, y]
* @param {number[]} thirdClick - Click position [x, y]
*/
Cypress.Commands.add('addAngle', (viewport, firstClick, secondClick, thirdClick) => {
cy.get(viewport).then($viewport => {
const [x1, y1] = firstClick;
const [x2, y2] = secondClick;
const [x3, y3] = thirdClick;
cy.wrap($viewport)
.click(x1, y1, { force: true })
.trigger('mousemove', { clientX: x2, clientY: y2 })
.click(x2, y2, { force: true })
.trigger('mousemove', { clientX: x3, clientY: y3 })
.click(x3, y3, { force: true });
});
});
Cypress.Commands.add('expectMinimumThumbnails', (seriesToWait = 1) => {
cy.get('[data-cy="study-browser-thumbnail"]', { timeout: 50000 }).should(
'have.length.gte',
seriesToWait
);
});
//Command to wait DICOM image to load into the viewport
Cypress.Commands.add('waitDicomImage', (mode = '/basic-test', timeout = 50000) => {
cy.window()
.its('cornerstone', { timeout: 30000 })
.should($cornerstone => {
const enabled = $cornerstone.getEnabledElements();
if (enabled?.length) {
enabled.forEach((item, i) => {
if (item.viewport.viewportStatus !== $cornerstone.Enums.ViewportStatus.RENDERED) {
throw new Error(`Viewport ${i} in state ${item.viewport.viewportStatus}`);
}
});
} else {
throw new Error('No enabled elements');
}
});
// This shouldn't be necessary, but seems to be.
cy.wait(250);
cy.log('DICOM image loaded');
});
//Command to reset and clear all the changes made to the viewport
Cypress.Commands.add('resetViewport', () => {
// Assign an alias to the More button
cy.get('[data-cy="MoreTools-split-button-primary"]')
.should('have.attr', 'data-tool', 'Reset')
.as('moreBtn');
// Use the alias to click on the More button
cy.get('@moreBtn').click();
});
Cypress.Commands.add('imageZoomIn', () => {
cy.initCornerstoneToolsAliases();
cy.get('@zoomBtn').click();
cy.wait(25);
//drags the mouse inside the viewport to be able to interact with series
cy.get('@viewport')
.trigger('mousedown', 'top', { buttons: 1 })
.trigger('mousemove', 'center', { buttons: 1 })
.trigger('mouseup');
});
Cypress.Commands.add('imageContrast', () => {
cy.initCornerstoneToolsAliases();
cy.get('@wwwcBtnPrimary').click();
cy.wait(25);
//drags the mouse inside the viewport to be able to interact with series
cy.get('@viewport')
.trigger('mousedown', 'center', { buttons: 1 })
.trigger('mousemove', 'top', { buttons: 1 })
.trigger('mouseup');
});
//Initialize aliases for Cornerstone tools buttons
Cypress.Commands.add('initCornerstoneToolsAliases', () => {
initCornerstoneToolsAliases();
});
//Initialize aliases for Common page elements
Cypress.Commands.add('initCommonElementsAliases', skipMarkers => {
initCommonElementsAliases(skipMarkers);
});
//Initialize aliases for Routes
Cypress.Commands.add('initRouteAliases', () => {
initRouteAliases();
});
//Initialize aliases for Study List page elements
Cypress.Commands.add('initStudyListAliasesOnDesktop', () => {
initStudyListAliasesOnDesktop();
});
//Add measurements in the viewport
Cypress.Commands.add(
'addLengthMeasurement',
(firstClick = [150, 100], secondClick = [130, 170]) => {
// Assign an alias to the button element
cy.get('@measurementToolsBtnPrimary').as('lengthButton');
cy.get('@lengthButton').should('have.attr', 'data-tool', 'Length');
cy.get('@lengthButton').then(button => {
// Only click the length tool if it is not active, in case the length tool is set up to
// toggle to inactive.
if (!button.is('.active')) {
cy.wrap(button).click();
}
});
cy.get('@lengthButton').should('have.class', 'bg-primary-light');
cy.get('@viewport').then($viewport => {
const [x1, y1] = firstClick;
const [x2, y2] = secondClick;
cy.wrap($viewport)
.click(x1, y1, { force: true })
.wait(1000)
.click(x2, y2, { force: true })
.wait(1000);
});
}
);
// Add brush stroke in the viewport
Cypress.Commands.add('addBrush', (viewport, firstClick = [85, 100], secondClick = [85, 300]) => {
cy.get(viewport)
.first()
.then(viewportElement => {
const [x1, y1] = firstClick;
const [x2, y2] = secondClick;
const steps = 10;
const xStep = (x2 - x1) / steps;
const yStep = (y2 - y1) / steps;
cy.wrap(viewportElement)
.trigger('mousedown', x1, y1, { buttons: 1 })
.then(() => {
for (let i = 1; i <= steps; i++) {
let x = x1 + xStep * i;
let y = y1 + yStep * i;
cy.wrap(viewportElement).trigger('mousemove', x, y, { buttons: 1 });
}
})
.trigger('mouseup');
});
});
// Add erase stroke in the viewport
Cypress.Commands.add('addEraser', (viewport, firstClick = [85, 100], secondClick = [85, 300]) => {
cy.get(viewport)
.first()
.then(viewportElement => {
const [x1, y1] = firstClick;
const [x2, y2] = secondClick;
const steps = 10;
const xStep = (x2 - x1) / steps;
const yStep = (y2 - y1) / steps;
cy.wrap(viewportElement)
.trigger('mousedown', x1, y1, { buttons: 1 })
.then(() => {
for (let i = 1; i <= steps; i++) {
let x = x1 + xStep * i;
let y = y1 + yStep * i;
cy.wrap(viewportElement).trigger('mousemove', x, y, { buttons: 1 });
}
})
.trigger('mouseup');
});
});
//Add measurements in the viewport
Cypress.Commands.add(
'addAngleMeasurement',
(initPos = [180, 390], midPos = [300, 410], finalPos = [180, 450]) => {
cy.get('[data-cy="MeasurementTools-split-button-secondary"]').click();
cy.get('[data-cy="Angle"]').click();
cy.addAngle('.cornerstone-canvas', initPos, midPos, finalPos);
}
);
/**
* Tests if element is NOT in viewport, or does not exist in DOM
*
* @param {string} element - element selector string or alias
* @returns
*/
Cypress.Commands.add('isNotInViewport', element => {
cy.get(element, { timeout: 3000 }).should($el => {
const bottom = Cypress.$(cy.state('window')).height() - 50;
const right = Cypress.$(cy.state('window')).width() - 50;
// If it's not visible, it's not in the viewport
if ($el) {
const rect = $el[0].getBoundingClientRect();
// TODO: support leftOf, above
const isBeneath = rect.top >= bottom && rect.bottom >= bottom;
const isRightOf = rect.left >= right && rect.right >= right;
const isNotInViewport = isBeneath && isRightOf;
expect(isNotInViewport).to.be.true;
}
});
});
/**
* Tests if element is in viewport, or it does exist in DOM
*
* @param {string} element - element selector string or alias
* @returns
*/
Cypress.Commands.add('isInViewport', element => {
cy.get(element, { timeout: 3000 }).should($el => {
const bottom = Cypress.$(cy.state('window')).height();
const right = Cypress.$(cy.state('window')).width();
// If it's not visible, it's not in the viewport
if ($el) {
const rect = $el[0].getBoundingClientRect();
// TODO: support leftOf, above
const isBeneath = rect.top < bottom && rect.bottom < bottom;
const isRightOf = rect.left < right && rect.right < right;
const isInViewport = isBeneath && isRightOf;
expect(isInViewport).to.be.true;
}
});
});
/**
* Percy.io Canvas screenshot workaround
*
*/
Cypress.Commands.add('percyCanvasSnapshot', (name, options = {}) => {
cy.document().then(doc => {
convertCanvas(doc);
});
// `domTransformation` does not appear to be working
// But modifying our immediate DOM does.
cy.percySnapshot(name, { ...options }); //, domTransformation: convertCanvas });
cy.document().then(doc => {
unconvertCanvas(doc);
});
});
Cypress.Commands.add('setLayout', (columns = 1, rows = 1) => {
cy.get('[data-cy="Layout"]').click();
cy.get(`[data-cy="Layout-${columns - 1}-${rows - 1}"]`).click();
cy.wait(10);
cy.waitDicomImage();
});
function convertCanvas(documentClone) {
documentClone.querySelectorAll('canvas').forEach(selector => canvasToImage(selector));
return documentClone;
}
function unconvertCanvas(documentClone) {
// Remove previously generated images
documentClone.querySelectorAll('[data-percy-image]').forEach(selector => selector.remove());
// Restore canvas visibility
documentClone.querySelectorAll('[data-percy-canvas]').forEach(selector => {
selector.removeAttribute('data-percy-canvas');
selector.style = '';
});
}
function canvasToImage(selectorOrEl) {
let canvas =
typeof selectorOrEl === 'object' ? selectorOrEl : document.querySelector(selectorOrEl);
let image = document.createElement('img');
let canvasImageBase64 = canvas.toDataURL('image/png');
// Show Image
image.src = canvasImageBase64;
image.style = 'width: 100%';
image.setAttribute('data-percy-image', true);
// Hide Canvas
canvas.setAttribute('data-percy-canvas', true);
canvas.parentElement.appendChild(image);
canvas.style = 'display: none';
}
//Initialize aliases for User Preferences modal
Cypress.Commands.add('initPreferencesModalAliases', () => {
initPreferencesModalAliases();
});
Cypress.Commands.add('openPreferences', () => {
cy.log('Open User Preferences Modal');
// Open User Preferences modal
cy.get('body').then(body => {
if (body.find('.OHIFModal').length === 0) {
cy.get('[data-cy="options-chevron-down-icon"]')
.scrollIntoView()
.click()
.then(() => {
cy.get('[data-cy="options-dropdown"]').last().click().wait(200);
});
}
});
});
Cypress.Commands.add('scrollToIndex', index => {
// Workaround implemented based on Cypress issue:
// https://github.com/cypress-io/cypress/issues/1570#issuecomment-450966053
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
cy.get('input.imageSlider[type=range]').then($range => {
// get the DOM node
const range = $range[0];
// set the value manually
nativeInputValueSetter.call(range, index);
// now dispatch the event
range.dispatchEvent(
new Event('change', {
value: index,
bubbles: true,
})
);
});
});
Cypress.Commands.add('closePreferences', () => {
cy.log('Close User Preferences Modal');
cy.get('body').then(body => {
// Close notification if displayed
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').first().click({ force: true });
}
// Close User Preferences Modal (if displayed)
if (body.find('.OHIFModal__header').length > 0) {
cy.get('[data-cy="close-button"]').click({ force: true });
}
});
});
Cypress.Commands.add('selectPreferencesTab', tabAlias => {
cy.initPreferencesModalAliases();
cy.get(tabAlias).as('selectedTab');
cy.get('@selectedTab').click();
cy.get('@selectedTab').should('have.class', 'active');
initPreferencesModalFooterBtnAliases();
});
Cypress.Commands.add('resetUserHotkeyPreferences', () => {
// Open User Preferences modal
cy.openPreferences();
cy.selectPreferencesTab('@userPreferencesHotkeysTab').then(() => {
cy.log('Reset Hotkeys to Default Preferences');
cy.get('@restoreBtn').click();
});
// Close Success Message overlay (if displayed)
cy.get('body').then(body => {
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').first().click({ force: true });
}
// Click on Save Button
cy.get('@saveBtn').click();
});
});
Cypress.Commands.add('resetUserGeneralPreferences', () => {
// Open User Preferences modal
cy.openPreferences();
cy.selectPreferencesTab('@userPreferencesGeneralTab').then(() => {
cy.log('Reset Language to Default Preferences');
cy.get('@restoreBtn').click();
});
// Close Success Message overlay (if displayed)
cy.get('body').then(body => {
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').first().click({ force: true });
}
// Click on Save Button
cy.get('@saveBtn').click();
});
});
Cypress.Commands.add('setNewHotkeyShortcutOnUserPreferencesModal', (function_label, shortcut) => {
// Within scopes all `.get` and `.contains` to within the matched elements
// dom instead of checking from document
cy.get('.HotkeysPreferences').within(() => {
cy.contains(function_label) // label we're looking for
.parent()
.find('input') // closest input to that label
.type(shortcut, { force: true }); // Set new shortcut for that function
});
});
Cypress.Commands.add(
'setWindowLevelPreset',
(preset_index, description_value, window_value, level_value) => {
let index = parseInt(preset_index) + 1;
// Set new Description value
cy.get(':nth-child(' + index + ') > .description > .preferencesInput')
.clear()
.type(description_value, {
force: true,
})
.blur();
// Set new Window value
cy.get(':nth-child(' + index + ') > .window > .preferencesInput')
.clear()
.type(window_value, {
force: true,
})
.blur();
// Set new Level value
cy.get(':nth-child(' + index + ') > .level > .preferencesInput')
.clear()
.type(level_value, {
force: true,
})
.blur();
}
);
Cypress.Commands.add('openDownloadImageModal', () => {
// Click on More button
cy.get('[data-cy="Capture"]').as('captureBtn').click();
});
Cypress.Commands.add('setLanguage', (language, save = true) => {
cy.openPreferences();
cy.initPreferencesModalAliases();
cy.selectPreferencesTab('@userPreferencesGeneralTab');
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
// Select Language and Save/Cancel
cy.get('#language-select').select(language);
// Close Success Message overlay (if displayed)
cy.get('body').then(body => {
if (body.find('.sb-closeIcon').length > 0) {
cy.get('.sb-closeIcon').first().click({ force: true });
}
//Click on Save/Cancel button
const toClick = save ? '@saveBtn' : '@cancelBtn';
cy.get(toClick).scrollIntoView().click();
});
});
// hide noisy logs
// https://github.com/cypress-io/cypress/issues/7362
// uncomment this if you really need the network logs
const origLog = Cypress.log;
Cypress.log = function (opts, ...other) {
if (opts.displayName === 'script' || opts.name === 'request') {
return;
}
return origLog(opts, ...other);
};

View File

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

View File

@@ -0,0 +1,13 @@
const base = require('../../jest.config.base.js');
const pkg = require('./package');
module.exports = {
...base,
displayName: pkg.name,
setupFilesAfterEnv: ['<rootDir>/src/__tests__/globalSetup.js'],
// rootDir: "../.."
// testMatch: [
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
// "<rootDir>/platform/app/**/*.test.js"
// ]
};

View File

@@ -0,0 +1,5 @@
const babelJest = require('babel-jest');
module.exports = babelJest.createTransformer({
rootMode: 'upward',
});

41
platform/app/netlify.toml Normal file
View File

@@ -0,0 +1,41 @@
# Netlify Config
#
# TOML Reference:
# https://www.netlify.com/docs/netlify-toml-reference/
#
# We use Netlify for deploy previews and for publishing docs (gh-pages branch).
# https://viewer.ohif.org is created using a different process that is
# managed by CircleCI and deployed to our Google Hosting
#
[build]
base = ""
build = "yarn run build:viewer:ci"
publish = "dist"
# NODE_VERSION in root `.nvmrc` takes priority
# YARN_FLAGS: https://www.netlify.com/docs/build-gotchas/#yarn
[build.environment]
# If 'production', `yarn install` does not install devDependencies
NODE_ENV = "development"
NODE_VERSION = "18.16.1"
YARN_VERSION = "1.22.5"
RUBY_VERSION = "2.6.2"
YARN_FLAGS = "--no-ignore-optional --pure-lockfile"
NETLIFY_USE_YARN = "true"
[[headers]]
# Define which paths this specific [[headers]] block will cover.
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
# Multi-key header rules are expressed with multi-line strings.
cache-control = '''
max-age=0,
no-cache,
no-store,
must-revalidate'''

113
platform/app/package.json Normal file
View File

@@ -0,0 +1,113 @@
{
"name": "@ohif/app",
"version": "3.9.1",
"productVersion": "3.4.0",
"description": "OHIF Viewer",
"author": "OHIF Contributors",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.js",
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"proxy": "http://localhost:8042",
"scripts": {
"build:viewer": "cross-env NODE_ENV=production yarn run build",
"build:dev": "cross-env NODE_ENV=development yarn run build",
"build:aws": "cross-env NODE_ENV=development APP_CONFIG=config/aws_static.js yarn run build && gzip -9 -r dist",
"build:viewer:ci": "cross-env NODE_ENV=production PUBLIC_URL=/ APP_CONFIG=config/netlify.js QUICK_BUILD=false yarn run build",
"build:viewer:qa": "cross-env NODE_ENV=production APP_CONFIG=config/google.js yarn run build",
"build:viewer:demo": "cross-env NODE_ENV=production APP_CONFIG=config/demo.js HTML_TEMPLATE=rollbar.html QUICK_BUILD=false yarn run build",
"build": "node --max_old_space_size=8096 ./../../node_modules/webpack/bin/webpack.js --progress --config .webpack/webpack.pwa.js",
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.pwa.js",
"dev:no:cache": "cross-env NODE_ENV=development webpack serve --no-cache --config .webpack/webpack.pwa.js",
"dev:orthanc": "cross-env NODE_ENV=development PROXY_TARGET=http://localhost:3000/pacs/dicom-web PROXY_DOMAIN=http://localhost:8042 PROXY_PATH_REWRITE_FROM=/pacs/dicom-web PROXY_PATH_REWRITE_TO=/dicom-web APP_CONFIG=config/docker-nginx-orthanc.js webpack serve --config .webpack/webpack.pwa.js",
"dev:orthanc:no:cache": "cross-env NODE_ENV=development PROXY_TARGET=http://localhost:3000/pacs/dicom-web PROXY_DOMAIN=http://localhost:8042 PROXY_PATH_REWRITE_FROM=/pacs/dicom-web PROXY_PATH_REWRITE_TO=/dicom-web APP_CONFIG=config/docker-nginx-orthanc.js webpack serve --no-cache --config .webpack/webpack.pwa.js",
"dev:dcm4chee": "cross-env NODE_ENV=development APP_CONFIG=config/local_dcm4chee.js webpack serve --config .webpack/webpack.pwa.js",
"dev:static": "cross-env NODE_ENV=development APP_CONFIG=config/local_static.js webpack serve --config .webpack/webpack.pwa.js",
"dev:viewer": "yarn run dev",
"start": "yarn run dev",
"test:e2e": "cypress open",
"test:e2e:local": "cypress run --config video=false --browser chrome --spec 'cypress/integration/common/**/*,cypress/integration/pwa/**/*'",
"test:e2e:dist": "start-server-and-test test:e2e:serve http://localhost:3000 test:e2e:ci",
"test:e2e:serve": "cross-env APP_CONFIG=config/e2e.js yarn start",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage",
"ci:generateSuccessVersion": "node -p -e \"require('./package.json').version\" > success_version.txt"
},
"files": [
"dist",
"README.md"
],
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/codec-charls": "^1.2.3",
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^2.2.4",
"@emotion/serialize": "^1.1.3",
"@ohif/core": "3.9.1",
"@ohif/extension-cornerstone": "3.9.1",
"@ohif/extension-cornerstone-dicom-rt": "3.9.1",
"@ohif/extension-cornerstone-dicom-seg": "3.9.1",
"@ohif/extension-cornerstone-dicom-sr": "3.9.1",
"@ohif/extension-default": "3.9.1",
"@ohif/extension-dicom-microscopy": "3.9.1",
"@ohif/extension-dicom-pdf": "3.9.1",
"@ohif/extension-dicom-video": "3.9.1",
"@ohif/extension-test": "3.9.1",
"@ohif/i18n": "3.9.1",
"@ohif/mode-basic-dev-mode": "3.9.1",
"@ohif/mode-longitudinal": "3.9.1",
"@ohif/mode-microscopy": "3.9.1",
"@ohif/mode-test": "3.9.1",
"@ohif/ui": "3.9.1",
"@ohif/ui-next": "3.9.1",
"@svgr/webpack": "^8.1.0",
"@types/react": "^18.3.3",
"classnames": "^2.3.2",
"core-js": "*",
"cornerstone-math": "^0.1.9",
"dcmjs": "*",
"detect-gpu": "^4.0.16",
"dicom-parser": "^1.8.9",
"dotenv-webpack": "^1.7.0",
"file-loader": "^6.2.0",
"hammerjs": "^2.0.8",
"history": "^5.3.0",
"i18next": "^17.0.3",
"i18next-browser-languagedetector": "^3.0.1",
"lodash.isequal": "4.5.0",
"oidc-client": "1.11.5",
"oidc-client-ts": "^3.0.1",
"prop-types": "^15.7.2",
"query-string": "^6.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^10.1.7",
"react-i18next": "^12.2.2",
"react-resize-detector": "^10.0.1",
"react-router": "^6.23.1",
"react-router-dom": "^6.8.1",
"react-shepherd": "6.1.1",
"shepherd.js": "13.0.3",
"url-loader": "^4.1.1",
"zustand": "4.5.5"
},
"devDependencies": {
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@types/node": "^20.12.12",
"identity-obj-proxy": "3.0.x",
"lodash": "^4.17.21",
"tailwindcss": "3.2.4"
}
}

View File

@@ -0,0 +1,104 @@
{
"extensions": [
{
"packageName": "@ohif/extension-default"
},
{
"packageName": "@ohif/extension-cornerstone",
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-measurement-tracking",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-cornerstone-dicom-sr",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-cornerstone-dicom-seg",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-cornerstone-dicom-pmap",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-cornerstone-dynamic-volume",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-dicom-microscopy",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-dicom-pdf",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-dicom-video",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-tmtv",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-test",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-cornerstone-dicom-rt",
"default": false,
"version": "3.0.0"
}
],
"modes": [
{
"packageName": "@ohif/mode-longitudinal"
},
{
"packageName": "@ohif/mode-segmentation"
},
{
"packageName": "@ohif/mode-tmtv"
},
{
"packageName": "@ohif/mode-microscopy"
},
{
"packageName": "@ohif/mode-preclinical-4d"
},
{
"packageName": "@ohif/mode-test",
"default": false,
"version": "3.0.0"
},
{
"packageName": "@ohif/mode-basic-dev-mode",
"default": false,
"version": "3.0.0"
}
],
"public": [
{
"directory": "./platform/public"
},
{
"packageName": "dicom-microscopy-viewer",
"importPath": "/dicom-microscopy-viewer/dicomMicroscopyViewer.min.js",
"globalName": "dicomMicroscopyViewer",
"directory": "./node_modules/dicom-microscopy-viewer/dist/dynamic-import"
}
]
}

View File

@@ -0,0 +1 @@
module.exports = require('../../postcss.config.js');

View File

@@ -0,0 +1,13 @@
console.log('preinstall.js');
const { exec } = require('child_process');
const log = (err, stdout, stderr) => console.log(stdout);
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (GITHUB_TOKEN) {
const command = `git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/".insteadOf ssh://git@github.com/`;
console.log(command);
exec(command, log);
} else {
console.log('No GITHUB_TOKEN found, skipping private repo customization');
}

View File

View File

@@ -0,0 +1,6 @@
# Specific to our deploy-preview
# Our docs are published using CircleCI + GitBook
# Configure redirects using netlify.toml
# Spa
/* /index.html 200

Some files were not shown because too many files have changed in this diff Show More