Backend app for Visual Regression Tracker
- Install Node
26(LTS);nvm usepicks it up from.nvmrc - clone repo
- Update
.envandprisma/.env(DATABASE_URLinprisma/.envmust match thePOSTGRES_*values in.env) - Start Postgres
docker compose up -d postgres npm inpm run test- Create DB structure
npx prisma migrate deploy- do not run
npx prisma db pushbefore it:db pushcreates the tables without the migration history, somigrate deploythen fails withP3005 The database schema is not empty. If that already happened, start over with an empty database (docker compose down -v)
- do not run
npm run test:e2e- Seed initial data
npx prisma db seed npm run start:debug
| Suite | Command | Needs | Covers |
|---|---|---|---|
| unit | npm test |
nothing | services with mocked dependencies (src/**/*.spec.ts) |
| e2e | npm run test:e2e |
Postgres with the migrations applied (DATABASE_URL); run npm run build first |
the app in-process against that database |
| acceptance | npm run test:acceptance |
docker compose up -d; the API on localhost:4200 |
the running API over HTTP and socket.io |
| ldap | npm run test:ldap |
COMPOSE_FILE=docker-compose.yml:docker-compose.ldap.yml docker compose up -d |
LDAP login against the LDAP mock |
| oidc | npm run test:oidc |
COMPOSE_FILE=docker-compose.yml:docker-compose.oidc.yml docker compose up -d |
the OIDC endpoints against the OIDC mock |
| prisma package | cd prisma && npm test |
npm ci in prisma/ |
the prisma/ package's own tests |
The compose stack runs the production image, which refuses the sample JWT_SECRET in .env:
run export JWT_SECRET=$(openssl rand -hex 32) before docker compose up.
The e2e suite loads the diff worker threads from dist/. Without a build they run
inline, so npm run build makes the e2e run match production.
Schema drift check (AGENTS.md: every schema change ships with its migration). Run it against a database where the migrations are applied; exit code 2 means the schema has changes without a migration:
cd prisma && npx prisma migrate deploy && npx prisma migrate diff --from-config-datasource --to-schema schema.prisma --exit-code
Defaults are what the code uses when the variable is unset or empty. The commented
lines in .env (section # optional) show the same variables.
| Variable | Default | What it does |
|---|---|---|
DATABASE_URL |
none, required | Postgres connection string. connection_limit sets the pool size per instance (see Database connections). |
APP_PORT |
3000 |
Port the API listens on. |
APP_FRONTEND_URL |
none | Frontend base URL, used to build links in test run results. |
BODY_PARSER_JSON_LIMIT |
body-parser default | Maximum JSON request body size, e.g. 5mb. |
JWT_SECRET |
none, required | Key that signs login tokens; startup fails when it is unset. Public sample values (jwtPrivateKey, the one in .env) stop startup when NODE_ENV=production (as in the Docker image) and log a warning otherwise. Generate one with openssl rand -hex 32. |
JWT_LIFE_TIME |
1d |
Token lifetime as a timespan such as 1d, 12h or 3600s. An invalid value falls back to 1d with a warning. |
SERVER_TIMEOUT |
Node.js default | HTTP server.timeout, in milliseconds. |
SERVER_HEADERS_TIMEOUT |
Node.js default | HTTP headersTimeout, in milliseconds. |
SERVER_KEEP_ALIVE_TIMEOUT |
Node.js default | HTTP keepAliveTimeout, in milliseconds. |
LOG_LEVEL |
log,error,warn,fatal |
Comma-separated Nest log levels to keep, e.g. log,warn,error,fatal. |
HTTPS_KEY_PATH |
./secrets/ssl.key |
TLS private key. HTTPS is used only when both files exist; otherwise the API serves HTTP. A file that exists but cannot be read stops startup. |
HTTPS_CERT_PATH |
./secrets/ssl.cert |
TLS certificate. |
| Variable | Default | What it does |
|---|---|---|
STATIC_SERVICE |
hdd |
Where images are stored: hdd (imageUploads/ in the working directory, /imageUploads in the image) or s3. With hdd, startup fails when that directory is not writable. Odiff works only with hdd. |
AWS_S3_BUCKET_NAME |
none | Bucket for STATIC_SERVICE=s3; required then. |
AWS_REGION |
from the AWS SDK | Read by the AWS SDK v3 default chain, not by VRT code. |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
from the AWS SDK | Same chain. An IAM role or shared AWS config works without them. |
| Variable | Default | What it does |
|---|---|---|
MAX_IMAGE_PIXELS |
100000000 |
Largest accepted image in pixels (width times height), checked from the PNG header before decoding. 0 turns the limit off. |
MULTIPART_FILE_SIZE_LIMIT |
104857600 (100 MiB) |
Largest accepted multipart file upload, in bytes. |
DIFF_WORKER_MAX_HEAP_MB |
512 |
Heap limit of each diff worker, in MB. |
OLLAMA_BASE_URL |
none | Ollama server for VLM comparisons; required when the Ollama provider is used. |
DINOV2_URL |
none | DINOv2 service for the second stage of the rendering-noise check. Unset, only the blur stage runs. |
DINOV2_TIMEOUT_MS |
60000 |
Timeout for one DINOv2 request, in milliseconds. A failed or timed-out request leaves the verdict to the blur stage. |
Rendering-noise check. A post-analysis that starts only on request: a reviewer runs it on one run from the diff dialog, or on all unresolved runs of a build from the list. It never changes a run's status or approves anything. It marks each unresolved run "likely noise" or "likely change" and sorts real changes first. The check has two stages, each with its own verdict:
- Blur runs whenever the project enables it. It measures the peak difference after a blur with σ = 1 px (threshold
80) and the share of solid changed pixels (threshold0.01). Above either threshold, the stage says "change". - DINOv2 takes a second look at the patches that the blur stage called noise. The stage says "change" when the strongest marked patch is above
0.15.
Both stages are off by default and are switched on or off per project in the "Post-analysis" section (noiseAnalysisConfig); the thresholds are fixed, and threshold keys saved by release candidates are ignored. Projects that use the VLM comparator are excluded. DINOV2_URL enables the second stage; without it the blur stage works alone. Runs are analysed one at a time from a queue in the API process. The queue is not persisted: analyses still pending when the API restarts are dropped and can be requested again. POST /test-runs/noise-analysis queues a list of run ids. The ignoreRenderingNoise key in imageComparisonConfig (rc1–rc3) is ignored.
On our benchmark of 168 real screenshot pairs it labels about 1 in 4 of the benchmark's bugs noise with blur alone (28 of 120, mostly small text changes, faint borders and light icons) and 1 in 7 with DINOv2 (18 of 120); no noise pair is labelled a change. The lists live in test_noise/bugs-labelled-noise.ts; npm run test:noise keeps them current.
| Variable | Default | What it does |
|---|---|---|
REGISTRATION_ENABLED |
true |
false (also 0, no, off) disables self-registration. |
LDAP_ENABLED |
false |
Enables LDAP login. When OIDC is enabled too, OIDC is used. |
LDAP_URL |
none | LDAP server URL, e.g. ldaps://ldap.example.com:636/. Required when LDAP is enabled. |
LDAP_BIND_USER |
none | DN of the service account used for the user search. Required when LDAP is enabled. |
LDAP_BIND_PASSWORD |
none | Password of the service account. Required when LDAP is enabled. |
LDAP_SEARCH_DN |
none | Base DN of the user search. Required when LDAP is enabled. |
LDAP_USERS_SEARCH_FILTER |
none | User search filter; {{email}} is replaced by the login email. Required when LDAP is enabled. |
LDAP_ATTRIBUTE_MAIL, LDAP_ATTRIBUTE_FIRST_NAME, LDAP_ATTRIBUTE_LAST_NAME |
none | Attributes that hold the email, first name and last name. Required when LDAP is enabled. |
LDAP_TLS_NO_VERIFY |
false |
Skip TLS certificate verification for LDAP. Test servers only. |
OIDC_ENABLED |
false |
Enables OIDC (SSO) login; see the OIDC section. |
OIDC_ISSUER_URL |
none | Issuer URL of the identity provider. Required when OIDC is enabled. An http:// issuer works (with a warning) for local setups. |
OIDC_CLIENT_ID |
none | OIDC client ID. Required when OIDC is enabled. |
OIDC_CLIENT_SECRET |
none | OIDC client secret. Required when OIDC is enabled. |
OIDC_REDIRECT_URI |
http://localhost:4200/users/oidc/callback |
Callback URL registered at the identity provider, without a query string. |
OIDC_SCOPES |
openid profile email |
Requested scopes. |
OIDC_ATTRIBUTE_MAIL |
email |
Claim that holds the email. |
OIDC_ATTRIBUTE_FIRST_NAME |
given_name |
Claim that holds the first name. |
OIDC_ATTRIBUTE_LAST_NAME |
family_name |
Claim that holds the last name. |
OIDC_ROLE_CLAIM |
groups |
Claim, or dotted path, that holds the roles. |
OIDC_ADMIN_VALUES |
vrt-admin,admin |
Comma-separated claim values that give the admin role. |
OIDC_DEFAULT_ROLE |
editor |
Role for every other user: admin or editor. |
OIDC_REQUIRE_VERIFIED_EMAIL |
true |
Reject logins whose email is not verified. |
OIDC_TLS_NO_VERIFY |
false |
Skip TLS verification for requests to the issuer's origin; other hosts are still verified. Test servers only. |
| Variable | Default | What it does |
|---|---|---|
REDIS_URL |
unset, in-memory adapter | Redis for socket.io fan-out between instances (see Scaling to multiple instances). |
Read by prisma db seed.
| Variable | Default | What it does |
|---|---|---|
DEFAULT_USER_EMAIL |
visual-regression-tracker@example.com |
Email of the default user. |
DEFAULT_USER_PASSWORD |
generated, printed once | Password of the default user. |
DEFAULT_USER_API_KEY |
generated, printed once | API key of the default user. |
The api and migration images run as uid/gid 1000:1000 (node), dinov2 as 65534:65534 (nobody). A volume or bind mount at /imageUploads, and TLS files mounted for HTTPS_KEY_PATH/HTTPS_CERT_PATH, must belong to that user, or the API stops at startup with the path and the fix:
chown -R 1000:1000 ./imageUploads ./sslA fresh named volume needs nothing: Docker copies the image's /imageUploads, which already belongs to 1000:1000.
- Redis runs several API instances behind one socket.io fan-out. Set
REDIS_URL; see Scaling to multiple instances. - DINOv2 is the second stage of the rendering-noise check (see
dinov2/README.md); the blur stage works without it. Start it withdocker compose --profile dinov2 up -d. The service listens on port 8000 inside the compose network only, so the API container usesDINOV2_URL=http://dinov2:8000. - Ollama runs the VLM comparison locally. Set
OLLAMA_BASE_URL; from the API container, which is started by compose, the host ishttp://host.docker.internal:11434. - Gemini runs the VLM comparison in the cloud. Its API key is set in the project's VLM settings, not in the environment.
Real-time UI updates are delivered over socket.io. Running more than one API instance requires both of the following — with only one, live updates break:
-
Redis backplane — set
REDIS_URLso every instance shares a socket.io pub/sub adapter. Without it, an event emitted on one instance never reaches clients connected to another (events are silently lost and the UI only updates after a manual refresh).REDIS_URL=redis://<host>:6379When
REDIS_URLis unset, the default in-memory adapter is used, so a single instance and local development work with no extra setup. Any Redis server from 5.0 on works (the API speaks RESP2). If Redis goes down, the API keeps running and reconnects; events emitted meanwhile may miss other instances. -
Reverse proxy with sticky sessions + WebSocket upgrade for
/socket.io/. Without sticky sessions the polling handshake fails with400 "Session ID unknown"because consecutive requests land on different instances. Example nginx:map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream vrt_api { ip_hash; # sticky sessions server api_1:3000; server api_2:3000; } location /socket.io/ { proxy_pass http://vrt_api; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; }
Confirmed with a 4-replica deployment: nginx ip_hash + WebSocket upgrade fixes
the handshake, but cross-replica event delivery still requires REDIS_URL.
Each API instance keeps one Prisma connection pool. By default it opens up to
num_physical_cpus * 2 + 1 connections, and idle connections stay open for
reuse. Size the pool with the connection_limit parameter of DATABASE_URL so
that replicas * connection_limit stays below Postgres max_connections
(default 100), leaving room for migrations and admin tools:
DATABASE_URL=postgresql://user:password@postgres:5432/vrt?connection_limit=10
- Generate keys here
- place in folder
/secretsnamedssl.certandssl.key
- Run
JWT_SECRET=$(openssl rand -hex 32) docker compose -f docker-compose.yml -f docker-compose.ldap.yml up(see docker docs for multiple-compose-files - merge) - test the login with ldap and have a look at the logs
curl 'http://localhost:4200/users/login' \ -H 'accept: */*' \ -H 'accept-language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6' \ -H 'content-type: application/json' \ --data-raw '{"email":"developer.one@ldapmock.local","password":"password"}'
Set OIDC_ENABLED=true and the OIDC_* variables in .env. Password login and registration are disabled while OIDC is enabled.
- Roles come from the identity provider. On every login the role is derived from
OIDC_ROLE_CLAIM:adminwhen a value matchesOIDC_ADMIN_VALUES, otherwiseOIDC_DEFAULT_ROLE. Roles assigned in the VRT UI are overwritten at the next login. When the claim is missing from the token, an existing user keeps their role. OIDC_ROLE_CLAIMaccepts a claim name or a dotted path into nested claims, e.g.realm_access.rolesfor Keycloak realm roles.- Accounts are matched by email. The first login creates the user, later logins find them by the email claim. If a user's email changes at the identity provider, a new VRT account is created. Unverified emails are rejected unless
OIDC_REQUIRE_VERIFIED_EMAIL=false.
- Run
JWT_SECRET=$(openssl rand -hex 32) docker compose -f docker-compose.yml -f docker-compose.oidc.yml up(see docker docs for multiple-compose-files - merge) - The issuer (
host.docker.internal:8090) must be reachable from both the api container and the browser. On macOS/Windows Docker Desktop this works out of the box; on Linux add127.0.0.1 host.docker.internalto/etc/hosts. - Open
http://localhost:8080/loginand click Login with SSO (or setREACT_APP_OIDC_AUTO_REDIRECT=trueto redirect straight away). - Mock users: username
admin(roleadmin), any other username (roleeditor). The password is ignored. Seeoauth2-mock-server-config.json. - Optional: inspect/trigger the flow manually via the debugger at
http://host.docker.internal:8090/default/debugger. - Verify the OIDC endpoints (api must run with OIDC enabled) and run the integration tests:
curl -i 'http://localhost:4200/users/oidc/enabled' curl -i 'http://localhost:4200/users/oidc/login' npm run test:oidc