diff --git a/.cspell.yaml b/.cspell.yaml deleted file mode 100644 index c9715912e..000000000 --- a/.cspell.yaml +++ /dev/null @@ -1,121 +0,0 @@ -version: "0.2" -dictionaries: - - coding-terms - - cpp-compound-words - - data-science - - docker - - en-gb - - en-us - - filetypes - - fullstack - - npm - - python - - python-common - - software-tools -enabled: true -enabledFileTypes: - "*": true -dot: true -useGitignore: true -cache: - useCache: true - cacheStrategy: content -ignorePaths: - - .cspell.yaml - - .cspellcache - - .devcontainer - - .dockerignore - - .git - - .gitignore - - .github/trivyignore - - .vscode - - package-lock.json - - pnpm-lock.yaml - - uv.lock - - backend/app/api/auth/resources/disposable_email_domains.txt - - frontend-app/src/assets/data/** - - frontend-app/src/types/api.generated.ts - -language: en_us -words: - # Proper nouns - - Donati - - Lierde - - RELab - - # Font names - - grotesk - - # Technical terms - - linkinator - - LogQL - - mpegurl - - nosniff - - OTLP - - shellcheck - - subrepo - - subrepos - - tootallnate - - trivyignores - - trixie - - tsvector - - # Miscellaneous - - orcid - - refurbishers - - remanufacturability - - remanufacturable - - repairability - - zenodo - -ignoreWords: - - USEPOLLING - -overrides: - - filename: "{backend,docs}/**" - words: - # Model terms - - categorymateriallink - - categoryproducttypelink - - circularityproperties - - fileparenttype - - imageparenttype - - materialproductlink - - newslettersubscriber - - organizationrole - - oauthaccount - - physicalproperties - - producttype - - subcomponent - - subcomponents - - supercategory - - taxonomydomain - - - filename: "backend/**" - words: - # Technical terms - - instrumentor - - instrumentors - - piexif - - PYTHONDONTWRITEBYTECODE - - PYTHONUNBUFFERED - - primaryjoin - - selectinload - - tsquery - - uninstrument - - xdist - - zxcvbn - - - filename: "docs/**" - words: - - RTMP - - HIBP - - - filename: "frontend-app/**" - words: - # Technical terms - - ellipsize - - pressable - - pressables - - refetches - - worklets diff --git a/.devcontainer/app/devcontainer.json b/.devcontainer/app/devcontainer.json new file mode 100644 index 000000000..195069ebe --- /dev/null +++ b/.devcontainer/app/devcontainer.json @@ -0,0 +1,15 @@ +{ + "name": "relab-app", + "dockerComposeFile": ["../../compose.yaml", "../../compose.dev.yaml"], + "service": "app", + "runServices": ["app"], + "workspaceFolder": "/opt/relab/app", + "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], + "overrideCommand": true, + "postAttachCommand": "echo 'Relab app devcontainer ready.\\nUse this for focused app work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nApp: http://127.0.0.1:8011'", + "features": { + "ghcr.io/devcontainers/features/git:1.3.8": {}, + "ghcr.io/devcontainers-extra/features/expo-cli:1.0.11": {}, + "ghcr.io/guiyomh/features/just:0.1.0": {} + } +} diff --git a/.devcontainer/backend/devcontainer.json b/.devcontainer/backend/devcontainer.json index 47c944a62..4df75a04f 100644 --- a/.devcontainer/backend/devcontainer.json +++ b/.devcontainer/backend/devcontainer.json @@ -6,9 +6,10 @@ "workspaceFolder": "/opt/relab/backend", "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], "overrideCommand": true, - "postAttachCommand": "echo 'RELab backend devcontainer ready.\\nUse this for focused backend work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nAPI: http://localhost:8011'", + "postAttachCommand": "echo 'Relab backend devcontainer ready.\\nUse this for focused backend work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nAPI: http://127.0.0.1:8010'", "features": { - "ghcr.io/devcontainers/features/git:1": {} + "ghcr.io/devcontainers/features/git:1.3.8": {}, + "ghcr.io/guiyomh/features/just:0.1.0": {} }, "customizations": { "vscode": { diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0426dc04b..701082e4f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,14 +1,15 @@ { "name": "relab-fullstack", "dockerComposeFile": ["../compose.yaml", "../compose.dev.yaml"], - "service": "app-site", - "runServices": ["api", "redis", "postgres", "docs-site", "web-site", "app-site"], + "service": "app", + "runServices": ["api", "redis", "postgres", "docs", "www", "app"], "workspaceFolder": "/opt/relab", "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers-extra/features/expo-cli:1": {}, - "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {} + "ghcr.io/devcontainers/features/git:1.3.8": {}, + "ghcr.io/devcontainers-extra/features/expo-cli:1.0.11": {}, + "ghcr.io/jsburckhardt/devcontainer-features/uv:1.0.0": {}, + "ghcr.io/guiyomh/features/just:0.1.0": {} }, - "postAttachCommand": "echo 'RELab full-stack devcontainer ready.\\nPrimary workflow: run just install once, then use just dev / just ci from the repo root.\\nPlatform: http://localhost:8010\\nAPI: http://localhost:8011\\nDocs: http://localhost:8012\\nApp: http://localhost:8013'" + "postAttachCommand": "echo 'Relab full-stack devcontainer ready.\\nPrimary workflow: run just install once, then use just dev / just ci from the repo root.\\nAPI: http://127.0.0.1:8010\\nApp: http://127.0.0.1:8011\\nDocs: http://127.0.0.1:8012\\nLanding site: http://127.0.0.1:8013'" } diff --git a/.devcontainer/docs/devcontainer.json b/.devcontainer/docs/devcontainer.json index 2e47ce3c5..3c684077b 100644 --- a/.devcontainer/docs/devcontainer.json +++ b/.devcontainer/docs/devcontainer.json @@ -1,13 +1,14 @@ { "name": "relab-docs", "dockerComposeFile": ["../../compose.yaml", "../../compose.dev.yaml"], - "service": "docs-site", - "runServices": ["docs-site"], + "service": "docs", + "runServices": ["docs"], "workspaceFolder": "/opt/relab/docs", "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], - "postAttachCommand": "echo 'RELab docs devcontainer ready.\\nUse this for focused docs work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nDocs: http://localhost:8012'", + "postAttachCommand": "echo 'Relab docs devcontainer ready.\\nUse this for focused docs work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nDocs: http://127.0.0.1:8012'", "features": { - "ghcr.io/cirolosapio/devcontainers-features/alpine-bash:0": {}, - "ghcr.io/devcontainers/features/git:1": {} + "ghcr.io/cirolosapio/devcontainers-features/alpine-bash:0.0.3": {}, + "ghcr.io/devcontainers/features/git:1.3.8": {}, + "ghcr.io/guiyomh/features/just:0.1.0": {} } } diff --git a/.devcontainer/frontend-app/devcontainer.json b/.devcontainer/frontend-app/devcontainer.json deleted file mode 100644 index 8b158bae6..000000000 --- a/.devcontainer/frontend-app/devcontainer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "relab-frontend-app", - "dockerComposeFile": ["../../compose.yaml", "../../compose.dev.yaml"], - "service": "app-site", - "runServices": ["app-site"], - "workspaceFolder": "/opt/relab/frontend-app", - "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], - "overrideCommand": true, - "postAttachCommand": "echo 'RELab app devcontainer ready.\\nUse this for focused app work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nApp: http://localhost:8013'", - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers-extra/features/expo-cli:1": {} - } -} diff --git a/.devcontainer/frontend-web/devcontainer.json b/.devcontainer/frontend-web/devcontainer.json deleted file mode 100644 index 481fda8e3..000000000 --- a/.devcontainer/frontend-web/devcontainer.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "relab-frontend-web", - "dockerComposeFile": ["../../compose.yaml", "../../compose.dev.yaml"], - "service": "web-site", - "runServices": ["web-site"], - "workspaceFolder": "/opt/relab/frontend-web", - "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], - "overrideCommand": true, - "postAttachCommand": "echo 'RELab web devcontainer ready.\\nUse this for focused web work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nWeb: http://localhost:8010'", - "features": { - "ghcr.io/devcontainers/features/git:1": {} - } -} diff --git a/.devcontainer/www/devcontainer.json b/.devcontainer/www/devcontainer.json new file mode 100644 index 000000000..475c7132a --- /dev/null +++ b/.devcontainer/www/devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "relab-www", + "dockerComposeFile": ["../../compose.yaml", "../../compose.dev.yaml"], + "service": "www", + "runServices": ["www"], + "workspaceFolder": "/opt/relab/www", + "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], + "overrideCommand": true, + "postAttachCommand": "echo 'Relab web devcontainer ready.\\nUse this for focused web work; the root full-stack container is the default onboarding path.\\nRun just install if needed, then just dev or just check.\\nWeb: http://127.0.0.1:8013'", + "features": { + "ghcr.io/devcontainers/features/git:1.3.8": {}, + "ghcr.io/guiyomh/features/just:0.1.0": {} + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d446c14e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Version control +.git/ +.gitignore + +# Local environments and secrets +.env +.env.* +!.env.example +secrets/ +backups/ + +# Python environments and caches +.venv/ +**/.venv/ +__pycache__/ +**/__pycache__/ +.ruff_cache/ +.pytest_cache/ +.mypy_cache/ +.ty/ + +# Node dependencies and package-manager stores +node_modules/ +**/node_modules/ +.pnpm-store/ + +# Build outputs and test reports +dist/ +**/dist/ +coverage/ +**/coverage/ +playwright-report/ +**/playwright-report/ +test-results/ +**/test-results/ +.astro/ +**/.astro/ +.expo/ +**/.expo/ +web-build/ +**/web-build/ +reports/ +**/reports/ + +# Editor and OS files +.vscode/ +.idea/ +.DS_Store + +# Debug logs +npm-debug.* +yarn-debug.* +yarn-error.* +pnpm-debug.* diff --git a/.env.example b/.env.example index c4f9f3aca..6e26a1e03 100644 --- a/.env.example +++ b/.env.example @@ -1,49 +1,113 @@ -# Root .env — committed template for host-local secrets. -# Copy to `.env` on each deploy host (prod, staging, dev laptop) and fill in -# values. This file is auto-loaded by `docker compose` for `${VAR}` -# interpolation. +# Root .env.example - deploy operator checklist. +# Copy to `.env` on each deploy host and fill every required value. # -# Non-secret per-environment config (APP_ENV, URLs, worker counts, …) lives -# in the committed `.env.prod.compose` / `.env.staging.compose` files at repo -# root — do not duplicate them here. The justfile `prod_compose` / -# `staging_compose` recipes pick the right one per environment. +# Variable types: +# - Non-secret: safe to commit or place in `.env` when host-local review is easier. +# - Secret: never commit real values. Application/runtime secrets normally live +# in `secrets//`; only host/Compose helper secrets that must be +# interpolated by Compose are shown here. +# +# Committed prod/staging public environment identity lives in `deploy/env/*.compose.env`: +# ENVIRONMENT, API_PUBLIC_URL, APP_PUBLIC_URL, SITE_PUBLIC_URL, DOCS_PUBLIC_URL, +# FEATURED_PRODUCT_ID, and RESTIC_OFFSITE_REPOSITORY. -COMPOSE_BAKE=true +# --- Required host-local deploy inputs -------------------------------- -# --- Cloudflare tunnel ----------------------------------------------- -TUNNEL_TOKEN=your_token # 🔀 prod or staging token depending on this host +# Type: secret | Consumer: Cloudflare tunnel | Purpose: authenticates this host's tunnel. +CLOUDFLARE_TUNNEL_TOKEN=replace-me -# --- Central monitoring stack (optional) ---------------------------- -# All three vars below are OPTIONAL. Setting an endpoint enables the -# corresponding exporter; leaving it unset disables it. There is no separate -# on/off flag — the endpoint IS the switch. -# -# Auth pattern (when the central ingress is public-but-protected, e.g. behind a -# Cloudflare WAF rule requiring Authorization: Basic …): -# printf '%s:%s' relab "$SECRET" | base64 # → -# Loki driver has no custom-header config → auth goes in the URL. -# OTEL SDK reads OTEL_EXPORTER_OTLP_HEADERS → pass it there (URL-encode the space). -# -# One-time, per host, before enabling Loki shipping: -# docker plugin install grafana/loki-docker-driver:latest \ -# --alias loki --grant-all-permissions +# Type: non-secret | Consumer: backend auth | Purpose: OAuth public client IDs. +GOOGLE_OAUTH_CLIENT_ID=replace-me +GITHUB_OAUTH_CLIENT_ID=replace-me + +# Type: non-secret | Consumer: backend email | Purpose: outbound email identity. +# Use `smtp` for SMTP relays, or `microsoft_graph` for Microsoft 365 Graph mail. +# Compose requires the shared identity fields below. Backend startup validation +# enforces the provider-specific fields and matching secret files. +EMAIL_PROVIDER=smtp +EMAIL_FROM="Relab " +EMAIL_REPLY_TO=relab@example.org + +# Type: non-secret | Consumer: backend email | Purpose: required when EMAIL_PROVIDER=smtp. +SMTP_HOST=smtp.example.org +SMTP_USERNAME=relab@example.org +# Secret file required for SMTP: secrets//smtp_password + +# Type: non-secret | Consumer: backend email | Purpose: required when EMAIL_PROVIDER=microsoft_graph. +# MICROSOFT_GRAPH_TENANT_ID=replace-me +# MICROSOFT_GRAPH_CLIENT_ID=replace-me +# MICROSOFT_GRAPH_SENDER_USER=relab@example.org +# Secret file required for Microsoft Graph: secrets//microsoft_graph_client_secret + +# Type: non-secret | Consumer: backend bootstrap | Purpose: initial superuser identity. +BOOTSTRAP_SUPERUSER_EMAIL=admin@example.org + +# Application/runtime secrets go in `secrets//`, not in this file. +# Create missing secret files with: +# just deploy-secrets-template prod +# just deploy-secrets-template staging -# Logs → Loki push endpoint (auth embedded in URL) -# LOKI_URL=https://relab:@logs.cml-relab.org/loki/api/v1/push +# --- Optional host-local deploy inputs -------------------------------- -# Traces/metrics → OTLP HTTP collector (+ matching auth header) +# Type: secret | Consumer: telemetry | Purpose: optional central monitoring. +# Setting OTEL_EXPORTER_OTLP_ENDPOINT is the on/off switch for the whole telemetry path. +# It turns on the API's own OpenTelemetry exporter (traces, metrics, logs with trace +# context) AND auto-includes compose.telemetry.yml, which ships every other +# container's stdout to the same collector. +# +# The endpoint and token come from the monitoring stack operator +# (github.com/CMLPlatform/monitoring). One token, two consumers: compose folds it into +# the SDK's header format for the API, and Alloy reads it directly. +# +# All three values must be set together (`just deploy-secrets-check` enforces it), and +# the endpoint must be https:// — a plaintext endpoint would ship the token and every +# container log in cleartext with no error anywhere. +# +# TELEMETRY_EDGE_KEY is a second, weaker credential: the Cloudflare WAF-skip rule for +# otlp. matches it (infra/cloudflare-zone, TF_VAR_telemetry_edge_key must carry the +# same value), so the bearer token itself never appears in a Cloudflare ruleset +# expression. Rotate the key together with the zone rule; the token rotates with the +# collector, independently. +# +# NOTE: both values reach the api and alloy containers as plain environment variables, +# so anyone in this host's docker group can read them via `docker inspect`. That group +# is already root-equivalent on the host; noted so it is a decision, not a surprise. +# +# PROJECT — the identity every signal is labelled with, and what the central stack's +# ProjectTelemetrySilent rule keys on — is deliberately NOT here. It lives in +# deploy/env/.compose.env, because it is a property of the project, not the host. # OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.cml-relab.org -# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20 +# OTLP_AUTH_TOKEN= +# TELEMETRY_EDGE_KEY= +# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +# +# On a host with an NVIDIA card, add GPU metrics (utilisation, VRAM, temperature, power, +# throttle reasons and XID faults). Needs the nvidia container runtime. Only 1/true/yes +# turn it on; 0/false/unset leave it off. +# GPU_METRICS=1 + +# Type: non-secret | Consumer: backup | Purpose: local restic repository path. +# One value for every stack on this host. Two environments co-located on one +# machine would therefore share ${BACKUP_HOST_DIR}/restic; that fails closed (the +# second environment's restic password will not open the first's repository) but +# means only one of them gets backups. Give each host a single environment, or +# set an absolute per-environment path here. +BACKUP_HOST_DIR=./backups -# --- Backup destinations --------------------------------------------- -BACKUP_DIR=./backups +# Type: non-secret | Consumer: postgres | Purpose: existing cluster superuser. +# Only needed when the database volume comes from a cluster whose superuser is +# not named `postgres` — the healthcheck runs `pg_isready -U $POSTGRES_USER`, +# so a wrong name leaves postgres permanently unhealthy. +# POSTGRES_SUPERUSER=postgres -# Remote rsync backup config (for use of backend/scripts/backup/rsync_backup.sh script) -BACKUP_RSYNC_REMOTE_HOST=user@host # 🔀 -BACKUP_RSYNC_REMOTE_PATH=/path/to/remote/backup # 🔀 +# Type: non-secret | Consumer: backend | Purpose: per-user upload ceilings. +# The quota ledger counts real rows, so an instance whose existing corpus +# already exceeds a limit blocks that owner from uploading entirely. Raise +# before first start on an instance with a large existing dataset. +MAX_UPLOAD_FILES_PER_USER=5000 +MAX_UPLOAD_BYTES_PER_USER_MB=2048 -# Remote rclone backup config (for use of backend/scripts/backup/rclone_backup.sh script) -BACKUP_RCLONE_REMOTE=myremote:/path/to/remote/backup # 🔀 -BACKUP_RCLONE_MULTI_THREAD_STREAMS=16 -BACKUP_RCLONE_TIMEOUT=5m -BACKUP_RCLONE_USE_COOKIES=false +# Type: non-secret | Consumer: backend | Purpose: ClamAV upload scanning. +# Must agree with the `scanning` Compose profile: leaving this true without +# starting clamav fails uploads closed. Set false only as an accepted risk. +MALWARE_SCAN_ENABLED=true diff --git a/.env.prod.compose b/.env.prod.compose deleted file mode 100644 index 56c1e1240..000000000 --- a/.env.prod.compose +++ /dev/null @@ -1,11 +0,0 @@ -# .env.prod.compose — committed, non-secret Compose interpolation vars for prod. -# Loaded via `docker compose --env-file .env --env-file .env.prod.compose ...` -# (see justfile `prod_compose`). Secrets (TUNNEL_TOKEN, etc.) live in the -# gitignored root `.env` on each deploy host. - -APP_ENV=prod -COMPOSE_PROJECT_NAME=relab_prod -WEB_CONCURRENCY=4 -BUILD_MODE=prod -PUBLIC_SITE_URL=https://docs.cml-relab.org -CSP_API_ORIGIN=https://api.cml-relab.org diff --git a/.env.staging.compose b/.env.staging.compose deleted file mode 100644 index 63ac47d65..000000000 --- a/.env.staging.compose +++ /dev/null @@ -1,11 +0,0 @@ -# .env.staging.compose — committed, non-secret Compose interpolation vars for staging. -# Loaded via `docker compose --env-file .env --env-file .env.staging.compose ...` -# (see justfile `staging_compose`). Secrets (TUNNEL_TOKEN, etc.) live in the -# gitignored root `.env` on each deploy host. - -APP_ENV=staging -COMPOSE_PROJECT_NAME=relab_staging -WEB_CONCURRENCY=2 -BUILD_MODE=staging -PUBLIC_SITE_URL=https://docs-test.cml-relab.org -CSP_API_ORIGIN=https://api-test.cml-relab.org diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 06a8a7a1e..96d099f7c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,18 +1,20 @@ -# Contributing to RELab +# Contributing to Relab -Thanks for contributing. RELab is a research platform developed at CML, Leiden University. The goal of this document is simple: get you productive without making you dig through the repo first. +Thanks for contributing. Relab is a research platform developed at CML, Leiden University. This page +gets you productive without digging through the repo first. -This page is for code and documentation changes. If you mainly want to run or deploy the stack, see [Install & Self-Host](https://docs.cml-relab.org/architecture/install/). +This page is for code and documentation changes. If you mainly want to run or deploy the stack, see +[Install and self-host](https://docs.cml-relab.org/operations/install/). ## Start Here -| I want to... | Start here | -| --------------------------------------- | ---------------------------------------------------------------------------------------- | -| get the recommended working environment | [Devcontainer Setup](#devcontainer-setup) | -| run the full stack locally in Docker | [Docker Development](#docker-development) | -| work on one subrepo directly | [Local Development](#local-development) | -| understand the system first | [docs.cml-relab.org/architecture](https://docs.cml-relab.org/architecture/) | -| understand config ownership | [engineering configuration](https://docs.cml-relab.org/architecture/engineering-config/) | +| I want to... | Start here | +| --------------------------------------- | --------------------------------------------------------------------------- | +| get the recommended working environment | [Devcontainer Setup](#devcontainer-setup) | +| run the full stack locally in Docker | [Docker Development](#docker-development) | +| work on one subrepo directly | [Local Development](#local-development) | +| understand the system first | [docs.cml-relab.org/architecture](https://docs.cml-relab.org/architecture/) | +| understand config ownership | [Tooling and configuration](#tooling-and-configuration) | ## Code of Conduct @@ -32,14 +34,15 @@ This is the recommended path into the repo if you use VS Code. 1. Clone the repository. -1. Create the environment files. +1. Create local backend secret files. ```bash - cp backend/.env.dev.example backend/.env.dev - cp .env.example .env + just deploy-secrets-template dev ``` -1. Fill in the required values in `backend/.env.dev` and `.env`. + Create `backend/.env.dev` only when you need backend-only local overrides such as OAuth, email, + or bootstrap settings. Runtime secrets live in gitignored files under `secrets/dev/`. Local + PostgreSQL and Redis run through Docker Compose. 1. Reopen the repo in the `relab-fullstack` devcontainer. @@ -47,6 +50,8 @@ This is the recommended path into the repo if you use VS Code. ```bash just setup + just dev-db + just dev-migrate just dev ``` @@ -58,20 +63,20 @@ This is the recommended path into the repo if you use VS Code. ### Available Configurations -| Configuration | Purpose | -| -------------------- | -------------------------------------------------- | -| `relab-fullstack` | primary onboarding path for full stack development | -| `relab-backend` | focused backend work | -| `relab-frontend-app` | focused Expo app work | -| `relab-frontend-web` | focused public site work | -| `relab-docs` | focused docs work | +| Configuration | Purpose | +| ----------------- | -------------------------------------------------- | +| `relab-fullstack` | primary onboarding path for full stack development | +| `relab-backend` | focused backend work | +| `relab-app` | focused Expo app work | +| `relab-www` | focused public site work | +| `relab-docs` | focused docs work | ### Forwarded Ports -- Platform: -- Backend: +- API: +- App frontend: - Docs: -- App frontend: +- Landing site: - PostgreSQL: `5432` - Redis: `6379` @@ -79,11 +84,25 @@ This is the recommended path into the repo if you use VS Code. Use this when you want the full stack without configuring each subrepo manually. -1. Create the backend environment file. +1. Create local backend secret files. ```bash - cp backend/.env.dev.example backend/.env.dev - cp .env.example .env + just deploy-secrets-template dev + ``` + + `backend/.env.dev` is optional backend-app-only local configuration. Root `.env` is for deploy + hosts, and runtime secrets live in `secrets/dev/`. A typical override file only contains + integration-facing values: + + ```text + GOOGLE_OAUTH_CLIENT_ID=google-oauth-client-id + GITHUB_OAUTH_CLIENT_ID=github-oauth-client-id + EMAIL_PROVIDER=smtp + SMTP_HOST=smtp.example.com + SMTP_USERNAME=you@example.com + EMAIL_FROM=Your Name + EMAIL_REPLY_TO=you@example.com + BOOTSTRAP_SUPERUSER_EMAIL=you@example.com ``` 1. Install local tooling. @@ -92,24 +111,31 @@ Use this when you want the full stack without configuring each subrepo manually. just setup ``` -1. Start the stack with file watching. +1. Start the containerized database and cache. ```bash - just dev + just dev-db ``` -1. Run migrations on first start. +1. Run migrations. ```bash just dev-migrate ``` +1. Start the stack with file watching. + + ```bash + just dev + ``` + ### Local Service URLs -- Platform: -- Backend: -- Docs: -- App frontend: +The services run on the same URLs as the [forwarded ports](#forwarded-ports) above. + +Docker development ports bind to localhost. If you want to test the Expo app from another phone, +tablet, or computer over your LAN, run the Expo server directly from `app/` with `just dev` instead +of using the Docker app service. ### Useful Commands @@ -130,7 +156,7 @@ Install: - [Git](https://git-scm.com/) - [uv](https://docs.astral.sh/uv/getting-started/installation) - [just](https://just.systems/man/en/) recommended -- Node.js LTS for the frontend subrepos +- Node.js (version from `.tool-versions`) for the frontend subrepos Then run: @@ -155,7 +181,72 @@ just security ``` Use `just --list` in any directory to see what is available there. -Manifest ownership, env rules, and infra review guidelines live in the [engineering configuration docs](https://docs.cml-relab.org/architecture/engineering-config/). + +## Tooling and Configuration + +Use `.tool-versions` as the source of truth for local tool versions. Do not duplicate exact versions +in docs unless a manifest or generated file requires it. + +Each configuration surface has one job: + +- root `justfile`: repo-wide orchestration and cross-project workflows +- subrepo `justfile`: local commands for one project +- `pyproject.toml`: Python dependencies and Python tool configuration +- `pnpm-workspace.yaml`: JavaScript workspace membership, package-manager policy, and shared tooling + catalogs +- `package.json`: JavaScript package dependencies and script wrappers +- env files: runtime and build-time configuration only +- GitHub workflow YAML: CI/CD wiring; move complex logic to versioned scripts +- `.pre-commit-config.yaml`: repository policy hooks, run by [`prek`](https://github.com/j178/prek) + (a drop-in replacement for `pre-commit`). `just setup` installs the git hooks; `just pre-commit` + runs them all + +Keep new settings in the smallest surface that actually needs them. If a change adds or renames env +vars, update the examples, validation rules, and affected docs in the same PR. + +## Quality Controls + +Run the relevant subrepo checks before opening a pull request: + +- backend: unit or integration tests, Ruff, and `ty` +- app: Jest, TypeScript, and lint checks +- www: Vitest, Astro checks, and Playwright where browser behavior changes +- docs: formatting and build smoke check + +For cross-repo or policy changes, also run `just ci` from the root. GitHub Actions covers dependency +review, container scanning, repository hygiene, and release artifact checks on every push. + +### Accessibility + +Accessibility is checked automatically. Every axe scan uses the same WCAG 2.0-2.2 +A/AA rule tags and strips animations for deterministic runs. `target-size` (2.5.8) +is the only 2.2-only rule axe-core ships and it is enforced; 2.4.11 Focus Not +Obscured and 2.4.13 Focus Appearance have no axe rule and are checked by hand. + +| Surface | Runtime axe scan | Static lint (every PR) | +| ------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `www/` | landing + privacy, contrast checked; ARIA landmark snapshots: `just www/test-e2e` | Biome `a11y` | +| `docs/` | homepage + getting-started `
`, contrast checked; snapshots: `just docs/test-e2e` | Biome `a11y` | +| `app/` | products list + detail on the Expo web build (`color-contrast` off): `just app/test-e2e-full-stack` | Biome `a11y` + `eslint-plugin-react-native-a11y` | + +The `www/` and `docs/` axe scans gate every PR that touches `www/`, `docs/`, or shared files. The +`app/` scan needs the full Docker backend, so it runs post-merge or on demand; the per-PR net for +the app is `eslint-plugin-react-native-a11y`, which validates RN accessibility props on each lint +run. See [validate.yml](workflows/validate.yml). + +These are automated checks, so a passing run is a floor, not proof of WCAG +conformance. The `app/` scan runs against the react-native-web build, so it does +not exercise native VoiceOver/TalkBack. Two `app/` lint rules are deferred +pending a labelling pass (`has-valid-accessibility-descriptors`, +`has-valid-accessibility-ignores-invert-colors`, see `../app/eslint.config.mjs`). + +## Security + +For changes that touch authentication, authorization, uploads, device flows, admin APIs, secrets, or +personal data, include security context in the pull request and update the relevant docs if behavior +changes. See [SECURITY.md](SECURITY.md) for the reviewer checklist. + +Use `just security` for local diagnosis. ## Backend Setup @@ -164,23 +255,32 @@ The backend lives in `backend/`. ### Requirements - `uv` -- PostgreSQL -- Redis recommended; required for production-like auth behavior +- Docker Compose for local PostgreSQL and Redis ### Setup ```bash cd backend uv sync --all-groups --frozen -cp .env.dev.example .env.dev -./scripts/local_setup.sh +cd .. +just deploy-secrets-template dev +just dev-db +just dev-migrate +cd backend just dev ``` -The API is available at . +`ENVIRONMENT` is required — the backend fails fast if it's unset. `just dev` does not export it, so +set `ENVIRONMENT=dev` in `backend/.env.dev` or export it in your shell before running `just dev`. + +The API is available at . Use `SEED_DUMMY_DATA=true just dev-migrate` when +you want sample data. Create `backend/.env.dev` only when you need backend-only local overrides such +as OAuth, email, or bootstrap settings. -- Public docs: -- Full docs: after authenticating as a superuser +- Public API reference: +- Device API reference: +- Development/testing-only JSON contracts: and + ### OpenAPI Examples @@ -188,31 +288,21 @@ Keep examples centralized and predictable: - Domain-specific examples go in `examples.py` (e.g., `backend/app/api/data_collection/examples.py`) - Cross-domain examples go in `backend/app/api/common/openapi_examples.py` -- Use `*_EXAMPLE` for single payloads, `*_EXAMPLES` for schema lists, `*_OPENAPI_EXAMPLES` for FastAPI named maps +- Use `*_EXAMPLE` for single payloads, `*_EXAMPLES` for schema lists, `*_OPENAPI_EXAMPLES` for + FastAPI named maps - In routers, pass examples via `openapi_examples=...` parameter - Update `backend/tests/integration/api/test_openapi_endpoints.py` when changing examples -### Backend Module Structure - -Keep modules small, explicit, and domain-shaped: - -- One top-level package per domain: `auth`, `background_data`, `data_collection`, `file_storage`, `newsletter`, `plugins/rpi_cam` -- Prefer flat modules first: `crud.py`, `dependencies.py`, `examples.py`, `exceptions.py`, `filters.py`, `models.py`, `schemas.py` -- Use `routers/` only when multiple route files exist; entrypoint goes in `routers/__init__.py` -- Use `models/` only when both ORM models and storage primitives exist; expose public surface at `models/__init__.py` -- Use `services/` and `utils/` only when they reflect a real boundary; delete pass-through layers when simple enough to call directly -- Keep shared behavior in `backend/app/api/common/` - ### Backend Test Architecture -Keep the backend suite organized by execution cost first, then by feature: - -- `backend/tests/unit/`: pure unit tests only, with no database session, testcontainers startup, or real app lifespan -- `backend/tests/integration/db/`: CRUD, ORM, and persistence behavior against the real schema -- `backend/tests/integration/api/`: HTTP endpoint behavior against the ASGI app -- `backend/tests/integration/flows/`: a small set of cross-boundary, multi-step scenarios +The backend suite is organized by execution cost: -Use the backend test commands that match those tiers: +| Tier | Path | When to use | +| --------------- | ---------------------------------- | ----------------------------------------------------------------------- | +| unit | `backend/tests/unit/` | pure logic with mocks/stubs only, no database or app lifespan | +| integration/db | `backend/tests/integration/db/` | behavior that depends on SQLAlchemy queries, migrations, or constraints | +| integration/api | `backend/tests/integration/api/` | HTTP behavior tested against the ASGI app; one behavior per test | +| flows | `backend/tests/integration/flows/` | full multi-step cross-boundary scenarios | ```bash cd backend @@ -223,72 +313,50 @@ just test-flows just test-ci ``` -Fixture conventions should stay explicit and descriptive: - -- `db_session` for database access -- `db_user` and `db_superuser` for persisted auth principals -- `api_client`, `api_client_user`, and `api_client_superuser` for HTTP tests -- `redis_client` or feature-local Redis fixtures where applicable - -Do not add or reintroduce `session` or `superuser` as in-repo fixture aliases. Use the canonical `db_session` and `db_superuser` names directly. - -Do not add compatibility-only test coverage for fixture aliases, re-export modules, or pass-through wrappers unless they protect a deliberate stable external contract. Prefer testing behavior at the canonical fixture or module surface. - -Keep fixtures close to the tests that use them when the reuse is local. Reserve `backend/tests/conftest.py` for bootstrap concerns such as testcontainers, test database setup, and global logging behavior. Avoid broad `autouse` fixtures unless they are true cross-suite safety rails. - -Keep API tests focused on one behavior per test. Avoid multi-step CRUD journeys in `tests/integration/api/`; move those broader stories to `tests/integration/flows/`. - -Path is the primary source of truth for where a test belongs: - -- Choose `unit` when the test can run with mocks/stubs only. -- Choose `integration/db` when the behavior depends on SQLAlchemy queries, migrations, or constraints. -- Choose `integration/api` when the behavior is expressed as HTTP requests against the app. -- Choose `flows` only when the value comes from verifying a full multi-step journey. - -If a test file starts growing into a mixed “god file”, split it by behavior before adding more cases. +Standard fixture names: `db_session`, `db_user`, `db_superuser`, `api_client`, `api_client_user`, +`api_client_superuser`, `redis_client`. ## Frontend Setup -### `frontend-app` +### `app` ```bash -cd frontend-app +cd app pnpm install --frozen-lockfile just dev ``` -The Expo dev server runs on . +The Expo dev server runs on . -If you are using a physical device or a non-default backend URL, create `frontend-app/.env.local` and set `EXPO_PUBLIC_API_URL`. +If you are using a physical device or a non-default backend URL, create `app/.env.local` and set +`EXPO_PUBLIC_API_URL`. -To enable Google OAuth on web, set `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID` in your env file to the web client ID from Google Cloud Console. The authorized redirect URI for your environment must also be registered there (e.g. `http://localhost:8013/login` for local dev). +To enable Google OAuth on web, set `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID` in your env file to the web +client ID from Google Cloud Console. The authorized redirect URI for your environment must also be +registered there (e.g. `http://127.0.0.1:8011/login` for local dev). ### Regenerating API types -The frontend TypeScript API types are autogenerated from the backend OpenAPI schema and written to `frontend-app/src/types/api.generated.ts`. - -When working on backend API changes, regenerate the types: +After backend API changes, regenerate the TypeScript types from the OpenAPI schema: ```bash -# from repo root -cd frontend-app -pnpm run codegen:api - -# regenerate and redact embedded JWT examples (recommended) -pnpm run codegen +cd app +just codegen # regenerate and redact embedded JWT examples ``` -You can also run `just codegen` inside `frontend-app` (after `just install`) which runs the regeneration and redaction steps. +See [app/README.md](../app/README.md) for more options. -### `frontend-web` +### `www` ```bash -cd frontend-web +cd www pnpm install --frozen-lockfile just dev ``` -The Astro dev server runs on . +The Astro dev server runs on . Use the numeric loopback +host when developing through VS Code Remote port forwarding; Firefox can be +unreliable with forwarded `localhost` URLs. ## Docs Setup @@ -300,9 +368,7 @@ pnpm install --frozen-lockfile just dev ``` -The docs site runs on . - -The docs app is the canonical home for public guides, architecture reference, and project context. Keep repo-level setup text in this file short and link back to the docs site when deeper explanation belongs there. +The docs site runs on . ## Development Workflow @@ -324,7 +390,9 @@ If you are new to the repo, start with the architecture docs before making struc just ci ``` -1. Push your branch and open a PR. +1. Push your branch. + +1. Open a pull request. 1. Address review feedback. @@ -336,120 +404,43 @@ Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): (): ``` -## CI - -The repo uses GitHub Actions for: - -- normal CI -- security checks -- release automation - -Locally, the important commands are: - -- `just ci` -- `just test` -- `just test-integration` -- `just security` - ## Backend Development -### Backend Code Style - -The backend uses: - -- Ruff for linting and formatting -- Ty for static type checking -- ShellCheck for shell scripts - -Useful commands from `backend/`: - -```bash -just lint -just format -just fix -just typecheck -just shellcheck -just check -``` - -### Backend Testing - -Useful commands from `backend/`: - -```bash -just test -just test-unit -just test-integration -just test-cov -``` - -When adding backend behavior, add tests close to the change. Prefer small unit tests unless the behavior really depends on routing, persistence, or integration boundaries. - -### Database Migrations - -When changing schema: - -1. Create a migration. - - ```bash - cd backend - just migrate-create "describe the change" - ``` - -1. Review the generated file in `alembic/versions/`. - -1. Apply it. - - ```bash - just migrate - ``` - -For Docker-based runs, you can also use `just dev-migrate` from the repo root. - -### Email Templates - -MJML source templates live in `backend/app/templates/emails/src/`. Compiled HTML lives in `backend/app/templates/emails/build/`. - -Do not edit compiled output directly. - -To rebuild email templates: - -```bash -cd backend -just compile-email -``` +For code style, test commands, migration workflow, and email templates, see +[backend/README.md](../backend/README.md). ## Frontend Development ### Frontend Code Style -- `frontend-app` uses Expo linting and TypeScript-based tooling -- `frontend-web` uses Biome and Astro validation +- `app` uses Expo linting and TypeScript-based tooling +- `www` uses Biome and Astro validation - follow the existing folder structure and naming patterns - prefer consistency with the current UI and component patterns over novelty ### Frontend Testing -For `frontend-app`: +For `app`: ```bash -cd frontend-app +cd app just test just test-ci just check ``` -For `frontend-web`: +For `www`: ```bash -cd frontend-web +cd www just test just test-ci just test-e2e just check ``` -When adding a new public-facing page to `frontend-web`, add at least one browser test. When adding app behavior in `frontend-app`, add Jest coverage for the new logic or screen behavior. +When adding a new public-facing page to `www`, add at least one browser test. When adding app +behavior in `app`, add Jest coverage for the new logic or screen behavior. ## Docs Development @@ -476,4 +467,5 @@ just format ## License -By contributing, you agree that your contributions are licensed under the project [LICENSE](LICENSE). +By contributing, you agree that your contributions are licensed under the project +[LICENSE](LICENSE). diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index c738973f2..e0559d31b 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -9,8 +9,8 @@ body: label: Area options: - backend - - frontend-web - - frontend-app + - www + - app - docs - infrastructure - other diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 075516cce..5ea873f57 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -9,8 +9,8 @@ body: label: Area options: - backend - - frontend-web - - frontend-app + - www + - app - docs - infrastructure - cross-cutting diff --git a/.github/ISSUE_TEMPLATE/internal-ticket.yml b/.github/ISSUE_TEMPLATE/internal-ticket.yml index 98c7853f9..9bb09aef3 100644 --- a/.github/ISSUE_TEMPLATE/internal-ticket.yml +++ b/.github/ISSUE_TEMPLATE/internal-ticket.yml @@ -9,8 +9,8 @@ body: label: Area options: - backend - - frontend-web - - frontend-app + - www + - app - docs - infrastructure - cross-cutting diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a1bc214ad..d5f8b8c81 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -29,6 +29,17 @@ _What problem does this solve or why is it worth merging?_ - [ ] Documentation updated (if applicable) - [ ] Database migrations created (if applicable) +## Security, if relevant + +For changes touching auth, permissions, browser rendering, dynamic URLs, uploads/media, RPi camera or device flows, admin APIs, deployment, secrets, dependencies, or personal data: + +- Assets affected: +- Trust boundary changed: +- Secure default/failure mode: +- Controls and tests: + +See the maintainer checklist in [SECURITY.md](SECURITY.md). + ## Notes for reviewers _Add rollout notes, tradeoffs, follow-up work, or links to related issues._ diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 8a3d7de39..850cc4b45 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -14,6 +14,113 @@ Instead, email [relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl) with: - We aim to acknowledge reports within 5 business days. - We aim to validate and triage confirmed issues as quickly as possible. -- For confirmed vulnerabilities, we will coordinate a fix and responsible disclosure timeline with the reporter where practical. +- For confirmed vulnerabilities, we will coordinate a fix and responsible disclosure timeline with + the reporter where practical. Please include enough detail for us to reproduce the problem. That saves time for everyone. + +## Security Baseline + +Relab uses [OWASP ASVS](https://github.com/OWASP/ASVS) as the application-security baseline and the +[OWASP Secure Product Design](https://cheatsheetseries.owasp.org/cheatsheets/Secure_Product_Design_Cheat_Sheet.html) +lens for product decisions. Keep controls simple, reviewable, and documented near the behavior they +protect. + +For the deployed security posture, trust-boundary model, egress policy, browser runtime policy, and +supply-chain artifact posture, see +[Security and hardening](https://docs.cml-relab.org/operations/security/). + +Review security-sensitive changes against this baseline: + +- Context: self-hosted research and data-collection platform. +- Components: backend, app, web, docs, PostgreSQL, Redis, storage, backups, OAuth, email, YouTube, + and RPi camera integrations. +- Connections: clients and devices enter through the API; PostgreSQL and Redis stay on the internal + data network; external providers are explicit trust boundaries. +- Code: authorization, validation, upload checks, browser security headers, and tests live close to + the behavior they protect. +- Configuration: secrets, Compose policy, HTTPS, least-privilege database roles, and secure runtime + defaults are source-controlled where practical. + +Security-sensitive areas: + +- authentication and OAuth +- public read APIs +- authenticated mutation APIs — create endpoints accept an `Idempotency-Key` header and cache the + response in Redis for one hour. + - The cache entry is scoped by authenticated user id, endpoint (parent id included), and key, so + a cached response can never be replayed across accounts or targets. + - The entry also stores a hash of the request body: reusing a key with a different payload is + rejected with `422` instead of replaying the earlier record. + - If Redis is unreachable the request fails closed with `503` rather than risking a duplicate. +- uploads and media — the `/uploads` mount serves stored bytes with a content-hashed, + immutable cache policy and `Cross-Origin-Resource-Policy: same-site`. That policy is + relaxed to `cross-origin` for `/uploads` alone in the `dev` and `testing` environments, + where the API and the frontends sit on different ports of `127.0.0.1` and Chromium blocks + the loads. The relaxation is derived from the environment, not configured, so staging and + production cannot opt into it. +- admin APIs +- RPi camera device APIs and WebSocket relay +- backups, secrets, logs, and telemetry +- release and security artifacts + +Valuable assets include accounts, profile/privacy settings, research records, uploaded media/files, +OAuth and YouTube tokens, RPi camera credentials, refresh-token state, database dumps, backup +material, and runtime secrets. + +Public read APIs are intentionally public: the product/component catalog and its research content +are world-readable by design, since the platform exists to publish that data. `profile_visibility` +hides owner identity attribution only — it is not a control over the underlying research content, +and must never be treated as one. + +Account privileges are three independent things, and conflating any two of them is a privilege +escalation: + +- `is_verified` gates whether an account may create records at all. +- `is_superuser` grants the `/admin` routes. It does **not** imply trust with research data. +- `role` (`contributor` by default, `lab`) is the contributor tier. It gates non-image + research-file upload and selects the upload quota tier. A superuser who is not `lab` is refused + a research-file upload exactly like any other contributor. + +Roles are assigned only by a superuser through `PUT /v1/admin/users/{user_id}/role`, which records +an audit event. `role` is deliberately absent from `UserUpdate`: fastapi-users' safe update path +strips a fixed set of privileged fields, so any new field on that schema would flow through +self-service `PATCH /users/me` — keeping role off the schema makes that escalation unrepresentable +rather than filtered. New and backfilled accounts start at `contributor`, so the tier fails closed. + +## Automated Checks + +Supply-chain and code-security checks: + +- Dependencies: GitHub Dependency Review / Dependency Graph and Renovate. +- Runtime images: Trivy scans and SPDX JSON SBOM artifacts. +- Infrastructure as code: Trivy misconfiguration scans for supported repo config files, OpenTofu + validates Cloudflare edge config, plus Relab Compose render and deploy secret path checks. +- Source code: CodeQL. +- Secrets: Gitleaks. +- GitHub Actions workflows: actionlint and Zizmor. +- Repository hygiene: OpenSSF Scorecard. + +Use `just security` for local maintainer diagnosis: it runs the dependency audits and the Gitleaks +secret scan. Trivy, CodeQL, actionlint, Zizmor, and Scorecard run in CI. + +Release SBOM assets are attested as files and uploaded with GitHub releases. + +## Maintainer Review + +Automated checks do not replace reviewer judgment. For changes that touch authentication, +authorization, uploads/media, RPi camera or device flows, admin APIs, deployment, secrets, +dependencies, or personal data, confirm: + +- authorization is enforced server-side, not only hidden in a client — hiding an upload affordance + from a client that lacks the role is a UX choice, never the control +- input is validated at API, upload, form, and device boundaries +- browser-rendered values stay on framework escaping paths; raw HTML sinks and dynamic URLs are + isolated, validated, and tested +- logs do not include tokens, passwords, private URLs, OAuth material, or other sensitive values +- secure defaults fail closed in production and staging +- auth, permission, upload, and device-flow behavior has focused test coverage + +Filtering a route out of a public OpenAPI schema hides it from the docs, not from attackers. +Authorization must be enforced in backend dependencies and services regardless of which schemas list +the route. diff --git a/.github/actions/check-required-jobs/action.yml b/.github/actions/check-required-jobs/action.yml new file mode 100644 index 000000000..d1c20dc16 --- /dev/null +++ b/.github/actions/check-required-jobs/action.yml @@ -0,0 +1,42 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +name: Check Required Jobs +description: Fail when any required upstream job failed, was cancelled, or returned an unexpected result. + +inputs: + results: + # Do not write an ${{ ... }} expression here: Actions evaluates descriptions, + # and `needs` is not a valid context inside a composite action. + description: The caller's `needs` context serialized as JSON via toJSON(). + required: true + +runs: + using: composite + steps: + - name: Check job results + shell: bash + env: + RESULTS_JSON: ${{ inputs.results }} + run: | + python3 - <<'PY' + import json + import os + import sys + + accepted = {"success", "skipped"} + failed = False + # Callers hand over the whole `needs` context, so the job list has exactly one + # home: the `needs:` block. Each value is {"result": ..., "outputs": {...}}. + needs = json.loads(os.environ["RESULTS_JSON"]) + + if not needs: + print("No upstream jobs to check — `needs` was empty.", file=sys.stderr) + sys.exit(1) + + for job, data in sorted(needs.items()): + result = data["result"] + print(f"{job}: {result}") + if result not in accepted: + failed = True + + sys.exit(1 if failed else 0) + PY diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml new file mode 100644 index 000000000..006b8b492 --- /dev/null +++ b/.github/actions/detect-changes/action.yml @@ -0,0 +1,84 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +name: Detect Changes +description: | + Run dorny/paths-filter once and expose the raw per-area flags plus event metadata. + Each calling workflow builds its own job-level decision from these outputs; the + schedule/dispatch/renovate logic differs per workflow (CI vs security), so it lives + in the workflow, not here. + +outputs: + # --- raw path flags (from .github/path-filters.yml) --- + shared: + description: Shared files touching every subrepo (e.g. .github, compose, justfile). + value: ${{ steps.filter.outputs.shared }} + policy: + description: Policy/config files (pre-commit, editorconfig, semgrep, etc.). + value: ${{ steps.filter.outputs.policy }} + backend: + description: Backend source or compose files. + value: ${{ steps.filter.outputs.backend }} + www: + description: www (landing/marketing) source or compose files. + value: ${{ steps.filter.outputs.www }} + app: + description: app (product frontend) source or compose files. + value: ${{ steps.filter.outputs.app }} + docs: + description: Docs-site source or compose files. + value: ${{ steps.filter.outputs.docs }} + containers: + description: Dockerfile / lockfile changes relevant to container images. + value: ${{ steps.filter.outputs.containers }} + smoke-backend: + description: Backend image build inputs (Dockerfiles, uv.lock). + value: ${{ steps.filter.outputs.smoke-backend }} + smoke-www: + description: www image build inputs (Dockerfile, Caddyfile, workspace manifests). + value: ${{ steps.filter.outputs.smoke-www }} + smoke-app: + description: app image build inputs (Dockerfile, Caddyfile, workspace manifests). + value: ${{ steps.filter.outputs.smoke-app }} + smoke-docs: + description: docs image build inputs (Dockerfile, Caddyfile, workspace manifests). + value: ${{ steps.filter.outputs.smoke-docs }} + smoke-orchestration: + description: Compose wiring, deploy inputs, and the deploy/backup ops scripts. + value: ${{ steps.filter.outputs.smoke-orchestration }} + + # --- event metadata (for schedule/dispatch/renovate routing) --- + is-schedule: + description: "'true' if the workflow was triggered by a schedule." + value: ${{ steps.event.outputs.is-schedule }} + is-dispatch: + description: "'true' if the workflow was triggered via workflow_dispatch." + value: ${{ steps.event.outputs.is-dispatch }} + is-merge-group: + description: "'true' if the workflow was triggered by a merge queue group." + value: ${{ steps.event.outputs.is-merge-group }} + is-renovate-pr: + description: "'true' if the current PR was opened by renovate[bot]." + value: ${{ steps.event.outputs.is-renovate-pr }} + +runs: + using: composite + steps: + - name: Detect changed paths + id: filter + # merge_group events carry no base/head PR context, so there is nothing to + # diff against — callers treat that event as "run everything". + if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' && github.event_name != 'merge_group' + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d + with: + filters: .github/path-filters.yml + + - name: Publish event metadata + id: event + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + echo "is-schedule=$([[ "$EVENT_NAME" == "schedule" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "is-dispatch=$([[ "$EVENT_NAME" == "workflow_dispatch" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "is-merge-group=$([[ "$EVENT_NAME" == "merge_group" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "is-renovate-pr=$([[ "$EVENT_NAME" == "pull_request" && "$PR_AUTHOR" == "renovate[bot]" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/detect-ci-changes/action.yml b/.github/actions/detect-ci-changes/action.yml deleted file mode 100644 index 0fc1d32ee..000000000 --- a/.github/actions/detect-ci-changes/action.yml +++ /dev/null @@ -1,76 +0,0 @@ -# yaml-language-server: $schema=https://json.schemastore.org/github-action.json -# spell-checker: ignore dorny -name: Detect CI Changes -description: Determine which CI jobs should run for this monorepo. - -outputs: - run-policy: - description: Whether the policy job should run. - value: ${{ steps.decide.outputs.run-policy }} - run-backend: - description: Whether the backend job should run. - value: ${{ steps.decide.outputs.run-backend }} - run-frontend-web: - description: Whether the frontend-web job should run. - value: ${{ steps.decide.outputs.run-frontend-web }} - run-frontend-app: - description: Whether the frontend-app job should run. - value: ${{ steps.decide.outputs.run-frontend-app }} - run-docs: - description: Whether the docs job should run. - value: ${{ steps.decide.outputs.run-docs }} - run-e2e: - description: Whether the full-stack E2E job should run. - value: ${{ steps.decide.outputs.run-e2e }} - -runs: - using: composite - steps: - - name: Detect changed paths - id: filter - if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d - with: - filters: .github/path-filters.yml - - - name: Decide which CI jobs should run - id: decide - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - SHARED: ${{ steps.filter.outputs.shared }} - POLICY: ${{ steps.filter.outputs.policy }} - BACKEND: ${{ steps.filter.outputs.backend }} - FRONTEND_WEB: ${{ steps.filter.outputs.frontend-web }} - FRONTEND_APP: ${{ steps.filter.outputs.frontend-app }} - DOCS: ${{ steps.filter.outputs.docs }} - run: | - # Scheduled runs only exercise docker-smoke; skip all other jobs. - if [[ "$EVENT_NAME" == "schedule" ]]; then - echo "run-policy=false" >> "$GITHUB_OUTPUT" - echo "run-backend=false" >> "$GITHUB_OUTPUT" - echo "run-frontend-web=false" >> "$GITHUB_OUTPUT" - echo "run-frontend-app=false" >> "$GITHUB_OUTPUT" - echo "run-docs=false" >> "$GITHUB_OUTPUT" - echo "run-e2e=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Manual dispatch runs exercise everything. - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - echo "run-policy=true" >> "$GITHUB_OUTPUT" - echo "run-backend=true" >> "$GITHUB_OUTPUT" - echo "run-frontend-web=true" >> "$GITHUB_OUTPUT" - echo "run-frontend-app=true" >> "$GITHUB_OUTPUT" - echo "run-docs=true" >> "$GITHUB_OUTPUT" - echo "run-e2e=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Path-based filtering for push/pull_request events. - echo "run-policy=$([[ "$SHARED" == "true" || "$POLICY" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-backend=$([[ "$SHARED" == "true" || "$BACKEND" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-frontend-web=$([[ "$SHARED" == "true" || "$FRONTEND_WEB" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-frontend-app=$([[ "$SHARED" == "true" || "$FRONTEND_APP" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-docs=$([[ "$SHARED" == "true" || "$DOCS" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-e2e=$([[ "$SHARED" == "true" || "$BACKEND" == "true" || "$FRONTEND_APP" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/detect-security-changes/action.yml b/.github/actions/detect-security-changes/action.yml deleted file mode 100644 index fb528f842..000000000 --- a/.github/actions/detect-security-changes/action.yml +++ /dev/null @@ -1,100 +0,0 @@ -# yaml-language-server: $schema=https://json.schemastore.org/github-action.json -# spell-checker: ignore dorny -name: Detect Security Changes -description: Determine which security jobs should run for this monorepo. - -outputs: - run-root-python: - description: Whether the root Python audit should run. - value: ${{ steps.decide.outputs.run-root-python }} - run-backend-python: - description: Whether the backend Python audit should run. - value: ${{ steps.decide.outputs.run-backend-python }} - run-docs-node: - description: Whether the docs Node audit should run. - value: ${{ steps.decide.outputs.run-docs-node }} - run-frontend-app-node: - description: Whether the frontend-app Node audit should run. - value: ${{ steps.decide.outputs.run-frontend-app-node }} - run-frontend-web-node: - description: Whether the frontend-web Node audit should run. - value: ${{ steps.decide.outputs.run-frontend-web-node }} - run-container-images: - description: Whether container image scanning and SBOM generation should run. - value: ${{ steps.decide.outputs.run-container-images }} - container-security-matrix: - description: JSON matrix describing which container images should be scanned. - value: ${{ steps.decide.outputs.container-security-matrix }} - -runs: - using: composite - steps: - - name: Detect changed paths - id: filter - if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d - with: - filters: .github/path-filters.yml - - - name: Decide which security jobs should run - id: decide - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - SHARED: ${{ steps.filter.outputs.shared }} - ROOT_PYTHON: ${{ steps.filter.outputs.root-python }} - BACKEND: ${{ steps.filter.outputs.backend }} - DOCS: ${{ steps.filter.outputs.docs }} - FRONTEND_APP: ${{ steps.filter.outputs.frontend-app }} - FRONTEND_WEB: ${{ steps.filter.outputs.frontend-web }} - CONTAINERS: ${{ steps.filter.outputs.containers }} - run: | - matrix_all='{"include":[ - {"service":"api","context":"./backend","dockerfile":"./backend/Dockerfile","image_ref":"relab/api:${{ github.sha }}"}, - {"service":"docs-site","context":"./docs","dockerfile":"./docs/Dockerfile","image_ref":"relab/docs-site:${{ github.sha }}"}, - {"service":"app-site","context":"./frontend-app","dockerfile":"./frontend-app/Dockerfile","image_ref":"relab/app-site:${{ github.sha }}"}, - {"service":"web-site","context":"./frontend-web","dockerfile":"./frontend-web/Dockerfile","image_ref":"relab/web-site:${{ github.sha }}"} - ]}' - - if [[ "$EVENT_NAME" == "schedule" || "$EVENT_NAME" == "workflow_dispatch" ]]; then - echo "run-root-python=true" >> "$GITHUB_OUTPUT" - echo "run-backend-python=true" >> "$GITHUB_OUTPUT" - echo "run-docs-node=true" >> "$GITHUB_OUTPUT" - echo "run-frontend-app-node=true" >> "$GITHUB_OUTPUT" - echo "run-frontend-web-node=true" >> "$GITHUB_OUTPUT" - echo "run-container-images=true" >> "$GITHUB_OUTPUT" - echo "container-security-matrix=$matrix_all" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "run-root-python=$([[ "$SHARED" == "true" || "$ROOT_PYTHON" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-backend-python=$([[ "$SHARED" == "true" || "$BACKEND" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-docs-node=$([[ "$SHARED" == "true" || "$DOCS" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-frontend-app-node=$([[ "$SHARED" == "true" || "$FRONTEND_APP" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-frontend-web-node=$([[ "$SHARED" == "true" || "$FRONTEND_WEB" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "run-container-images=$([[ "$SHARED" == "true" || "$CONTAINERS" == "true" || "$BACKEND" == "true" || "$DOCS" == "true" || "$FRONTEND_APP" == "true" || "$FRONTEND_WEB" == "true" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - include_entries=() - - if [[ "$SHARED" == "true" || "$CONTAINERS" == "true" || "$BACKEND" == "true" ]]; then - include_entries+=('{"service":"api","context":"./backend","dockerfile":"./backend/Dockerfile","image_ref":"relab/api:${{ github.sha }}"}') - fi - - if [[ "$SHARED" == "true" || "$CONTAINERS" == "true" || "$DOCS" == "true" ]]; then - include_entries+=('{"service":"docs-site","context":"./docs","dockerfile":"./docs/Dockerfile","image_ref":"relab/docs-site:${{ github.sha }}"}') - fi - - if [[ "$SHARED" == "true" || "$CONTAINERS" == "true" || "$FRONTEND_APP" == "true" ]]; then - include_entries+=('{"service":"app-site","context":"./frontend-app","dockerfile":"./frontend-app/Dockerfile","image_ref":"relab/app-site:${{ github.sha }}"}') - fi - - if [[ "$SHARED" == "true" || "$CONTAINERS" == "true" || "$FRONTEND_WEB" == "true" ]]; then - include_entries+=('{"service":"web-site","context":"./frontend-web","dockerfile":"./frontend-web/Dockerfile","image_ref":"relab/web-site:${{ github.sha }}"}') - fi - - if ((${#include_entries[@]} == 0)); then - echo 'container-security-matrix={"include":[]}' >> "$GITHUB_OUTPUT" - else - matrix_json=$(printf '%s\n' "${include_entries[@]}" | paste -sd, -) - echo "container-security-matrix={\"include\":[${matrix_json}]}" >> "$GITHUB_OUTPUT" - fi diff --git a/.github/actions/docker-smoke/action.yml b/.github/actions/docker-smoke/action.yml new file mode 100644 index 000000000..923bdbd3e --- /dev/null +++ b/.github/actions/docker-smoke/action.yml @@ -0,0 +1,45 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +name: Docker Smoke +description: | + Run an allowlisted just-defined docker smoke recipe on a fresh checkout with Buildx. + Shared by the scheduled/dispatched docker-smoke matrix. + +inputs: + recipe: + description: The just recipe to invoke (e.g. docker-smoke-backend). + required: true + +runs: + using: composite + steps: + - name: Set up runtime + uses: ./.github/actions/setup-runtime + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + + # compose.yaml already declares `cache_from/cache_to: type=gha` on the built + # services, but that backend needs ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL, + # which the runner only hands to JavaScript actions — buildx invoked from a + # shell step (via `just`) never sees them, so the cache silently no-ops. + # Docker's own docs point at this action for exactly that case. + - name: Expose GitHub Actions runtime to BuildKit + uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + + - name: Run smoke test + shell: bash + env: + RECIPE: ${{ inputs.recipe }} + # Route builds through bake, whose buildx integration honours the compose + # cache_from/cache_to entries. CI-only: local `just docker-smoke-*` runs are + # unchanged, and no cache is exported without the runtime env above. + COMPOSE_BAKE: "true" + run: | + # Name-shaped allowlist rather than an enumerated one, so adding a smoke + # recipe needs no edit here. Anchored, and the character class refuses + # anything but a bare recipe name, so no other just recipe can be reached. + if [[ ! "$RECIPE" =~ ^(docker-smoke-[a-z][a-z-]*|docker-orchestration-smoke)$ ]]; then + echo "Unsupported docker smoke recipe: $RECIPE" >&2 + exit 1 + fi + just "$RECIPE" diff --git a/.github/actions/publish-reports/action.yml b/.github/actions/publish-reports/action.yml new file mode 100644 index 000000000..de35e824b --- /dev/null +++ b/.github/actions/publish-reports/action.yml @@ -0,0 +1,60 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +name: Publish Reports +description: | + Upload a subrepo's coverage and JUnit results to Codecov and archive the + coverage report. Skips the Codecov uploads on pull requests from forks, where + the token is not available. + +inputs: + token: + description: Codecov token. + required: true + flags: + description: Codecov flags, and the subrepo name used for upload/artifact names. + required: true + coverage-file: + description: Coverage file path to upload. + required: true + junit-file: + description: JUnit XML file path to upload. + required: true + network-prefix: + description: Prefix prepended to uploaded report paths before Codecov matches files. + required: true + artifact-name: + description: Name of the archived workflow artifact. + required: true + artifact-path: + description: Path(s) to archive as a workflow artifact. + required: true + +runs: + using: composite + steps: + - name: Upload coverage + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/actions/upload-codecov + with: + token: ${{ inputs.token }} + files: ${{ inputs.coverage-file }} + flags: ${{ inputs.flags }} + name: validate-${{ inputs.flags }}-coverage + disable-search: "true" + network-prefix: ${{ inputs.network-prefix }} + + - name: Upload test results + # Runs even when tests fail so Codecov can track flaky/failing tests. + if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: ./.github/actions/upload-test-results + with: + token: ${{ inputs.token }} + files: ${{ inputs.junit-file }} + flags: ${{ inputs.flags }} + + - name: Archive coverage report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.artifact-path }} + retention-days: 7 diff --git a/.github/actions/setup-runtime/action.yml b/.github/actions/setup-runtime/action.yml index 18d3bfe7f..c519d14b8 100644 --- a/.github/actions/setup-runtime/action.yml +++ b/.github/actions/setup-runtime/action.yml @@ -19,6 +19,10 @@ inputs: description: Lockfile path(s) used for pnpm cache keys. required: false default: "" + setup-playwright: + description: Cache the Playwright browser binaries (~/.cache/ms-playwright). Callers still run `playwright install`; on a cache hit it skips the download. + required: false + default: "false" runs: using: composite @@ -48,5 +52,12 @@ runs: cache: pnpm cache-dependency-path: ${{ inputs.cache-dependency-path }} + - name: Cache Playwright browsers + if: inputs.setup-playwright == 'true' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae + with: + path: ~/.cache/ms-playwright + key: playwright|${{ runner.os }}|${{ hashFiles('pnpm-lock.yaml') }} + - name: Install just uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 diff --git a/.github/actions/upload-test-results/action.yml b/.github/actions/upload-test-results/action.yml new file mode 100644 index 000000000..8d87bbf24 --- /dev/null +++ b/.github/actions/upload-test-results/action.yml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +name: Upload Test Results +description: Upload JUnit test results to Codecov Test Analytics with shared defaults. + +inputs: + files: + description: JUnit XML file path(s) to upload. + required: true + flags: + description: Codecov flags. + required: true + token: + description: Codecov token. + required: true + +runs: + using: composite + steps: + - name: Upload test results to Codecov + uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 + with: + files: ${{ inputs.files }} + flags: ${{ inputs.flags }} + token: ${{ inputs.token }} + fail_ci_if_error: false + disable_search: "true" + slug: CMLPlatform/relab diff --git a/.github/codecov.yml b/.github/codecov.yml index 1b80ebb75..b3d76d27e 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,6 +1,5 @@ # Codecov configuration # https://docs.codecov.com/docs/codecovyml-reference -# spell-checker: ignore carryforward coverage: # Overall project status check, requires all flags to report before posting @@ -29,20 +28,20 @@ flags: - backend/ carryforward: true - frontend-app: + app: paths: - - frontend-app/ + - app/ carryforward: true - frontend-web: + www: paths: - - frontend-web/ + - www/ carryforward: true # Path resolution note: # Frontend LCOV uploads contain paths relative to their subrepo (for example # "src/app/index.tsx"). CI passes an explicit network_prefix per upload, so -# Codecov resolves those files against "frontend-app/" and "frontend-web/" +# Codecov resolves those files against "app/" and "www/" # without relying on repository-wide path search heuristics. comment: diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml new file mode 100644 index 000000000..3fb313363 --- /dev/null +++ b/.github/codeql-config.yml @@ -0,0 +1,29 @@ +name: "Relab CodeQL Config" + +queries: + - uses: security-and-quality + +# Honor in-code `# codeql[query-id]` / `lgtm[query-id]` suppression comments for +# verified false positives (advanced setup does not include these by default). +packs: + python: + - codeql/python-queries:AlertSuppression.ql + javascript: + - codeql/javascript-queries:AlertSuppression.ql + +query-filters: + # Ruff (F401) owns unused-import linting and, unlike this query, resolves names + # used only inside string annotations / `cast("...")`, which ruff's TC006 rule + # mandates repo-wide. Every alert from this query here has been a false positive. + - exclude: + id: py/unused-import + # The informational cycle query flags the deliberate model-registry pattern + # (lazy imports documented in app/core/model_registry.py). The order-sensitive + # `py/unsafe-cyclic-import` query stays enabled. + - exclude: + id: py/cyclic-import + +# Alembic revision files intentionally expose module-level metadata variables such as `revision` +# and `down_revision`, which trigger recurring false positives for `py/unused-global-variable`. +paths-ignore: + - backend/alembic/** diff --git a/.github/container-images.json b/.github/container-images.json new file mode 100644 index 000000000..1e979cbee --- /dev/null +++ b/.github/container-images.json @@ -0,0 +1,44 @@ +[ + { + "service": "api", + "context": "./backend", + "dockerfile": "./backend/Dockerfile", + "image": "relab-backend", + "scope": "backend" + }, + { + "service": "migrator", + "context": "./backend", + "dockerfile": "./backend/Dockerfile.migrations", + "image": "relab-backend-migrations", + "scope": "backend" + }, + { + "service": "relab-backup", + "context": "./backend", + "dockerfile": "./backend/Dockerfile.backups", + "image": "relab-backup", + "scope": "backend" + }, + { + "service": "docs", + "context": ".", + "dockerfile": "./docs/Dockerfile", + "image": "relab-docs", + "scope": "docs" + }, + { + "service": "app", + "context": ".", + "dockerfile": "./app/Dockerfile", + "image": "relab-app", + "scope": "app" + }, + { + "service": "www", + "context": ".", + "dockerfile": "./www/Dockerfile", + "image": "relab-www", + "scope": "www" + } +] diff --git a/.github/gitleaks.toml b/.github/gitleaks.toml index acae64526..b799e09dc 100644 --- a/.github/gitleaks.toml +++ b/.github/gitleaks.toml @@ -1,14 +1,31 @@ [extend] useDefault = true -[allowlist] +[[allowlists]] description = "Global allowlist for known false positives" paths = [ - # Auto-generated OpenAPI types contain example JWTs from fastapi-users docs - '''frontend-app/src/types/api\.generated\.ts''', + # Auto-generated OpenAPI schema + types contain example JWTs from fastapi-users docs + '''app/src/types/api\.generated\.ts''', + '''app/src/types/openapi\.json''', ] regexTarget = "line" regexes = [ # PERF_IMAGE_ID is a sample image UUID used in perf-baseline docs, not a secret '''PERF_IMAGE_ID=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}''', ] + +[[allowlists]] +description = "Deliberate passphrase fixture used in password validation and auth tests" +condition = "AND" +# NOTE: keep these unanchored. gitleaks scans all history, so they must also match the +# pre-rename frontend-app/ paths (e.g. "frontend-app/e2e/..." contains "app/e2e/"). +# Anchoring them with ^ re-breaks the scheduled scan on ~9 historical findings. +paths = [ + '''backend/tests/.*''', + '''app/e2e/.*''', + '''app/src/__tests__/.*''', + '''app/src/.*/__tests__/.*''', +] +stopwords = [ + '''correct-horse-battery-staple-v42''', +] diff --git a/.github/path-filters.yml b/.github/path-filters.yml index 89ceec7a2..b4332ff8c 100644 --- a/.github/path-filters.yml +++ b/.github/path-filters.yml @@ -1,50 +1,133 @@ shared: + - 'assets/**' - '.github/**' - '.devcontainer/**' - '.vscode/**' - - '.python-version' - '.env.example' - 'backend/.env*.example' - 'justfile' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'uv.lock' + - 'scripts/**' + - 'deploy/**' + - 'infra/**' - 'compose*.yml' + - 'compose*.yaml' policy: - '.github/**' - '.pre-commit-config.yaml' - - '.prettierrc*' + - '.semgrep/**' + - '.semgrepignore' - '.editorconfig' - '.devcontainer/**' - '.vscode/**' - - '.python-version' - '.env.example' - 'backend/.env*.example' - 'pyproject.toml' - 'justfile' - -root-python: - - 'pyproject.toml' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' - 'uv.lock' + - 'scripts/**' + - 'deploy/**' + - 'infra/**' backend: - 'backend/**' - 'compose*.yml' + - 'compose*.yaml' -frontend-web: - - 'frontend-web/**' +www: + - 'www/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' - 'compose*.yml' + - 'compose*.yaml' -frontend-app: - - 'frontend-app/**' +app: + - 'app/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' - 'compose*.yml' + - 'compose*.yaml' docs: - 'docs/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' - 'compose*.yml' + - 'compose*.yaml' containers: - 'compose*.yml' + - 'compose*.yaml' - '**/Dockerfile*' - 'backend/uv.lock' - - 'docs/pnpm-lock.yaml' - - 'frontend-app/pnpm-lock.yaml' - - 'frontend-web/pnpm-lock.yaml' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + +# --- docker smoke legs ------------------------------------------------------- +# Per-leg splits of `containers`, so a PR only pays for the ~30 min smoke legs it +# can actually break. Each leg gates on what changes its *image build inputs or +# service wiring*, not on subrepo source. +# +# The frontend images do compile subrepo source (`COPY www/src/` + astro build in +# a root-context build), so strictly speaking the subrepo is part of the build +# context. It is still left out on purpose: a source-level build break is already +# caught by the www/app/docs jobs (check + build) and the e2e jobs, so rebuilding +# a full image on every source PR buys no extra signal for ~30 min a leg. The +# weekly scheduled run smokes all six legs regardless. + +smoke-backend: + - 'backend/Dockerfile*' + - 'backend/.dockerignore' + - 'backend/pyproject.toml' + - 'backend/.python-version' + - 'backend/uv.lock' + +smoke-www: + - 'www/Dockerfile*' + - 'www/Caddyfile' + - 'www/package.json' + - '.dockerignore' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + +smoke-app: + - 'app/Dockerfile*' + - 'app/Caddyfile' + - 'app/package.json' + - '.dockerignore' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + +smoke-docs: + - 'docs/Dockerfile*' + - 'docs/Caddyfile' + - 'docs/package.json' + - '.dockerignore' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + +# Compose wiring, deploy inputs, the two ops scripts the orchestration and backup +# flows shell out to, plus the smoke harness itself: the justfile holds every +# recipe body and the composite action holds the recipe allowlist, so a change to +# either must still smoke something. +smoke-orchestration: + - 'compose*.yml' + - 'compose*.yaml' + - 'deploy/**' + - 'scripts/deploy_ops.sh' + - 'scripts/backup_restic_ops.sh' + - 'justfile' + - '.github/actions/docker-smoke/**' diff --git a/.github/release-please-config.json b/.github/release-please-config.json index e3b5a4ab7..4a37de294 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -12,9 +12,9 @@ "backend/app/__version__.py", "backend/pyproject.toml", "docs/package.json", - "frontend-app/app.json", - "frontend-app/package.json", - "frontend-web/package.json", + "app/app.json", + "app/package.json", + "www/package.json", "package.json", "pyproject.toml" ] diff --git a/.github/renovate.json b/.github/renovate.json index 3f302e99b..e6920054b 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -7,91 +7,85 @@ ":preserveSemverRanges", "schedule:weekly" ], - "semanticCommits": "enabled", "semanticCommitType": "chore", "semanticCommitScope": "deps", - "platformAutomerge": true, + "prConcurrentLimit": 8, "configMigration": true, - "pep621": { - "enabled": true - }, "pre-commit": { "enabled": true }, - "lockFileMaintenance": { - "enabled": true, - "automerge": true - }, + "minimumReleaseAge": "14 days", + "labels": ["dependencies"], + "osvVulnerabilityAlerts": true, "packageRules": [ { - "groupName": "runtime-containers", - "matchManagers": ["docker-compose", "dockerfile"], - "matchUpdateTypes": ["digest", "minor", "patch"], - "pinDigests": true, - "automerge": false, - "labels": ["runtime-risk", "containers"] + "description": "Expo SDK-managed packages, versions pinned by the SDK, don't bump independently", + "matchFileNames": ["app/package.json"], + "matchPackageNames": [ + "expo", + "/^expo($|-)/", + "@expo/**", + "react", + "react-dom", + "react-native", + "@types/react", + "jest-expo", + "react-native-gesture-handler", + "react-native-reanimated", + "react-native-safe-area-context", + "react-native-screens", + "react-native-svg", + "react-native-web" + ], + "enabled": false }, { - "matchUpdateTypes": ["minor", "patch"], - "automerge": true, - "labels": ["automerge"], - "matchPackageNames": [ - "*" - ] + "description": "Non-breaking updates merge themselves once CI is green", + "matchUpdateTypes": ["minor", "patch", "digest"], + "automerge": true }, { - "groupName": "repo-tooling", - "matchFileNames": [ - ".pre-commit-config.yaml", - ".github/**", - ".devcontainer/**", - ".vscode/**", - ".python-version", - "backend/.python-version", - "package.json", - "pnpm-lock.yaml", - "pyproject.toml", - "uv.lock" - ], - "labels": ["tooling"] + "description": "One PR per subrepo instead of one per package", + "groupName": "backend", + "matchFileNames": ["backend/**"] }, { - "groupName": "backend-python-runtime", - "matchFileNames": ["backend/pyproject.toml", "backend/uv.lock", "backend/Dockerfile*"] + "groupName": "app", + "matchFileNames": ["app/**"] }, { - "groupName": "docs-site-runtime-tooling", - "matchFileNames": ["docs/package.json", "docs/pnpm-lock.yaml", "docs/Dockerfile*"] + "groupName": "www", + "matchFileNames": ["www/**"] }, { - "groupName": "frontend-web-runtime-tooling", - "matchFileNames": [ - "frontend-web/package.json", - "frontend-web/pnpm-lock.yaml", - "frontend-web/Dockerfile*" - ] + "groupName": "docs", + "matchFileNames": ["docs/**"] }, { - "groupName": "frontend-app-runtime-tooling", + "groupName": "repo-tooling", "matchFileNames": [ - "frontend-app/package.json", - "frontend-app/pnpm-lock.yaml", - "frontend-app/Dockerfile*" + ".tool-versions", + ".node-version", + ".pre-commit-config.yaml", + ".devcontainer/**", + "package.json", + "pnpm-lock.yaml", + "pyproject.toml", + "uv.lock" ] }, { - "groupName": "containers-compose", - "matchFileNames": ["**/compose.*.yaml", "**/compose.yaml"], + "description": "Batch GitHub Actions updates into one PR, pinned to immutable SHA digests", + "matchManagers": ["github-actions"], + "groupName": "github-actions", "pinDigests": true }, { - "groupName": "github-actions", - "matchManagers": ["github-actions"], - "matchUpdateTypes": ["digest", "minor", "patch"], - "pinDigests": true, - "automerge": true, - "labels": ["automerge", "github-actions"] + "description": "Batch container image updates into one PR, pinned to immutable SHA digests", + "matchManagers": ["dockerfile", "docker-compose"], + "groupName": "container-images", + "pinDigests": true } ], - "labels": ["dependencies", "renovate"] + "platformAutomerge": true } diff --git a/.github/trivyignore b/.github/trivyignore deleted file mode 100644 index 273ca63bc..000000000 --- a/.github/trivyignore +++ /dev/null @@ -1,23 +0,0 @@ -# Trivy vulnerability allowlist. -# -# Format: one CVE per line. Comments document scope and review date so stale -# entries can be pruned. Re-check whenever the Caddy base image is bumped. - -# --- caddy:2-alpine transitive Go deps --- -# The Caddy binary statically links these; upstream has not yet published a -# patched release (checked 2026-04-23). None of the vulnerable code paths are -# reachable in our usage: the docs site serves static files over plain HTTP -# on a single port with no step-CA, no gRPC, no JWE, and runs on Linux. -# Drop these entries once caddy:2-alpine ships with the fixed versions. -CVE-2026-34986 # go-jose JWE DoS — we don't use JWE -CVE-2026-30836 # smallstep/certificates SCEP — we don't run an internal CA -CVE-2026-39883 # opentelemetry-go BSD kenv PATH hijack — Linux-only runtime -CVE-2026-33186 # grpc-go authz HTTP/2 path validation — we don't use gRPC authz - -# --- alpine 3.23 base packages --- -# The Dockerfile runs `apk upgrade --no-cache`, but these fixes are not yet -# published to the alpine 3.23 community/main repos (checked 2026-04-23). -# Drop these entries once `apk upgrade` produces the fixed versions. -CVE-2026-28390 # openssl libcrypto3/libssl3 CMS NULL-deref DoS — static docs server doesn't parse CMS -CVE-2026-40200 # musl/musl-utils stack overflow — reachable only via locally run utils, not HTTP path -CVE-2026-22184 # zlib untgz buffer overflow — utility CLI only, not linked into Caddy request path diff --git a/.github/trivyignore.yaml b/.github/trivyignore.yaml new file mode 100644 index 000000000..924f42a67 --- /dev/null +++ b/.github/trivyignore.yaml @@ -0,0 +1,21 @@ +# Trivy allowlist for image vulnerability and IaC/config scans. +# +# Trivy's YAML ignore format is still experimental, so workflows pass this file +# explicitly. Every entry must include an expiry and a clear statement. +vulnerabilities: + # The caddy:2-alpine binary is built against Go 1.26.3; these need 1.26.4+, + # which upstream had not shipped when reviewed on 2026-07-13. Re-check + # whenever the caddy:2-alpine digest is bumped. + - id: CVE-2026-27145 + expired_at: 2026-10-01 + statement: Go stdlib DoS; fixed in Go 1.26.4, no caddy release built with it yet. + - id: CVE-2026-39822 + expired_at: 2026-10-01 + statement: Go os.Root symlink traversal; caddy serves a fixed static /srv root, no user-controlled paths. + - id: CVE-2026-42504 + expired_at: 2026-10-01 + statement: Go MIME header DoS; fixed in Go 1.26.4, no caddy release built with it yet. + +misconfigurations: [] +secrets: [] +licenses: [] diff --git a/.github/workflows/ops.yml b/.github/workflows/ops.yml index ea039cb5a..b0129f62c 100644 --- a/.github/workflows/ops.yml +++ b/.github/workflows/ops.yml @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout trusted workflow ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc59f4c01..02faeaecd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,7 @@ on: - main workflow_dispatch: -permissions: - contents: write - pull-requests: write +permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -19,7 +17,19 @@ jobs: release-please: runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + container_matrix: ${{ steps.container-matrix.outputs.matrix }} steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + - name: Run release-please id: release uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 @@ -27,3 +37,80 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} config-file: .github/release-please-config.json manifest-file: .github/.release-please-manifest.json + + # Runs unconditionally: release-sboms is gated on release_created, but its + # matrix expression is still parsed, so the output must always be valid JSON. + - name: Build the container matrix + id: container-matrix + shell: bash + run: | + # Every runtime image gets an SBOM on release. The image definitions are + # shared with the container security matrix in security.yml, which filters + # the same file by scope. + include=$(jq -c --arg sha "$GITHUB_SHA" \ + '[ .[] | {service, context, dockerfile, image_ref: (.image + ":" + $sha)} ]' \ + .github/container-images.json) + echo "matrix={\"include\":${include}}" >> "$GITHUB_OUTPUT" + + release-sboms: + name: Release SBOM (${{ matrix.service }}) + needs: [ release-please ] + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + attestations: write + contents: write + id-token: write + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.release-please.outputs.container_matrix) }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + + - name: Build image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + load: true + pull: true + tags: ${{ matrix.image_ref }} + # Read-only: the container security matrix in security.yml populates + # these scopes on every run, so an SBOM build starts warm without + # spending release-time cache writes on an image nothing deploys. + cache-from: type=gha,scope=${{ matrix.service }} + # The www/app builds require their public URLs. These images are only + # used for SBOM generation, never deployed, so placeholders are fine. + build-args: | + PUBLIC_APP_URL=https://app.example.invalid + PUBLIC_DOCS_URL=https://docs.example.invalid + PUBLIC_SITE_URL=https://example.invalid + PUBLIC_API_URL=https://api.example.invalid + EXPO_PUBLIC_API_URL=https://api.example.invalid + EXPO_PUBLIC_WEBSITE_URL=https://example.invalid + EXPO_PUBLIC_DOCS_URL=https://docs.example.invalid + + - name: Generate SPDX SBOM + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 + with: + image-ref: ${{ matrix.image_ref }} + format: spdx-json + output: sbom-${{ matrix.service }}.spdx.json + + - name: Attest SBOM asset provenance + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 + with: + subject-path: sbom-${{ matrix.service }}.spdx.json + + - name: Upload SBOM to GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + run: gh release upload "$TAG_NAME" "sbom-${{ matrix.service }}.spdx.json" --clobber diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 07f05d186..8664a4b39 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,25 +1,31 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -# spell-checker: ignore anchore, aquasecurity, gacts name: Security on: + branch_protection_rule: schedule: - cron: "0 2 * * 1" pull_request: - branches: [ main, develop ] + branches: [ main ] push: branches: [ main ] + # NOTE: inert until the repository enables the merge queue. Wired up now so a + # required Security check cannot stall the queue; a merge_group run behaves like + # a push to main (full scan set) because the event carries no PR context for + # path filtering. + merge_group: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Keyed by event too, so a scheduled or merge-queue run never cancels (or is + # cancelled by) a push run sharing github.ref. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} permissions: contents: read - security-events: write defaults: run: @@ -28,27 +34,104 @@ defaults: jobs: changes: name: Detect Changes + if: github.event_name != 'branch_protection_rule' runs-on: ubuntu-latest timeout-minutes: 5 outputs: - run-root-python: ${{ steps.detect.outputs.run-root-python }} - run-backend-python: ${{ steps.detect.outputs.run-backend-python }} - run-docs-node: ${{ steps.detect.outputs.run-docs-node }} - run-frontend-app-node: ${{ steps.detect.outputs.run-frontend-app-node }} - run-frontend-web-node: ${{ steps.detect.outputs.run-frontend-web-node }} - run-container-images: ${{ steps.detect.outputs.run-container-images }} - container-security-matrix: ${{ steps.detect.outputs.container-security-matrix }} + run-container-images: ${{ steps.decide.outputs.run-container-images }} + container-security-matrix: ${{ steps.decide.outputs.container-security-matrix }} + run-iac: ${{ steps.decide.outputs.run-iac }} + run-audit-python: ${{ steps.decide.outputs.run-audit-python }} + run-audit-www: ${{ steps.decide.outputs.run-audit-www }} + run-audit-app: ${{ steps.decide.outputs.run-audit-app }} + run-audit-docs: ${{ steps.decide.outputs.run-audit-docs }} + run-codeql: ${{ steps.decide.outputs.run-codeql }} + codeql-matrix: ${{ steps.decide.outputs.codeql-matrix }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Detect changes id: detect - uses: ./.github/actions/detect-security-changes + uses: ./.github/actions/detect-changes + + - name: Decide which security jobs should run + id: decide + shell: bash + env: + IS_SCHEDULE: ${{ steps.detect.outputs.is-schedule }} + IS_DISPATCH: ${{ steps.detect.outputs.is-dispatch }} + IS_MERGE_GROUP: ${{ steps.detect.outputs.is-merge-group }} + SHARED: ${{ steps.detect.outputs.shared }} + BACKEND: ${{ steps.detect.outputs.backend }} + DOCS: ${{ steps.detect.outputs.docs }} + APP: ${{ steps.detect.outputs.app }} + WWW: ${{ steps.detect.outputs.www }} + CONTAINERS: ${{ steps.detect.outputs.containers }} + run: | + # Scheduled, manual, and merge-queue runs scan every runtime image and emit + # SBOMs. merge_group has no PR context, so no path filter can narrow it. + if [[ "$IS_SCHEDULE" == "true" || "$IS_DISPATCH" == "true" || "$IS_MERGE_GROUP" == "true" ]]; then + SHARED=true + fi + + shared_trigger=$([[ "$SHARED" == "true" || "$CONTAINERS" == "true" ]] && echo true || echo false) + echo "run-iac=$shared_trigger" >> "$GITHUB_OUTPUT" + + # Scheduled dependency audits, gated the same way as the CodeQL language + # matrix below: a subrepo-only change only needs its own audit re-run. + { + echo "run-audit-python=$([[ "$shared_trigger" == "true" || "$BACKEND" == "true" ]] && echo true || echo false)" + echo "run-audit-www=$([[ "$shared_trigger" == "true" || "$WWW" == "true" ]] && echo true || echo false)" + echo "run-audit-app=$([[ "$shared_trigger" == "true" || "$APP" == "true" ]] && echo true || echo false)" + echo "run-audit-docs=$([[ "$shared_trigger" == "true" || "$DOCS" == "true" ]] && echo true || echo false)" + } >> "$GITHUB_OUTPUT" + + # Which subrepos' images to scan. The image definitions themselves live in + # .github/container-images.json, shared with the release SBOM matrix. + scopes=() + if [[ "$shared_trigger" == "true" || "$BACKEND" == "true" ]]; then scopes+=(backend); fi + if [[ "$shared_trigger" == "true" || "$DOCS" == "true" ]]; then scopes+=(docs); fi + if [[ "$shared_trigger" == "true" || "$APP" == "true" ]]; then scopes+=(app); fi + if [[ "$shared_trigger" == "true" || "$WWW" == "true" ]]; then scopes+=(www); fi + + if ((${#scopes[@]} == 0)); then + echo "run-container-images=false" >> "$GITHUB_OUTPUT" + echo 'container-security-matrix={"include":[]}' >> "$GITHUB_OUTPUT" + else + scopes_json=$(printf '%s\n' "${scopes[@]}" | jq -Rsc 'split("\n") | map(select(length > 0))') + include=$(jq -c --arg sha "$GITHUB_SHA" --argjson scopes "$scopes_json" \ + '[ .[] | select(.scope | IN($scopes[])) | {service, context, dockerfile, image_ref: (.image + ":" + $sha)} ]' \ + .github/container-images.json) + echo "run-container-images=true" >> "$GITHUB_OUTPUT" + echo "container-security-matrix={\"include\":${include}}" >> "$GITHUB_OUTPUT" + fi + + # Which CodeQL languages to analyse. A backend-only PR has no TypeScript to + # scan, and vice versa. `shared` covers .github/**, so any workflow change + # (and every schedule/dispatch run) still analyses all three. + langs=() + if [[ "$shared_trigger" == "true" ]]; then langs+=(actions); fi + if [[ "$shared_trigger" == "true" || "$BACKEND" == "true" ]]; then langs+=(python); fi + if [[ "$shared_trigger" == "true" || "$APP" == "true" || "$WWW" == "true" || "$DOCS" == "true" ]]; then + langs+=(javascript-typescript) + fi + + if ((${#langs[@]} == 0)); then + echo "run-codeql=false" >> "$GITHUB_OUTPUT" + echo 'codeql-matrix={"include":[]}' >> "$GITHUB_OUTPUT" + else + codeql_include=$(printf '%s\n' "${langs[@]}" \ + | jq -Rsc 'split("\n") | map(select(length > 0) | {language: ., "build-mode": "none"})') + echo "run-codeql=true" >> "$GITHUB_OUTPUT" + echo "codeql-matrix={\"include\":${codeql_include}}" >> "$GITHUB_OUTPUT" + fi secret-scan: name: Secret Scan - if: github.event_name != 'merge_group' + if: github.event_name != 'branch_protection_rule' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -56,6 +139,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 + persist-credentials: false - name: Run gitleaks uses: gacts/gitleaks@c9a0338361dc45a01aa7ebaaa5330179f3c62873 @@ -64,133 +148,147 @@ jobs: env: GITLEAKS_CONFIG: .github/gitleaks.toml - python-audit-root: - name: Python Audit (root) - needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-root-python == 'true' + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - - name: Set up runtime - uses: ./.github/actions/setup-runtime with: - setup-python: "true" - uv-cache-dependency-glob: uv.lock - - - name: Install root dependencies - run: uv sync --frozen + persist-credentials: false - - name: Run root dependency audit - run: just audit-root + - name: Review dependency changes + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 + with: + fail-on-severity: moderate - backend-python-audit: - name: Python Audit (backend) + python-audit: + name: Python Audit (root + backend) needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-backend-python == 'true' + if: github.event_name != 'pull_request' && needs.changes.outputs.run-audit-python == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime with: setup-python: "true" - uv-cache-dependency-glob: backend/uv.lock + uv-cache-dependency-glob: | + uv.lock + backend/uv.lock - - name: Install dependencies + - name: Install root dependencies + run: uv sync --frozen + + - name: Run root dependency audit + run: just audit-root + + - name: Install backend dependencies run: just backend/install - - name: Run dependency audit + - name: Run backend dependency audit run: just backend/audit - docs-node-audit: - name: Node Audit (docs) + www-node-audit: + name: Node Audit (www) needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-docs-node == 'true' + if: github.event_name != 'pull_request' && needs.changes.outputs.run-audit-www == 'true' runs-on: ubuntu-latest timeout-minutes: 15 - continue-on-error: true steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime with: setup-node: "true" - cache-dependency-path: docs/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: just docs/install + run: just www/install - name: Run dependency audit - run: | - set -o pipefail - output="$(just docs/audit 2>&1)" - status=$? - printf '%s\n' "$output" - if [ "$status" -ne 0 ]; then - exit "$status" - fi - - if printf '%s\n' "$output" | grep -Eq '^Found [1-9][0-9]* known vulnerability|^Vulnerabilities:'; then - echo "::warning title=Docs dependency audit::Vulnerabilities were found in docs dependencies. Review the audit output above." - { - printf '### Docs dependency audit\n\n' - printf 'Warning: vulnerabilities were found in docs dependencies.\n\n' - printf 'This check is intentionally non-blocking so the pull request can still be merged.\n\n' - printf 'Review the audit output in the step log above.\n' - } >> "$GITHUB_STEP_SUMMARY" - fi + run: just www/audit - frontend-app-node-audit: - name: Node Audit (frontend-app) + app-node-audit: + name: Node Audit (app) needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-frontend-app-node == 'true' + if: github.event_name != 'pull_request' && needs.changes.outputs.run-audit-app == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime with: setup-node: "true" - cache-dependency-path: frontend-app/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: just frontend-app/install + run: just app/install - name: Run dependency audit - run: just frontend-app/audit + run: just app/audit - frontend-web-node-audit: - name: Node Audit (frontend-web) + docs-node-audit: + name: Node Audit (docs) needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-frontend-web-node == 'true' + if: github.event_name != 'pull_request' && needs.changes.outputs.run-audit-docs == 'true' runs-on: ubuntu-latest timeout-minutes: 15 + # Docs dependencies carry known low-severity advisories that aren't worth + # blocking merges over; surface them without failing the workflow. + continue-on-error: true steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime with: setup-node: "true" - cache-dependency-path: frontend-web/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: just frontend-web/install + run: just docs/install - name: Run dependency audit - run: just frontend-web/audit + run: | + status=0 + output="$(just docs/audit 2>&1)" || status=$? + printf '%s\n' "$output" + if [ "$status" -ne 0 ]; then + exit "$status" + fi + + if printf '%s\n' "$output" | grep -Eq '^Found [1-9][0-9]* known vulnerability|^Vulnerabilities:'; then + echo "::warning title=Docs dependency audit::Vulnerabilities were found in docs dependencies. Review the audit output above." + { + printf '### Docs dependency audit\n\n' + printf 'Warning: vulnerabilities were found in docs dependencies.\n\n' + printf 'This check is intentionally non-blocking so the pull request can still be merged.\n\n' + printf 'Review the audit output in the step log above.\n' + } >> "$GITHUB_STEP_SUMMARY" + fi container-security: name: Container Security (${{ matrix.service }}) @@ -204,6 +302,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd @@ -214,25 +314,41 @@ jobs: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} load: true + # `pull` keeps base images fresh so the scan sees current CVEs; the GHA + # cache still covers every layer built on top of them. pull: true tags: ${{ matrix.image_ref }} + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,scope=${{ matrix.service }},mode=max + # The www/app builds require their public URLs. These images are only + # scanned, never deployed, so placeholders are fine; unused args on the + # other services are ignored. + build-args: | + PUBLIC_APP_URL=https://app.example.invalid + PUBLIC_DOCS_URL=https://docs.example.invalid + PUBLIC_SITE_URL=https://example.invalid + PUBLIC_API_URL=https://api.example.invalid + EXPO_PUBLIC_API_URL=https://api.example.invalid + EXPO_PUBLIC_WEBSITE_URL=https://example.invalid + EXPO_PUBLIC_DOCS_URL=https://docs.example.invalid - name: Scan image with Trivy - uses: aquasecurity/trivy-action@1f0aa582c8c8f5f7639610d6d38baddfea4fdcee + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 with: image-ref: ${{ matrix.image_ref }} format: table exit-code: "1" ignore-unfixed: true severity: HIGH,CRITICAL - trivyignores: .github/trivyignore + trivyignores: .github/trivyignore.yaml - name: Generate SBOM - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 with: - image: ${{ matrix.image_ref }} + image-ref: ${{ matrix.image_ref }} format: spdx-json - output-file: sbom-${{ matrix.service }}.spdx.json + output: sbom-${{ matrix.service }}.spdx.json + skip-setup-trivy: true - name: Archive container security artifacts if: always() @@ -240,4 +356,153 @@ jobs: with: name: security-${{ matrix.service }}-artifacts path: sbom-${{ matrix.service }}.spdx.json - retention-days: 14 + retention-days: 90 + + trivy-iac: + name: Trivy IaC + needs: [ changes ] + if: github.event_name != 'branch_protection_rule' && needs.changes.outputs.run-iac == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Scan IaC with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 + with: + scan-type: config + scan-ref: . + format: sarif + output: trivy-iac.sarif + exit-code: "1" + severity: HIGH,CRITICAL + # Without this, the SARIF path reports every severity and `exit-code` + # then fails the job on LOW/MEDIUM findings too. + limit-severities-for-sarif: true + trivyignores: .github/trivyignore.yaml + skip-dirs: .venv,backend/.venv,node_modules,app/node_modules,docs/node_modules,www/node_modules + + - name: Upload Trivy IaC SARIF + if: always() + uses: github/codeql-action/upload-sarif@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 + with: + sarif_file: trivy-iac.sarif + + codeql: + name: CodeQL (${{ matrix.language }}) + needs: [ changes ] + if: needs.changes.outputs.run-codeql == 'true' + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: 30 + permissions: + security-events: write + packages: read + actions: read + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.changes.outputs.codeql-matrix) }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql-config.yml + + - name: Perform CodeQL analysis + id: analyze + uses: github/codeql-action/analyze@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 + with: + category: "/language:${{ matrix.language }}" + output: sarif-results + + # Code scanning stores but does not act on the suppression data that the + # AlertSuppression queries (see codeql-config.yml) emit for in-code + # `# codeql[query-id]` comments; this official action closes the loop by + # dismissing the matching alerts. Suppressions stay reviewable in diffs. + # Default branch only: dismissal is a global alert property, and the action + # only ever reads default-branch alerts, so on a PR it is a no-op at best. + - name: Dismiss alerts suppressed in code + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: advanced-security/dismiss-alerts@a18f986bdb40edba0dd7a74382c15d4a3d50a1c8 # v2.0.3 + with: + sarif-id: ${{ steps.analyze.outputs.sarif-id }} + sarif-file: sarif-results + env: + GITHUB_TOKEN: ${{ github.token }} + + scorecard: + name: Scorecard + # Deliberately not run on merge_group (nor pull_request): Scorecard rates the + # repository and its default branch, not the queued commit. `security-result` + # accepts the skip. + if: github.event_name == 'branch_protection_rule' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + checks: read + contents: read + id-token: write + issues: read + pull-requests: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 + with: + sarif_file: results.sarif + + security-result: + name: Security Result + # The `needs:` block below is the single source of blocking jobs — it is passed + # wholesale to check-required-jobs. Skipped jobs are accepted, failed/cancelled fail. + if: always() + needs: + - changes + - secret-scan + - dependency-review + - python-audit + - www-node-audit + - app-node-audit + - docs-node-audit + - container-security + - trivy-iac + - codeql + - scorecard + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Check security jobs + uses: ./.github/actions/check-required-jobs + with: + results: ${{ toJSON(needs) }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 52478ec1d..f16b7cc6f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,22 +1,27 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -# spell-checker: ignore rhysd - name: Validate on: push: - branches: [ main, develop ] + branches: [ main ] pull_request: - branches: [ main, develop ] + branches: [ main ] + # NOTE: inert until the repository enables the merge queue. Wired up now so the + # queue can be switched on without another workflow change; a merge_group run + # behaves exactly like a push to main (full checks, all smoke legs) because the + # event carries no PR context for path filtering. merge_group: workflow_dispatch: schedule: - cron: "0 3 * * 1" concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Keyed by event too, so a scheduled or merge-queue run never cancels (or is + # cancelled by) a push run that happens to share github.ref. PR and push runs + # are still superseded by their own newer commits. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} permissions: contents: read @@ -31,22 +36,104 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - run-policy: ${{ steps.detect.outputs.run-policy }} - run-backend: ${{ steps.detect.outputs.run-backend }} - run-frontend-web: ${{ steps.detect.outputs.run-frontend-web }} - run-frontend-app: ${{ steps.detect.outputs.run-frontend-app }} - run-docs: ${{ steps.detect.outputs.run-docs }} - run-e2e: ${{ steps.detect.outputs.run-e2e }} + run-policy: ${{ steps.decide.outputs.run-policy }} + run-backend: ${{ steps.decide.outputs.run-backend }} + run-www: ${{ steps.decide.outputs.run-www }} + run-app: ${{ steps.decide.outputs.run-app }} + run-docs: ${{ steps.decide.outputs.run-docs }} + run-e2e: ${{ steps.decide.outputs.run-e2e }} + smoke-legs: ${{ steps.decide.outputs.smoke-legs }} + is-renovate-pr: ${{ steps.detect.outputs.is-renovate-pr }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Detect changes id: detect - uses: ./.github/actions/detect-ci-changes + uses: ./.github/actions/detect-changes + + - name: Decide which CI jobs should run + id: decide + shell: bash + env: + IS_SCHEDULE: ${{ steps.detect.outputs.is-schedule }} + IS_DISPATCH: ${{ steps.detect.outputs.is-dispatch }} + IS_MERGE_GROUP: ${{ steps.detect.outputs.is-merge-group }} + SHARED: ${{ steps.detect.outputs.shared }} + POLICY: ${{ steps.detect.outputs.policy }} + BACKEND: ${{ steps.detect.outputs.backend }} + WWW: ${{ steps.detect.outputs.www }} + APP: ${{ steps.detect.outputs.app }} + DOCS: ${{ steps.detect.outputs.docs }} + SMOKE_BACKEND: ${{ steps.detect.outputs.smoke-backend }} + SMOKE_WWW: ${{ steps.detect.outputs.smoke-www }} + SMOKE_APP: ${{ steps.detect.outputs.smoke-app }} + SMOKE_DOCS: ${{ steps.detect.outputs.smoke-docs }} + SMOKE_ORCHESTRATION: ${{ steps.detect.outputs.smoke-orchestration }} + run: | + ALL_LEGS='["docker-smoke-backend","docker-orchestration-smoke","docker-smoke-www","docker-smoke-app","docker-smoke-docs","docker-smoke-backups"]' + + # Scheduled runs exercise docker-smoke, plus the backend suite under a + # rotating TEST_SEED (see the backend job) — the test factories generate + # data from a fixed seed, so a test that leans on a generated value only + # fails once the seed moves. Everything else is skipped. + if [[ "$IS_SCHEDULE" == "true" ]]; then + for k in run-policy run-www run-app run-docs run-e2e; do + echo "$k=false" >> "$GITHUB_OUTPUT" + done + echo "run-backend=true" >> "$GITHUB_OUTPUT" + echo "smoke-legs=$ALL_LEGS" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Manual dispatch and merge-queue runs exercise everything: a merge_group + # event has no PR context, so path filters cannot run at all. + if [[ "$IS_DISPATCH" == "true" || "$IS_MERGE_GROUP" == "true" ]]; then + for k in run-policy run-backend run-www run-app run-docs run-e2e; do + echo "$k=true" >> "$GITHUB_OUTPUT" + done + echo "smoke-legs=$ALL_LEGS" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Path-based filtering for push/pull_request events. + { + echo "run-policy=$([[ "$SHARED" == "true" || "$POLICY" == "true" ]] && echo true || echo false)" + echo "run-backend=$([[ "$SHARED" == "true" || "$BACKEND" == "true" ]] && echo true || echo false)" + echo "run-www=$([[ "$SHARED" == "true" || "$WWW" == "true" ]] && echo true || echo false)" + echo "run-app=$([[ "$SHARED" == "true" || "$APP" == "true" ]] && echo true || echo false)" + echo "run-docs=$([[ "$SHARED" == "true" || "$DOCS" == "true" ]] && echo true || echo false)" + echo "run-e2e=$([[ "$SHARED" == "true" || "$BACKEND" == "true" || "$APP" == "true" ]] && echo true || echo false)" + } >> "$GITHUB_OUTPUT" + + # Per-leg smoke selection: each ~30 min leg runs only when its own build + # inputs moved. backend Dockerfiles feed api/migrator/backups alike, and + # compose+deploy+ops-script changes drive both orchestration and backups. + # NOTE: steps run under `set -e`, so use `if` rather than `[[ ... ]] && ...`, + # whose false branch would fail the step. + legs=() + if [[ "$SMOKE_BACKEND" == "true" ]]; then + legs+=(docker-smoke-backend) + fi + if [[ "$SMOKE_BACKEND" == "true" || "$SMOKE_ORCHESTRATION" == "true" ]]; then + legs+=(docker-orchestration-smoke docker-smoke-backups) + fi + if [[ "$SMOKE_WWW" == "true" ]]; then + legs+=(docker-smoke-www) + fi + if [[ "$SMOKE_APP" == "true" ]]; then + legs+=(docker-smoke-app) + fi + if [[ "$SMOKE_DOCS" == "true" ]]; then + legs+=(docker-smoke-docs) + fi + # Empty array renders as `[]`, which skips the docker-smoke job entirely. + echo "smoke-legs=$(printf '%s\n' ${legs[@]+"${legs[@]}"} | jq -Rnc '[inputs | select(. != "")]')" >> "$GITHUB_OUTPUT" automation: - name: Automation and Compose Policy + name: Automation, IaC, and Compose Policy needs: [ changes ] if: github.event_name != 'schedule' && needs.changes.outputs.run-policy == 'true' runs-on: ubuntu-latest @@ -54,6 +141,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime @@ -65,48 +154,63 @@ jobs: uv.lock backend/uv.lock - - name: Compute pre-commit cache key + - name: Compute prek cache key run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> "$GITHUB_ENV" - - name: Cache pre-commit environments + - name: Cache prek environments uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae with: - path: ~/.cache/pre-commit - key: pre-commit|${{ runner.os }}|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} + path: ~/.cache/prek + key: prek|${{ runner.os }}|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} - name: Install root dependencies run: pnpm install --frozen-lockfile - - name: Run pre-commit checks + - name: Set up OpenTofu + uses: opentofu/setup-opentofu@9d84900f3238fab8cd84ce47d658d25dd008be2f + with: + tofu_wrapper: false + + - name: Run repository policy checks run: >- - ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'SKIP=no-commit-to-branch ' || '' }}uv run pre-commit run --all-files --show-diff-on-failure + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'SKIP=no-commit-to-branch ' || '' }}uv run prek run --all-files --show-diff-on-failure + + - name: Validate Cloudflare edge IaC + run: just cloudflare-check - - name: Check shared dep version alignment - run: pnpm run syncpack:check + - name: Validate environment variable policy + run: just env-policy-check - name: Validate Compose stacks run: just compose-config + - name: Validate deploy secret paths + run: just deploy-secrets-check + - name: Publish summary run: | { echo "### Validation automation" echo echo "- repository policy checks passed" - echo "- shared dep versions aligned across subrepos" + echo "- Cloudflare edge IaC validated" + echo "- environment variable policy validated" echo "- Compose configs rendered successfully" - echo "- GitHub workflow lint passed via pre-commit" + echo "- deploy secret file paths matched rendered Compose" + echo "- GitHub workflow lint passed via prek" } >> "$GITHUB_STEP_SUMMARY" backend: name: Backend needs: [ changes ] - if: needs.changes.outputs.run-backend == 'true' && github.event_name != 'schedule' + if: needs.changes.outputs.run-backend == 'true' runs-on: ubuntu-latest timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime @@ -122,150 +226,142 @@ jobs: - name: Run CI test suite run: just backend/test-ci - - - name: Upload coverage - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: ./.github/actions/upload-codecov + env: + # The factories seed themselves from TEST_SEED so a run is reproducible. + # A fixed seed freezes the remaining coin flips rather than removing + # them, so the weekly scheduled run rotates it and surfaces any test + # that only passes under seed 0. run_id, not $RANDOM: the seed is then + # recorded on the run, and TEST_SEED= reproduces the failure. + TEST_SEED: ${{ github.event_name == 'schedule' && github.run_id || '0' }} + + - name: Publish reports + uses: ./.github/actions/publish-reports with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./backend/reports/coverage/coverage.xml flags: backend - name: validate-backend-coverage - - - name: Archive backend reports - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: validate-backend-reports - path: | + coverage-file: ./backend/reports/coverage/coverage.xml + junit-file: ./backend/reports/junit.xml + network-prefix: backend/app/ + artifact-name: validate-backend-reports + artifact-path: | backend/reports/coverage/html/ backend/reports/coverage/coverage.xml - retention-days: 7 - frontend-web: - name: Frontend Web + www: + name: www needs: [ changes ] - if: needs.changes.outputs.run-frontend-web == 'true' && github.event_name != 'schedule' + if: needs.changes.outputs.run-www == 'true' && github.event_name != 'schedule' runs-on: ubuntu-latest timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime with: setup-node: "true" - cache-dependency-path: frontend-web/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: just frontend-web/install + run: just www/install - name: Run check - run: just frontend-web/check + run: just www/check - name: Run CI test suite - run: just frontend-web/test-ci + run: just www/test-ci - - name: Upload coverage - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: ./.github/actions/upload-codecov + - name: Publish reports + uses: ./.github/actions/publish-reports with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./frontend-web/coverage/lcov.info - flags: frontend-web - name: validate-frontend-web-coverage - disable-search: "true" - network-prefix: frontend-web/ - - - name: Archive frontend-web coverage - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: validate-frontend-web-coverage - path: frontend-web/coverage/ - retention-days: 7 - - e2e-frontend-web: - name: Frontend Web E2E + flags: www + coverage-file: ./www/coverage/lcov.info + junit-file: ./www/junit.xml + network-prefix: www/ + artifact-name: validate-www-coverage + artifact-path: www/coverage/ + + e2e-www: + name: www E2E needs: [ changes ] - if: github.event_name == 'workflow_dispatch' || (needs.changes.outputs.run-frontend-web == 'true' && github.event_name != 'schedule') + if: github.event_name == 'workflow_dispatch' || (needs.changes.outputs.run-www == 'true' && github.event_name != 'schedule' && needs.changes.outputs.is-renovate-pr != 'true') runs-on: ubuntu-latest timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime with: setup-node: "true" - cache-dependency-path: frontend-web/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml + setup-playwright: "true" - name: Install dependencies - run: just frontend-web/install + run: just www/install - name: Build site - run: just frontend-web/build + run: just www/build - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium firefox webkit - working-directory: frontend-web + run: pnpm exec playwright install --with-deps ${{ github.event_name == 'workflow_dispatch' && 'chromium firefox webkit' || 'chromium' }} + working-directory: www - name: Run browser E2E tests - run: just frontend-web/test-e2e + run: just www/${{ github.event_name == 'workflow_dispatch' && 'test-e2e-cross-browser' || 'test-e2e' }} - name: Archive Playwright report if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: validate-frontend-web-playwright - path: frontend-web/playwright-report/ + name: validate-www-playwright + path: www/playwright-report/ retention-days: 7 - frontend-app: - name: Frontend App + app: + name: App needs: [ changes ] - if: needs.changes.outputs.run-frontend-app == 'true' && github.event_name != 'schedule' + if: needs.changes.outputs.run-app == 'true' && github.event_name != 'schedule' runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime with: setup-node: "true" - cache-dependency-path: frontend-app/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: just frontend-app/install + run: just app/install - name: Run check - run: just frontend-app/check + run: just app/check - name: Run CI test suite - run: just frontend-app/test-ci + run: just app/test-ci - - name: Upload coverage - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: ./.github/actions/upload-codecov + - name: Publish reports + uses: ./.github/actions/publish-reports with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./frontend-app/coverage/lcov.info - flags: frontend-app - name: validate-frontend-app-coverage - disable-search: "true" - network-prefix: frontend-app/ - - - name: Archive frontend-app coverage - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: validate-frontend-app-coverage - path: frontend-app/coverage/ - retention-days: 7 + flags: app + coverage-file: ./app/coverage/lcov.info + junit-file: ./app/junit.xml + network-prefix: app/ + artifact-name: validate-app-coverage + artifact-path: app/coverage/ docs: name: Docs @@ -276,12 +372,15 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime with: setup-node: "true" - cache-dependency-path: docs/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml + setup-playwright: "true" - name: Install dependencies run: just docs/install @@ -297,8 +396,9 @@ jobs: run: just docs/test-ci e2e-full-stack: - name: Frontend App Full-Stack E2E + name: App Full-Stack E2E needs: [ changes ] + # merge_group is treated like a push to main: the queued commit gets the full pass. if: github.event_name == 'workflow_dispatch' || (needs.changes.outputs.run-e2e == 'true' && (github.event_name == 'push' || github.event_name == 'merge_group')) runs-on: ubuntu-latest timeout-minutes: 35 @@ -307,6 +407,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd @@ -315,17 +417,18 @@ jobs: uses: ./.github/actions/setup-runtime with: setup-node: "true" - cache-dependency-path: frontend-app/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml + setup-playwright: "true" - - name: Install frontend-app dependencies - run: just frontend-app/install + - name: Install app dependencies + run: just app/install - name: Build Expo web app for local E2E backend - run: just frontend-app/build-web + run: just app/build-web - name: Install Playwright browsers run: pnpm exec playwright install --with-deps chromium - working-directory: frontend-app + working-directory: app - name: Run full-stack E2E tests run: just test-e2e-full-stack @@ -334,34 +437,57 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: validate-frontend-app-e2e - path: frontend-app/playwright-report/ + name: validate-app-e2e + path: app/playwright-report/ retention-days: 7 docker-smoke: name: Docker Smoke (${{ matrix.recipe }}) - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + needs: [ changes ] + # `changes` emits the leg list; `[]` means nothing container-relevant moved and + # the whole job skips (an accepted result for validate-result). + if: needs.changes.outputs.smoke-legs != '' && needs.changes.outputs.smoke-legs != '[]' runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false matrix: - recipe: - - docker-smoke-backend - - docker-orchestration-smoke - - docker-smoke-frontend-web - - docker-smoke-frontend-app - - docker-smoke-docs - - docker-smoke-user-upload-backups + recipe: ${{ fromJSON(needs.changes.outputs.smoke-legs) }} steps: - - name: Checkout + - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - - name: Set up runtime - uses: ./.github/actions/setup-runtime - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + - name: Run smoke recipe + uses: ./.github/actions/docker-smoke + with: + recipe: ${{ matrix.recipe }} + + validate-result: + name: Validate Result + # The `needs:` block below is the single source of blocking jobs — it is passed + # wholesale to check-required-jobs. Skipped jobs are accepted, failed/cancelled fail. + if: always() + needs: + - changes + - automation + - backend + - www + - e2e-www + - app + - docs + - e2e-full-stack + - docker-smoke + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - - name: Run smoke test - run: just ${{ matrix.recipe }} + - name: Check validate jobs + uses: ./.github/actions/check-required-jobs + with: + results: ${{ toJSON(needs) }} diff --git a/.gitignore b/.gitignore index ff0b7d522..d82a49c3b 100644 --- a/.gitignore +++ b/.gitignore @@ -72,21 +72,40 @@ __pycache__/ !.env.example !.env.*.example -# Committed Compose interpolation vars per environment (non-secret only) -!.env.*.compose + +# Personal machine-local MCP server config (absolute paths, local gateways) +.mcp.json # Local Docker Compose overrides compose.local.yml - -# Local architecture diagrams -LOCAL_DIAGRAMS.md - -# LLM config -.claude -AGENTS.md +compose.host.yaml + +# Local Docker Compose secret files +secrets/ + +# Local restic backup repository (root-owned, created by the backup service) +backups/ + +# Local OpenTofu/Terraform working files +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfplan +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +terraform.tfvars +terraform.tfvars.json +*.auto.tfvars +*.auto.tfvars.json +.terraformrc +terraform.rc # pnpm store .pnpm-store -# Local cspell cache -.cspellcache +# Generated by infra/cloudflare/generate-imports.sh: adoption-only, delete after apply. +infra/cloudflare*/imports.tf diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..bb6eac98a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +26.4.0 diff --git a/.npmrc b/.npmrc deleted file mode 100644 index e97cd2e8a..000000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -engine-strict=true -prefer-frozen-lockfile=true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dbe4fa5df..5d4fd3042 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,19 @@ # Repository-wide pre-commit policy hooks. # Project-specific linting and test commands live in the subrepo `justfile`s. - -# spell-checker: ignore rhysd +# +# Fast fixers/guards run on every commit; slow verification-only checks are +# tagged `stages: [ pre-push ]` so they run once per push, not per commit. +default_install_hook_types: [ commit-msg, pre-commit, pre-push ] repos: - repo: https://github.com/gitleaks/gitleaks - rev: v8.30.1 + rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # v8.30.1 hooks: - id: gitleaks - args: ["--config", ".github/gitleaks.toml"] + args: [ "--config", ".github/gitleaks.toml" ] - repo: https://github.com/executablebooks/mdformat - rev: 1.0.0 + rev: 82912cdaea4fb830f751504486a7879c70526547 # 1.0.0 hooks: - id: mdformat additional_dependencies: @@ -20,7 +22,7 @@ repos: - mdformat-ruff - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 hooks: - id: check-added-large-files exclude: ^backend/app/api/auth/resources/disposable_email_domains\.txt$ @@ -38,32 +40,71 @@ repos: exclude: ^.*/build/.*\.html$ - repo: https://github.com/rhysd/actionlint - rev: v1.7.12 + rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12 hooks: - id: actionlint files: ^\.github/workflows/.*\.ya?ml$ + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: e3eebf65325ccc992422292cb7a4baee967cf815 # v1.26.1 + hooks: + - id: zizmor + args: [ "--min-confidence=high" ] + files: ^\.github/(?:workflows|actions)/.*\.ya?ml$ + - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.11.0.1 + rev: 745eface02aef23e168a8afb6b5737818efbea95 # v0.11.0.1 hooks: - id: shellcheck args: [ "--external-sources", "--source-path=SCRIPTDIR:." ] exclude: (^|/)(node_modules|\.venv)/ + - repo: https://github.com/scop/pre-commit-shfmt + rev: 05c1426671b9237fb5e1444dd63aa5731bec0dfb # v3.13.1-1 + hooks: + - id: shfmt + args: [ "-w", "-i", "4", "-ci", "-bn" ] + exclude: (^|/)(node_modules|\.venv)/ + - repo: https://github.com/commitizen-tools/commitizen - rev: v4.13.9 + rev: 205e71c26d6dd6a802ca674c9ae4d12f1103a9bb # v4.16.4 hooks: - id: commitizen stages: [ commit-msg ] - repo: https://github.com/simonvanlierde/check-json5 - rev: v1.1.0 + rev: c70883b5be6d9fb87deac8d75395031372d54e83 # v1.2.0 hooks: - id: check-json5 exclude: '^(?:frontend[^/]*(?:/.*)?/data/.*\.json|(?:.*/)?package-lock\.json)$' + - repo: https://github.com/semgrep/pre-commit + rev: ce0f4a080d77d0593b6419111837eadf4a9aca27 # v1.169.0 + hooks: + - id: semgrep + name: semgrep-browser-js-policy + # Dir scan (not pass_filenames) so .semgrepignore is honored — explicit + # file targets bypass it and would flag ignored test/generated files. + args: [ "--config", ".semgrep", "--error", "app", "www", "docs" ] + pass_filenames: false + files: ^(?:app|www|docs|\.semgrep/|\.semgrepignore$) + stages: [ pre-push ] + + - repo: local + hooks: + # Runs the .semgrep/*.{tsx,astro,html,js} fixtures against the rules so a broken + # rule regex is caught. Must run from inside .semgrep/ — semgrep skips hidden + # dot-dirs, so `--test .semgrep` from the repo root finds no tests. + - id: semgrep-rule-tests + name: semgrep-rule-tests + language: system + entry: bash -c 'cd .semgrep && exec uvx --quiet --from semgrep==1.169.0 semgrep --test .' + pass_filenames: false + files: ^\.semgrep/ + stages: [ pre-push ] + - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.1 + rev: d9fca3320346514799461a80b0753eb45d707d46 # 0.11.28 hooks: - id: uv-lock name: uv-lock-root @@ -73,29 +114,45 @@ repos: name: uv-lock-backend files: ^backend/(uv\.lock|pyproject\.toml|uv\.toml)$ entry: uv lock --project backend - - id: uv-lock - name: uv-lock-docs - files: ^docs/(uv\.lock|pyproject\.toml|uv\.toml)$ - entry: uv lock --project docs + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 01a675ea018f2fb714478a5ffb83fcea8374bb06 # v0.15.21 + hooks: + - id: ruff-check + args: [ "--config=pyproject.toml" ] + - id: ruff-format + args: [ "--config=pyproject.toml" ] - repo: local hooks: - - id: cspell - name: cspell - entry: pnpm exec cspell + - id: ty-root-scripts + name: ty-root-scripts + entry: uv run ty check language: system - types: [ text ] - # Disable cspell caching as pre-commit and CI doesn't play well with it. - args: [ "--no-cache", "--no-must-find-files" ] - - id: pnpm-lock-frontend-app - name: pnpm-lock-frontend-app - entry: just frontend-app/lockfile + files: ^scripts/.*\.py$ + pass_filenames: false + - id: brand-assets-check + name: brand-assets-check + entry: just assets-check + language: system + # NOTE: the target set (scripts/sync_brand_assets.py COPY_ASSETS / + # GENERATED_ASSETS / PROCESSED_ASSETS) spans too many disparate paths across + # app/, www/, docs/, backend/ for a maintainable regex; the check runs + # `--check` only (no rewrites) and completes in a few seconds, so run it + # unconditionally on every push instead. + always_run: true + pass_filenames: false + stages: [ pre-push ] + - id: backend-openapi-check + name: backend-openapi-check + entry: just backend/openapi-check language: system - files: ^frontend-app/package\.json$ + files: ^(?:backend/app/.*|backend/scripts/generate/export_openapi\.py|backend/justfile|backend/pyproject\.toml|backend/uv\.lock|docs/public/api/schemas/openapi\.(?:public|device)\.json|app/src/types/openapi\.json)$ pass_filenames: false - - id: pnpm-lock-frontend-web - name: pnpm-lock-frontend-web - entry: just frontend-web/lockfile + stages: [ pre-push ] + - id: pnpm-lock + name: pnpm-lock + entry: pnpm install --lockfile-only --ignore-scripts language: system - files: ^frontend-web/package\.json$ + files: ^(?:package\.json|pnpm-workspace\.yaml|app/package\.json|docs/package\.json|www/package\.json)$ pass_filenames: false diff --git a/.semgrep/browser-runtime-safety.astro b/.semgrep/browser-runtime-safety.astro new file mode 100644 index 000000000..b8070f1a8 --- /dev/null +++ b/.semgrep/browser-runtime-safety.astro @@ -0,0 +1,9 @@ +--- +const trustedHtml = 'trusted'; +--- + + +
+ + +
{trustedHtml}
diff --git a/.semgrep/browser-runtime-safety.tsx b/.semgrep/browser-runtime-safety.tsx new file mode 100644 index 000000000..0b4786aa6 --- /dev/null +++ b/.semgrep/browser-runtime-safety.tsx @@ -0,0 +1,44 @@ +// ruleid: relab.browser-runtime-safety.dom-html-sink +document.body.innerHTML = '

unsafe

'; + +// ruleid: relab.browser-runtime-safety.dom-html-sink +document.body.insertAdjacentHTML('beforeend', '

unsafe

'); + +// ruleid: relab.browser-runtime-safety.dom-html-sink +document.body.setHTMLUnsafe('

unsafe

'); + +// ruleid: relab.browser-runtime-safety.dom-html-sink +const Unsafe = () =>
unsafe

' }} />; + +// ok: relab.browser-runtime-safety.dom-html-sink +document.body.append(document.createElement('p')); + +function UnsafeWebView() { + return ( + // ruleid: relab.browser-runtime-safety.webview-unsafe-origin + + ); +} + +function PlaintextWebView() { + return ( + // ruleid: relab.browser-runtime-safety.webview-unsafe-origin + + ); +} + +function ReviewedWebView() { + return ( + // ok: relab.browser-runtime-safety.webview-unsafe-origin + + ); +} + +// ruleid: relab.browser-runtime-safety.webview-new-usage +const { WebView: ImportedWebView } = require('react-native-webview'); + +// ruleid: relab.browser-runtime-safety.webview-new-usage +import { WebView } from 'react-native-webview'; diff --git a/.semgrep/browser-runtime-safety.yml b/.semgrep/browser-runtime-safety.yml new file mode 100644 index 000000000..e77c3840b --- /dev/null +++ b/.semgrep/browser-runtime-safety.yml @@ -0,0 +1,43 @@ +rules: + - id: relab.browser-runtime-safety.astro-set-html + message: Do not render unsanitized HTML with Astro set:html; use structured components instead. + severity: ERROR + languages: [ generic ] + paths: + include: + - "/docs/src/**/*.astro" + - "/www/src/**/*.astro" + pattern-regex: '\bset:html\s*=' + + - id: relab.browser-runtime-safety.dom-html-sink + message: Do not write raw HTML into the DOM; use structured rendering or an explicitly reviewed sanitizer boundary. + severity: ERROR + languages: [ generic ] + paths: + include: + - "/docs/src/**" + - "/docs/public/**" + - "/www/src/**" + - "/www/public/**" + - "/app/src/**" + pattern-regex: '\.(?:innerHTML|outerHTML)\s*=|\binsertAdjacentHTML\s*\(|\bdocument\.write\s*\(|\bdangerouslySetInnerHTML\b|\.setHTMLUnsafe\s*\(' + + - id: relab.browser-runtime-safety.webview-unsafe-origin + message: Do not add permissive or plaintext React Native WebView origins. + severity: ERROR + languages: [ generic ] + paths: + include: + - "/app/src/**" + pattern-regex: 'originWhitelist\s*=\s*\{\s*\[[^\]]*(?:["'']\*["'']|["'']http://)' + + - id: relab.browser-runtime-safety.webview-new-usage + message: New React Native WebView usage must go through an explicit security review; keep WebView isolated to the reviewed product video component. + severity: ERROR + languages: [ generic ] + paths: + include: + - "/app/src/**" + exclude: + - "/app/src/components/product/ProductVideoEmbed.tsx" + pattern-regex: '(?:from\s+["'']react-native-webview["'']|require\(\s*["'']react-native-webview["'']\s*\))' diff --git a/.semgrep/third-party-browser-js.html b/.semgrep/third-party-browser-js.html new file mode 100644 index 000000000..17785a758 --- /dev/null +++ b/.semgrep/third-party-browser-js.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/.semgrep/third-party-browser-js.js b/.semgrep/third-party-browser-js.js new file mode 100644 index 000000000..332148f5a --- /dev/null +++ b/.semgrep/third-party-browser-js.js @@ -0,0 +1,14 @@ +// ruleid: relab.third-party-browser-js.remote-module-import +import "https://modules.example.com/widget.js"; + +// ruleid: relab.third-party-browser-js.remote-module-import +const widget = await import("https://modules.example.com/widget.js"); + +// ok: relab.third-party-browser-js.remote-module-import +import { initThemeControl } from "@/scripts/theme.ts"; + +// ruleid: relab.third-party-browser-js.cdn-runtime-reference +const scriptOrigin = "https://cdn.jsdelivr.net/npm/example"; + +// ok: relab.third-party-browser-js.cdn-runtime-reference +const badgeUrl = "https://img.shields.io/codecov/c/github/CMLPlatform/relab"; diff --git a/.semgrep/third-party-browser-js.yml b/.semgrep/third-party-browser-js.yml new file mode 100644 index 000000000..851cdb942 --- /dev/null +++ b/.semgrep/third-party-browser-js.yml @@ -0,0 +1,41 @@ +rules: + - id: relab.third-party-browser-js.remote-script-src + message: Do not load browser runtime scripts from third-party origins; bundle first-party code instead. + severity: ERROR + languages: [ generic ] + pattern-regex: ']*\bsrc\s*=\s*["''](?:https?:)?//' + + - id: relab.third-party-browser-js.remote-module-import + message: Do not import browser runtime JavaScript from remote URLs; install and bundle dependencies locally. + severity: ERROR + languages: [ generic ] + pattern-regex: '\b(?:import|export)\s+(?:[^;\n]*?\s+from\s+)?["''](?:https?:)?//|import\s*\(\s*["''](?:https?:)?//' + + - id: relab.third-party-browser-js.cdn-runtime-reference + message: Do not add CDN-hosted browser runtime assets; install and serve first-party bundled assets. + severity: ERROR + languages: [ generic ] + # Anchored so the marketing-tag rule below scans exactly the same surface. + paths: &browser-asset-paths + include: + - "/app/**" + - "/docs/src/components/**" + - "/docs/src/pages/**" + - "/docs/src/scripts/**" + - "/docs/public/**" + - "/docs/Caddyfile" + - "/docs/package.json" + - "/docs/astro.config.*" + - "/www/src/**" + - "/www/public/**" + - "/www/Caddyfile" + - "/www/package.json" + - "/www/astro.config.*" + pattern-regex: 'https?://(?:[^/\s"''`<>]+\.)?(?:cdn\.jsdelivr\.net|unpkg\.com|cdnjs\.cloudflare\.com|cdn\.[^/\s"''`<>]+)' + + - id: relab.third-party-browser-js.marketing-tag-signature + message: Do not add browser marketing or analytics tags without an explicit third-party JavaScript security review. + severity: ERROR + languages: [ generic ] + paths: *browser-asset-paths + pattern-regex: '\b(?:googletagmanager|google-analytics|gtag\s*\(|dataLayer|plausible\.io|matomo|hotjar|segment\.com)' diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 000000000..31c804f2d --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,14 @@ +**/__tests__/** +**/*.test.* +**/*.spec.* +**/e2e/** +**/node_modules/** +**/dist/** +**/.astro/** +**/.expo/** +**/coverage/** +**/playwright-report/** +**/test-results/** +**/pnpm-lock.yaml +app/src/types/api.generated.ts +.semgrep/** diff --git a/.syncpackrc.json b/.syncpackrc.json deleted file mode 100644 index d6d7513ce..000000000 --- a/.syncpackrc.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://unpkg.com/syncpack@14.3.0/schema.json", - "source": [ - "package.json", - "docs/package.json", - "frontend-web/package.json", - "frontend-app/package.json" - ] -} diff --git a/.tool-versions b/.tool-versions index 1856b91f2..1a9a9a640 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ -node 24.14.1 -pnpm 10.33.0 -python 3.14.3 -uv 0.11.7 -just 1.50.0 +node 26.4.0 +pnpm 11.13.0 +python 3.14.6 +uv 0.11.28 +just 1.51.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index f78c69eb8..86d3876f1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,24 +1,42 @@ { - "editor.formatOnSave": true, - "python.analysis.typeCheckingMode": "standard", - "python.terminal.activateEnvInCurrentTerminal": true, - "python.terminal.activateEnvironment": true, - "python.testing.pytestEnabled": true, - "[astro][javascript][typescript][javascriptreact][typescriptreact][json][jsonc]": { - "editor.defaultFormatter": "biomejs.biome", + "[astro][javascript][typescript][javascriptreact][typescriptreact]": { "editor.codeActionsOnSave": { "source.fixAll.biome": "always", "source.organizeImports.biome": "always" - } + }, + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[markdown]": { + "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" }, "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff", "editor.codeActionsOnSave": { "source.fixAll.ruff": "explicit", "source.organizeImports.ruff": "explicit" - } + }, + "editor.defaultFormatter": "charliermarsh.ruff" }, - "[markdown]": { - "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" - } + "biome.lsp.bin": "./node_modules/.bin/biome", + "biome.suggestInstallingGlobally": false, + "editor.formatOnSave": true, + "files.watcherExclude": { + "**/.astro/**": true, + "**/.git/objects/**": true, + "**/.pnpm/**": true, + "**/backend/.venv/**": true, + "**/dist/**": true, + "**/node_modules/**": true, + "**/playwright-report/**": true, + "**/test-results/**": true + }, + "python.analysis.typeCheckingMode": "standard", + "python.terminal.activateEnvInCurrentTerminal": true, + "python.terminal.activateEnvironment": true, + "python.testing.pytestEnabled": true } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b170bc93..ee660ed6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,7 +93,7 @@ Major expansion of the platform: reworked authentication, overhauled frontend-ap ### Description -Initial release of the Reverse Engineering Lab platform for circular economy and computer vision research. +Initial release of the Relab platform for circular economy and computer vision research. ### Features diff --git a/CITATION.cff b/CITATION.cff index c5c583a42..eaf9d46cc 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,12 +12,21 @@ authors: orcid: "https://orcid.org/0000-0002-8287-2413" email: "f.donati@cml.leidenuniv.nl" affiliation: "Department of Industrial Ecology, Institute of Environmental Sciences (CML), Leiden University" -title: "Reverse Engineering Lab - Data Collection Platform" +title: "Relab - Data Collection Platform" abstract: "Data collection platform for disassembled power tool images and metadata supporting computer vision tasks for life cycle assessments." version: 0.2.0 identifiers: + # The concept DOI comes first: it always resolves to the newest release, which is + # what a "cite this software" identifier should do. Cite the version DOI below only + # when pinning a specific release — provenance links, which must not move, do. + # Both are read by backend/tests, so the version entry needs bumping alongside + # `version:` and `date-released:` on every release. + - type: doi + value: 10.5281/zenodo.16637742 + description: Concept DOI for all versions of Relab - type: doi value: 10.5281/zenodo.19703316 + description: Version DOI for Relab v0.2.0 date-released: 2026-04-23 url: https://cml-relab.org repository-code: "https://github.com/CMLPlatform/relab" diff --git a/LICENSE-APACHE-2.0 b/LICENSE-APACHE-2.0 new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/LICENSE-APACHE-2.0 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..4d316cc06 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,245 @@ +# Product + +What Relab is, who it serves, and which facts about it are settled. This covers the product as a +whole; `app/`, `www/`, and `docs/` are surfaces of it. Per-surface strategy lives in the surface +briefs. + +## Purpose + +Relab is a data collection and viewing platform for a PhD at Leiden University (CML), built around +the circular economy and the 9R strategies. Users document a physical product, break it into +components, and tag those components with materials and circularity observations. The output is a +structured, citable research dataset about how real products are built and how recoverable their +materials are. + +Success has an order, and it is load-bearing: + +1. PhD evidence and credibility. The platform must stand up as defensible research infrastructure. +1. Outside contribution and a published dataset. Both are real goals. Neither outranks credibility + when they conflict. + +Proof beats conversion. A surface that recruits contributors at the cost of looking like serious +research infrastructure has made the wrong trade. Capture accuracy also outranks capture speed. + +Non-goals, from `docs/.../project/use-cases.md`: not a PLM system, not mass-scale crowdsourcing, +not an automated computer-vision pipeline, not an elastic cloud platform. + +## Platform + +Web ships. `build` is `expo export -p web`, E2E runs Playwright against that export, and the +deployed surface is `app.cml-relab.org` behind Caddy. + +Native iOS and Android are configured in `app/app.json` but this repo's tooling does not build or +release them. Native remains a live target, so design nothing that could not be carried to a phone +build. + +## Users + +The audience is staged, in build order: + +1. CML lab researchers and students. Now, primary. Internal, authenticated, trained. +1. External contributors. Near term. Public profiles at `/users/[username]`, a + `profile_visibility` preference, and email-verification gating already anticipate them. +1. Repair-café visitors. Medium term. Untrained, one-off, on-site. +1. General citizen scientists. Long term. + +Levels 3 and 4 are a trajectory, not a current requirement. They are recorded because they change +what "good" means for onboarding, empty states, terminology, and error recovery. A trained lab user +tolerates schema vocabulary that a repair-café walk-in will not. + +Access rules: + +- Signed-out visitors can browse. `/` redirects to `/products` and the list renders anonymously. +- Verification gates creation. Unverified accounts see "Verify your email to start creating". +- A username is required to own records. +- `isSuperuser` grants the backend's `/admin` routes and nothing in the app. The app displays it as + a badge and is not planned to become an admin surface. + +Accounts carry a `role` of `contributor` (default) or `lab`, alongside the three booleans. `lab` +grants non-image research-file upload and the larger upload quota. Only a superuser assigns a role, +through `PUT /v1/admin/users/{user_id}/role`; `role` is absent from the self-service update schema. +Existing accounts were backfilled to `contributor`, so lab members are promoted by hand after +deploy. The enum stops at two values on purpose: a "viewer" is an unverified account and an "admin" +is a superuser, so more rungs would give each of those two sources of truth. + +## Positioning + +Site line: "Relab | Product data and disassembly records for the circular economy." + +The mechanism a neighbouring product could not truthfully copy: teardown records captured by people +with the product physically disassembled in front of them. Recursive product → component hierarchy +of unbounded depth, photographic evidence at every node, and per-node circularity observations. +Field-captured primary data, not scraped specifications or inferred bills of materials. + +Relab works downstream, at the point of failure, with the people who already open products. That +vantage captures as-failed composition, wear, and recoverability that as-designed producer +specifications never record. + +The sharpest comparison: repair groups have logged more than 400,000 records under the Open Repair +Data Standard, but those are event-level, with no component hierarchies, no masses, no geometry. +Relab's contribution is exactly that missing depth. + +**Careful with the 9R framing.** The 9R strategies motivate the project and supply its wordmark. +They are not applied per record. The platform does not tag a record with an R-strategy and does not +recommend one. Do not write copy implying records are classified, scored, or captured against the +9R ladder. + +## Operating context + +**The bench.** A person with a product taken apart in front of them, hands occupied, camera in use, +working through a hierarchy one node at a time. Capture is interleaved with disassembly, not done +afterward from notes. + +**Capture-first creation.** `/products/new` opens straight into a capture screen, POSTs, then +`replace`s to the record in edit mode. It uses `replace` so a back gesture cannot re-submit the +form. `components/new` repeats this one level down. Nesting is uncapped; breadcrumbs truncate at 12 +ancestors. + +**Raspberry Pi camera rig.** An optional paired capture device, enabled per user via the +`rpi_camera_enabled` preference. Pairing uses a 6-character code plus a camera name. Pi captures can +return `queued`, meaning the frame is on the device but not yet uploaded. A YouTube live-streaming +flow sits on the same device. + +**Connectivity is not assumed.** Save mutations pause offline and fire on reconnect. The UI shows +"Queued — sends when online". + +**Screen sizes.** Phone layout is the base. Web adapts at `md` (768) and `lg` (1024); at `lg` a +persistent `TopNav` replaces the stack header. `useBreakpoint()` is web-only, so native always reads +as phone. + +## Constraints that shape design + +**Data model.** One entity with `role: 'product' | 'component'` and a parent link. Physical +properties are weight, width, height, depth, with `undefined` as the deliberate "unset" sentinel. +Circularity properties are exactly three free-text notes: Recyclability, Disassemblability, +Remanufacturability. + +**Uncertainty is data, not failure.** "Likely polypropylene, unconfirmed" is a good observation, and +contributors are told to leave a field empty rather than pick the closest wrong match. So an empty +or unconfirmed field must never render as an error, a warning, a red state, or a completeness +penalty. A record shown as a progress bar toward 100% would reward false precision and damage the +dataset. + +**Two hardware tiers, and the low one is legitimate.** A phone or tablet plus a scale and a ruler is +enough to contribute usefully. The Pi rig only pays off for repeatable imagery across many products. +No surface may present it as a prerequisite. + +**Uploads.** Images only in-app: jpeg, png, webp, gif, bmp, 10 MB hard cap each. The backend rejects +an upload unless extension, declared MIME type, and sniffed content agree. Uploads are sequential by +design. Quotas are tiered by role (contributor 1,000 files / 1 GB; lab 20,000 files / 20 GB) and +`/users/me` reports limits and usage. There is no per-product limit. + +**Idempotency.** Creates carry an `Idempotency-Key` minted once per draft, so a retry, a rehydrated +draft, or a second Save press cannot double-create. + +**CPV taxonomy.** Product and component types come from the EU procurement taxonomy, shipped as a +~2 MB bundled `cpv.json` loaded once per session. + +**Auth.** Password plus exactly two OAuth providers (Google, GitHub). MFA is TOTP with single-use +recovery codes. OAuth-only accounts have no usable password, which changes whether unlinking a +social login requires one. + +Runtime hardening lives in `app/Caddyfile`; the security baseline lives in +[.github/SECURITY.md](.github/SECURITY.md). + +## Deliberately absent + +Recorded so nobody assumes them into existence: + +- **Video is URL-only.** `Product.videos` holds links. Self-hosted video is deferred, roughly a year + out. Do not design upload affordances for it. +- **Non-image file upload is lab-only**, in a "Research files" block in the Media section, edit mode + only, base product only. No general-purpose upload affordance exists outside it. +- **Offline queueing is web-only in practice.** `onlineManager` has no native connectivity listener + yet, so a native build never pauses. Tracked as a TODO in `app/src/app/_layout.tsx`. +- **No localization.** All copy is hardcoded English. This becomes a real barrier at the repair-café + stage and is currently unplanned. +- **Three documented design goals are not shipped:** the value-return-to-contributors loop, taxonomy + interoperability, and per-record 9R classification. Copy implying any of them is live is a factual + error. +- **No published dataset release, no screenshots, no app store listing, no testimonials, benchmarks, + pricing, or adoption figures.** A first dataset release and the first screenshots are expected + soon; write about them in the present tense only once they land. + +**Three numbers that look alike and are not.** The pilot study produced 1,331 component records and +3,610 images from 78 products (`docs/src/content/docs/project/index.md`); those are historical, not +the live dataset. `www/src/lib/landing.ts` fetches live totals from `backend/app/api/stats` at build +time; those are current. `www/src/components/BrandHero.test.ts` pins a fallback fixture whose 3610 +collides with the pilot image count by coincidence. Never conflate them. + +## Brand commitments + +- **The name is "Relab" in all text:** copy, titles, alt text, aria-labels, metadata. "R9lab" is a + visual wordmark device that lives only in logo artwork. Never as text, never "R-nine-lab". +- **Never write "Reverse Engineering Lab".** This is an IP-law concern, not a style preference. +- **The name, logo, and wordmark carry no licence.** Nominative use needs no permission. +- **Direction: "Cyanotype & Manila — the colour of engineering documentation."** "Verdigris & + Copper" is the documented fallback, still pending supervisor review. +- **The accent is data, not decoration.** Manila labels facts: R-numbers, record IDs, status pills. + It never fills a button and never drives a hover or pressed state. Primary blue carries all + interaction. This is the rule most likely to be violated by visual enthusiasm, and the one that + keeps the interface reading as engineering documentation. +- **Palette is machine-enforced.** `assets/brand.css` and `assets/palette.json` are the sources; + `app/src/theme/__tests__/palette-sync.test.ts` fails if they drift. +- **Type: IBM Plex superfamily** on web and docs. The Expo app stays on platform system fonts for + native feel and Dynamic Type, capped at 2×. It adopts the scale and palette, not the typeface. +- **Form language "Flat & Sharp":** radius 6/8/12/9999, inline surfaces flat with a 1px hairline, + one `shadow-overlay` tier for things that genuinely float. +- **Icons: `lucide-react-native` at 16/20/24.** Brand marks are vendored Simple Icons SVGs rendered + with `currentColor`, never recoloured to their own palette. +- **Voice:** circularity framing, lab vernacular (products, components, materials, samples). + +## Principles + +1. **The bench beats the desk.** Any step, confirmation, or mode switch between observing and + recording costs real data. +1. **The dataset is the deliverable.** Accuracy outranks speed and delight. Idempotent creates, + offline queueing, and unset sentinels all exist because a lost or duplicated observation is worse + than a slow one. +1. **Nothing is gated that does not need to be.** Each existing gate earns its place. No new one + should appear without the same justification. +1. **Vocabulary is a staged liability.** Prefer terminology that survives the audience expansion + over terminology that mirrors the schema. + +## Accessibility + +**Target: WCAG 2.2 AA** across www, docs, and app. Chosen over 2.1 AA because Leiden University +public surfaces fall under the EU Web Accessibility Directive via EN 301 549. + +All three surfaces tag `wcag2a`/`wcag2aa`/`wcag21a`/`wcag21aa`/`wcag22a`/`wcag22aa`; www and docs +fail on any violation, the app filters to `serious` and `critical` (RN-Web emits minor/moderate +noise it cannot fix). axe-core 4.12 ships exactly one 2.2-only rule, `target-size` (2.5.8), and it +is now enforced — it passes on www, docs, and the app's products and detail screens, backing up +`MIN_TAP_TARGET = 44`. + +A named gap remains, narrower than before: **2.4.11 Focus Not Obscured** (sticky headers, the docs +sidebar) and **2.4.13 Focus Appearance** (pairs with `--color-ring` on www) have no axe rule at all +and are verified by hand. + +Enforced today: + +- axe in Playwright against the web build, gating on `serious` and `critical`. `color-contrast` is + disabled there because RN-Web emits noise the app cannot fix; contrast is verified instead in + `semantic-contrast.test.ts` and `palette-sync.test.ts` against the committed palette. +- `MIN_TAP_TARGET = 44`, with icon-only controls keeping a ≥44px hit area. +- `eslint-plugin-react-native-a11y` blocks: `lint:react` must pass with zero warnings. +- `prefers-reduced-motion` via `ReduceMotion.System` on Reanimated transitions. +- Screen-reader parity handled explicitly: `accessibilityLiveRegion` is Android-only and `aria-live` + is web-only, so iOS gets an `AccessibilityInfo.announceForAccessibility` call. + +No manual keyboard or assistive-technology pass has been run on any surface. Automated coverage is a +floor. + +The public statement lives at `www/src/pages/accessibility.astro` (copy in +`src/copy/accessibility-content.ts`), linked from the site footer. It claims partial conformance and +names what is untested, which is the honest shape and the one the Web Accessibility Directive asks +for. + +That page is the user-facing half only. The compliant artifact under the Dutch implementation +(Tijdelijk besluit digitale toegankelijkheid overheid) is a register entry per domain, filed through +the invulassistent at toegankelijkheidsverklaring.nl and signed by someone who can bind the +university. **Open, and not the maintainer's alone to close:** ask Leiden's accessibility +coordinator whether `cml-relab.org`, `app.cml-relab.org`, and `docs.cml-relab.org` are covered by an +existing entry or need their own. A second statement that contradicts the university's is worse than +none, so the page deliberately omits a feedback response window and an escalation body until that +answer lands. The decree's own baseline is WCAG 2.1 AA, so the 2.2 target clears it with room. diff --git a/README.md b/README.md index 2a077ad01..b0344126b 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,23 @@ -# RELab +

+ Relab +

+ +# Relab [![Version](https://img.shields.io/github/v/release/CMLPlatform/relab?include_prereleases&filter=v*)](CHANGELOG.md) -[![License: AGPL-v3+](https://img.shields.io/badge/License-AGPL--v3+-rebeccapurple.svg)](LICENSE.md) -[![Data License: ODbL](https://img.shields.io/badge/Data_License-ODbL-rebeccapurple.svg)](https://opendatacommons.org/licenses/odbl/) +[![License: AGPL-v3+](https://img.shields.io/badge/License-AGPL--v3+-rebeccapurple.svg)](LICENSE) +[![Data License: CC BY 4.0](https://img.shields.io/badge/Data_License-CC_BY_4.0-rebeccapurple.svg)](https://creativecommons.org/licenses/by/4.0/) [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.16637742-blue.svg)](https://doi.org/10.5281/zenodo.16637742) [![Coverage](https://img.shields.io/codecov/c/github/CMLPlatform/relab)](https://codecov.io/gh/CMLPlatform/relab) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/CMLPlatform/relab/badge)](https://scorecard.dev/viewer/?uri=github.com/CMLPlatform/relab) [![FAIR checklist badge](https://fairsoftwarechecklist.net/badge.svg)](https://fairsoftwarechecklist.net/v0.2?f=31&a=32113&i=22322&r=123) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](.github/CODE_OF_CONDUCT.md) [![Deployed](https://img.shields.io/website?url=https%3A%2F%2Fcml-relab.org&label=website)](https://cml-relab.org) -RELab is an open-source research platform for collecting and publicly viewing data on the disassembly of durable goods. It is built at [CML, Leiden University](https://www.universiteitleiden.nl/en/science/environmental-sciences) to support industrial ecology and circular economy research through better primary product data generation. +Relab is an open-source research platform for collecting and publicly viewing data on the +disassembly of durable goods. It is built at +[CML, Leiden University](https://www.universiteitleiden.nl/en/science/environmental-sciences) to +support industrial ecology and circular economy research by generating better primary product data. It combines: @@ -18,20 +26,35 @@ It combines: - an Astro site for publicly viewing project and dataset information - a separate docs site for architecture, workflows, and deployment notes -The platform is meant to do two things at once: +The platform is meant to do two things at once: support structured data collection during +disassembly work, and make that data easier to publish, browse, and reuse later. -- support structured data collection during disassembly work -- make that data easier to publish, browse, and reuse later +The broader research vision comes from a simple problem: circular-economy and industrial-ecology +research depends on detailed product data — what things are made of, how they come apart, which +parts matter — yet that data is scarce, mostly closed, and slow to produce. Producers tend to treat +it as proprietary, and the alternative of small expert teams sampling by hand cannot keep pace with +the products entering the market. -The broader research vision comes from a simple problem: industrial ecology has many data platforms, but far fewer open, low-barrier workflows for generating new standardized product-level observations. +Relab addresses that gap with a bottom-up model: -RELab addresses that gap with a bottom-up model: +- middle- and end-of-life actors such as repairers, refurbishers, dismantlers, and recyclers can + contribute data directly +- these downstream actors meet products at the point of failure, capturing as-failed composition, + wear, and recoverability that as-designed producer specifications never show +- collaborative and citizen-science style workflows can turn routine repair and disassembly into + structured observations +- the resulting records can be shared openly, linked to related databases, and reused in later + research -- middle- and end-of-life actors such as repairers, refurbishers, dismantlers, and recyclers can contribute data directly -- collaborative and citizen-science style workflows can turn routine repair and disassembly into structured observations -- the resulting records can be shared openly, linked to related databases, and reused in later research +Contributors get value back too — composition insight, sustainability metrics, and repair or +R-strategy guidance — so routine disassembly becomes a two-way exchange rather than one-way data +entry. We call this a circular data economy: middle- and end-of-life observations feed back into the +upstream data infrastructure that research and design rely on, complementing the top-down flow of +producer specifications. -The long-term goal is to contribute to an open industrial ecology data commons by combining collaborative data collection, public data access, interoperability with existing and upcoming databases, and AI-ready structured observations. +The long-term goal is to contribute to an open industrial ecology data commons: data that is +collected collaboratively, publicly accessible, linkable to existing and upcoming databases, and +structured enough for machine-learning use. ## Start Here @@ -41,48 +64,96 @@ The fastest path is the hosted platform: If you want to go deeper: -- [Install & Self-Host](https://docs.cml-relab.org/architecture/install/) for running or self-hosting the stack +- [Install and self-host](https://docs.cml-relab.org/operations/install/) for running or + self-hosting the stack - [CONTRIBUTING.md](.github/CONTRIBUTING.md) for making code or docs changes - [docs.cml-relab.org](https://docs.cml-relab.org) for architecture and user-facing docs ## Monorepo -| Path | Purpose | -| --------------- | ----------------------------------------------------- | -| `backend/` | FastAPI API, auth, data model, file handling, plugins | -| `frontend-app/` | Expo / React Native research app | -| `frontend-web/` | Astro public website | -| `docs/` | Documentation site | +| Path | Purpose | +| ---------- | ----------------------------------------------------- | +| `backend/` | FastAPI API, auth, data model, file handling, plugins | +| `app/` | Expo / React Native research app | +| `www/` | Astro public website | +| `docs/` | Documentation site | Infrastructure is orchestrated with Docker Compose from the repo root. +Shared brand assets live in `assets/` and are synced into the consumer +subrepos with `just assets-sync`. + +Configuration has five homes: committed public prod/staging identity in +`deploy/env/*.compose.env`, deploy-host inputs in the gitignored root `.env`, +runtime secrets in gitignored `secrets//` files, optional backend-only +local overrides in `backend/.env.dev`, and framework/test fixtures such as +`app/.env.development` and `backend/.env.test`. + ## Common Commands ```bash -just setup # install workspace dependencies and pre-commit hooks +just setup # install workspace dependencies and git hooks just ci # run the canonical local CI pipeline just test # run local test suites just security # run dependency and security checks just dev # start the full Docker dev stack with file watching +just deploy-secrets-template dev # create local backend secret files ``` +## Accessibility + +Accessibility is checked in CI: axe scans plus per-PR a11y lint across `www/`, +`docs/`, and `app/`. See [Quality Controls](.github/CONTRIBUTING.md#quality-controls) +for what runs where. + ## Project Links - [Live Platform](https://app.cml-relab.org) - [Documentation](https://docs.cml-relab.org) -- [API Docs](https://api.cml-relab.org/docs) +- [API Docs](https://docs.cml-relab.org/api/public/) - [Roadmap](https://docs.cml-relab.org/project/roadmap) ## Community and Policy - [Contributing](.github/CONTRIBUTING.md) -- [Install & Self-Host](https://docs.cml-relab.org/architecture/install/) +- [Install and self-host](https://docs.cml-relab.org/operations/install/) - [Security](.github/SECURITY.md) - [Code of Conduct](.github/CODE_OF_CONDUCT.md) - [Changelog](CHANGELOG.md) - [Citation](CITATION.cff) - [License](LICENSE) +## Licensing + +Four layers, licensed separately on purpose: + +| What | Licence | Why | +| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Platform software — backend, app, www, docs site | [AGPL-3.0-or-later](LICENSE) | Copyleft, including over a network, so a hosted fork stays open. | +| API specification — the OpenAPI schemas this repository generates (`openapi.public.json`, `openapi.device.json`) and the client types generated from them | [Apache-2.0](LICENSE-APACHE-2.0) | An integration surface should not pass copyleft to anyone writing a client or an importer. Includes a patent grant. | +| Curated dataset releases | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | One instrument over the database structure and its contents. CC 4.0 licenses the EU *sui generis* database right, so no second licence is needed for the data. Attribution is the whole obligation, which keeps the dataset usable for machine learning. | + +Site content — the writing on the docs site and on cml-relab.org — is +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Copyleft over prose is an odd fit, since +its obligations are about providing corresponding source. Code samples in the documentation are +Apache-2.0 instead, because CC licences are not meant for software. + +**Relab, the logo and the wordmark are not licensed by any of the above.** No licence here +grants rights in them, and CC BY says so explicitly (§2(b)(2)). Use the name to refer to this +project, not to identify your own work as Relab. + +The RPi camera plugin's schema (`openapi.rpi-cam.json`) is published here for reference but is +fetched verbatim from its own repository, +[relab-rpi-cam-plugin](https://github.com/CMLPlatform/relab-rpi-cam-plugin), and carries whatever +licence that project sets — currently Apache-2.0 as well, so all three published schemas answer +alike. + +Two limits that no licence changes. Personal data is governed by the GDPR regardless of the licence, +so it is the sanitisation applied when building a release — not CC BY — that makes publication +lawful. And CC BY grants no trademark rights: teardown photographs necessarily show brand marks and +model numbers, which the licence covers as images without licensing the marks themselves. + ## Contact -Questions about the platform, code, or dataset: [relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl) +Questions about the platform, code, or dataset: +[relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl) diff --git a/app/.env.development b/app/.env.development new file mode 100644 index 000000000..458939839 --- /dev/null +++ b/app/.env.development @@ -0,0 +1,10 @@ +# Development overrides for the Expo app environment variables. +# URLs of related services (overridden by compose.dev.yml in Docker) +EXPO_PUBLIC_API_URL='http://127.0.0.1:8010' # The URL of the locally hosted backend API. +EXPO_PUBLIC_WEBSITE_URL='http://127.0.0.1:8013' # The URL of the locally hosted frontend website. +EXPO_PUBLIC_DOCS_URL='http://127.0.0.1:8012' # The URL of the locally hosted documentation site. + +# For Expo Go or another LAN device, run the Expo server on the host with `just dev`. +# Docker dev ports are intentionally localhost-only. +# EXPO_PUBLIC_API_URL=http://192.168.X.X:8010 +# EXPO_PUBLIC_WEBSITE_URL=http://192.168.X.X:8013 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..d538f8f87 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,55 @@ +### Auto-generated +# Dependencies +node_modules/ + +# Expo +.expo/ +expo-env.d.ts +src/uniwind-types.d.ts +dist/ +web-build/ + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +android/ +ios/ + +# Metro +.metro-health-check* + +# TypeScript +*.tsbuildinfo +.eslintcache + +# Debug logs +npm-debug.* +yarn-debug.* +yarn-error.* + +# Local certs / keys +*.pem + +# Local env overrides +.env.local + +### Manual additions +# Testing +coverage/ +junit.xml +playwright-report/ +test-results/ + +# Custom jest timing output files +.jest-*.json + +# Env files (frontend env files are all public) +!.env.development +!.env.staging +!.env.prod +!.env.test diff --git a/app/Caddyfile b/app/Caddyfile new file mode 100644 index 000000000..c54a83275 --- /dev/null +++ b/app/Caddyfile @@ -0,0 +1,81 @@ +{ + admin off +} + +:8081 { + root * /srv + encode zstd gzip + + handle /health { + respond "ok" 200 + } + + @dangerous_methods method TRACE TRACK CONNECT + handle @dangerous_methods { + header Allow "GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD" + respond "HTTP method is not allowed." 405 + } + + header { + # Prevent MIME-type sniffing + X-Content-Type-Options "nosniff" + # Force HTTPS on subsequent visits + Strict-Transport-Security "max-age=63072000; includeSubDomains" + # Restrict referrer information + Referrer-Policy "no-referrer" + # Isolate top-level browsing context and same-site resources without blocking YouTube embeds. + Cross-Origin-Opener-Policy "same-origin" + Cross-Origin-Resource-Policy "same-site" + # Only declare features we intentionally allow; product photos need same-origin camera access. + Permissions-Policy "camera=(self)" + # Content Security Policy + # - script-src 'unsafe-inline' 'unsafe-eval': Expo web export uses + # inline bootstrap code and runtime eval-based module loading. + # - style-src 'unsafe-inline': React Native Web requires inline styles + # - img-src data: blob:: used for image previews before upload + # - connect-src blob:: Expo web uses blob-backed worker/runtime URLs + # in addition to the API endpoint for data fetching. + # - worker-src blob:: proactively allow blob-backed workers if the + # web runtime starts using them explicitly. + # - media-src blob:: covers blob-backed previews/snapshots without + # opening media loading to arbitrary origins. + # - frame-src youtube-nocookie: product video embeds use YouTube's + # privacy-enhanced embed host. + # - Report-Only policy: observes the intended strict script policy + # while Expo web still requires unsafe inline/eval script support. + # - CADDY_API_ORIGIN: injected by the container environment per deployment + -Content-Security-Policy + -Content-Security-Policy-Report-Only + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: {$CADDY_API_ORIGIN}; connect-src 'self' blob: {$CADDY_API_ORIGIN}; worker-src 'self' blob:; media-src 'self' blob: {$CADDY_API_ORIGIN}; font-src 'self' data:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" + Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; script-src-elem 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: {$CADDY_API_ORIGIN}; connect-src 'self' blob: {$CADDY_API_ORIGIN}; worker-src 'self' blob:; media-src 'self' blob: {$CADDY_API_ORIGIN}; font-src 'self' data:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" + } + + # Hashed, content-addressed static assets — serve directly with no SPA fallback + # and cache immutably. Without this, a missing chunk would fall through to + # try_files and serve index.html as text/html, which nosniff then blocks as a + # MIME mismatch. A real 404 is unambiguous and fails fast. + @static_assets path /_expo/static/* /assets/* + handle @static_assets { + header Cache-Control "public, max-age=31536000, immutable" + file_server + } + + # Email verification and password-reset URLs carry single-use tokens in + # fragments. Serve the SPA entry point without browser storage. + @sensitive_auth_routes path /verify* /reset-password* + handle @sensitive_auth_routes { + header Cache-Control "no-store" + try_files {path} /index.html + file_server + } + + # SPA routing: fall back to index.html for navigation requests. + # no-cache ensures the browser always revalidates index.html after a deploy + # so it picks up the latest chunk hashes rather than loading a stale entry + # point that references chunks from the previous build. + handle { + header Cache-Control "no-cache" + try_files {path} /index.html + file_server + } +} diff --git a/app/DESIGN.md b/app/DESIGN.md new file mode 100644 index 000000000..a04bbdf40 --- /dev/null +++ b/app/DESIGN.md @@ -0,0 +1,427 @@ +--- +name: Relab App +description: Field data-collection app for circular-economy product teardowns — the colour of engineering documentation. +colors: + background: '#FAFBFE' + foreground: '#16202E' + card: '#F0F3FA' + primary: '#1F4C96' + primary-foreground: '#FFFFFF' + secondary: '#565E71' + muted: '#E0E2EC' + muted-foreground: '#44474F' + manila: '#8F6212' + manila-foreground: '#FFFFFF' + destructive: '#BA1A1A' + border: '#C4C6D0' + input: '#74777F' + ring: '#1F4C96' + status-success: '#2E7D32' + status-warning: '#A05A00' + status-info: '#1565C0' + status-offline: '#5A6675' + status-live: '#8F6212' +typography: + display: + fontFamily: system-ui + fontSize: 38px + lineHeight: 44px + title: + fontFamily: system-ui + fontSize: 24px + lineHeight: 30px + heading: + fontFamily: system-ui + fontSize: 19px + lineHeight: 24px + body: + fontFamily: system-ui + fontSize: 16px + lineHeight: 26px + label: + fontFamily: system-ui + fontSize: 13px + lineHeight: 18px + letterSpacing: 1.3px + caption: + fontFamily: system-ui + fontSize: 13px + lineHeight: 18px + data: + fontFamily: Menlo, monospace + fontSize: 14px + lineHeight: 20px + eyebrow: + fontFamily: system-ui + fontSize: 13px + lineHeight: 18px + letterSpacing: 1.3px +rounded: + control: 6px + card: 8px + overlay: 12px + full: 9999px +spacing: + xs: 4px + sm: 8px + md: 16px +components: + button-primary: + backgroundColor: '{colors.primary}' + textColor: '{colors.primary-foreground}' + rounded: '{rounded.control}' + height: 44px + button-outline: + textColor: '{colors.primary}' + rounded: '{rounded.control}' + height: 44px + button-destructive: + backgroundColor: '{colors.destructive}' + textColor: '#FFFFFF' + rounded: '{rounded.control}' + height: 44px + chip: + backgroundColor: '{colors.card}' + textColor: '{colors.primary}' + rounded: '{rounded.control}' + height: 44px + input: + textColor: '{colors.foreground}' + rounded: '{rounded.control}' + height: 44px + card: + backgroundColor: '{colors.card}' + textColor: '{colors.foreground}' + rounded: '{rounded.card}' + status-pill: + typography: '{typography.label}' + rounded: '{rounded.control}' + height: 24px +--- + +# Design System: Relab App + +## Overview + +**Creative North Star: "Cyanotype & Manila — the colour of engineering documentation"** + +*Mirrors assets/DESIGN.md:8 — change it there first.* + +The app is a field instrument, not a showcase. Its user is at a bench with a product in pieces, +hands occupied, working down a hierarchy one node at a time. Everything here follows from that: +the interface is a surface you write **on**, not one you look **at**. Prussian blue carries every +action; manila marks the data. Surfaces are flat, edges are sharp, and nothing floats unless it +genuinely floats above something else. + +Component character is **precise and unfussy** — controls behave like well-made lab instruments: +exact, quiet, no ornament, no theatre. A control's job is to be unambiguous at a glance and +forgiving under a thumb, not to reward attention. Where a decision is between expressive and +legible, legible wins; the record outlives the session. + +This app diverges from its sibling web surfaces in two ways, each stated as a rule below: it +stays on **platform system fonts** rather than IBM Plex, and it keeps the **eyebrow** type +variant that www and docs do not use. Both are deliberate. + +**Key Characteristics:** + +- Flat base, exactly one floating tier — no ambient shadow anywhere else. +- Blue is interaction, manila is data. The two never trade jobs. +- 44px touch floor, monospace with tabular figures for every measurement. +- Dark mode is parity, not an afterthought — every token is a scheme-aware pair. +- Motion is functional and always honours `prefers-reduced-motion`. + +### Why brand rules are restated here rather than referenced + +`assets/DESIGN.md` is the estate-wide brand source, and this file is the app's. Someone building +a screen reads this one. A rule that lives here only as a cross-reference to another document is +a rule they will not read, and every rule below has been broken at least once in this codebase by +someone who did not know it existed. + +So the five load-bearing brand rules are restated here **in full**. Each carries a +`Mirrors assets/DESIGN.md:NN` marker, so one search over the repository lists every copy of every +mirrored rule and the line it came from — a copy that names its source is a cache, a copy that +does not is a fork. + +The duplication is deliberate. Change a mirrored rule in `assets/DESIGN.md` first, then update +its copies. Do not consolidate them back into references. + +## Colors + +A cyanotype palette: a deep Prussian blue against near-white paper, with a single earthy manila +accent reserved for data. Both schemes are generated from `assets/palette.json` into +`src/theme/palette.generated.ts` and verified by `src/theme/__tests__/palette-sync.test.ts` — +do not hand-edit either. + +### Primary + +- **Prussian Blue** (`#1F4C96` light / `#8FB8FF` dark): Every interactive element. Buttons, + links, focus rings, active navigation, selected state. If a thing responds to touch, it is + blue. + +### Secondary + +- **Slate** (`#565E71` / `#BEC6DC`): Supporting chrome and secondary labels where full + foreground weight would over-emphasise. + +### Tertiary + +- **Manila** (`#8F6212` / `#E3B95C`): Data labels only — R-numbers, record IDs, live and status + pills, strategy tags. Never an interaction colour. + +### Neutral + +- **Paper** (`#FAFBFE` / `#11141D`): Page background. +- **Ink** (`#16202E` / `#E2E6EE`): Primary text. +- **Card** (`#F0F3FA` / `#1A2030`): Cards, panels, list rows. Reached in code as `bg-card`. +- **Muted Ink** (`#44474F` / `#C4C6D0`): Secondary text; meets 4.5:1 on paper in both schemes. +- **Hairline** (`#C4C6D0` / `#44474F`): The canonical border. Dividers, card edges, input + strokes. + +### Status + +Scheme-aware pairs in `src/theme/tokens.ts`: success `#2E7D32`/`#7BC67E`, warning +`#A05A00`/`#FFB74D`, danger = destructive, info `#1565C0`/`#90CAF9`, offline `#5A6675`/`#9E9E9E`, +live = manila. + +### Named Rules + +**The Data-Label Rule.** The manila accent is a **data-label colour** — R-numbers, record IDs, +small data labels, live/status pills, strategy tags. **Accent is for small text, never for +mass.** It never fills a button, never drives a hover or pressed state, and never paints bars, +big figures, or large areas. Interaction is always primary blue. *Mirrors +assets/DESIGN.md:173-190 — change it there first.* + +**The One Tint Rule.** `tokens.surface.accent` — primary at 12% — is the single canonical +selected/tinted fill: chips, history rows, toggles, active nav. The Tailwind spelling is +`bg-primary/12`. There is exactly one tint value; `bg-primary/10` is not a second opinion, it is +a bug. + +**The Primary-Strong Rule.** Pressed and hover states on filled controls use the real +`primary-strong` shade — `#143567` light / `#BAD3FF` dark — not alpha on the primary. In Tailwind +that is `active:bg-primary-strong` / `hover:bg-primary-strong`. + +`assets/brand.css` and `assets/palette.json` are separate vocabularies — web-brand and +shadcn-shaped — reconciled by the `BRAND_PARITY` table in `scripts/sync_brand_assets.py`. +`primaryStrong` is in both, so `just assets-check` fails if the app and web values drift. + +Note the dark-scheme direction: `primary-strong` is *lighter* than `primary` in dark mode +(#BAD3FF over #8FB8FF). Pressed means more energy, not more ink. + +## Typography + +**Display / UI Font:** platform system font (San Francisco on iOS, Roboto on Android, system UI +stack on web) +**Data / Label Font:** platform monospace (Menlo on iOS, `monospace` elsewhere), with +`font-variant: tabular-nums` + +**Character:** Neutral and native. The app borrows the *scale and palette* of the brand's IBM +Plex system without shipping the typeface — measurements line up in monospace, everything else +gets out of the way. + +### Hierarchy + +- **Display** (38/44): The one big number or name on a screen. Profile hero, account identity. +- **Title** (24/30): Screen and section titles. +- **Heading** (19/24): Subsection headers, card titles. +- **Body** (16/26): Prose, descriptions, form values. Selectable by default. +- **Label** (13/18, +1.3 tracking): Field labels and dense chrome. +- **Caption** (13/18): Helper text, timestamps, secondary annotations. +- **Data** (14/20, monospace, tabular figures): Every measurement, ID, count, and code. If it is + a number the user might compare to another number, it is `data`. +- **Eyebrow** (13/18, +1.3 tracking, uppercase, muted ink): Labels a **value inside a compact + tag**. + +### Named Rules + +**The System-Font Rule.** The app **intentionally stays on platform system fonts** — native +feel, Dynamic Type support, zero load cost. It adopts the brand's scale and palette, not its +typeface. www and docs use IBM Plex; this divergence is deliberate and must not be "unified". +*Mirrors assets/DESIGN.md:33-35 — change it there first.* + +Dynamic Type is capped at 2x, and the cap belongs on **every** text primitive, not just +`AppText`: `ui/text` renders every button label plus HeroStats, ComponentRow, GoLiveDialog and +ProductDelete, so a cap that misses it is not app-wide. Both apply it by default. + +**The Eyebrow-Is-A-Datum Rule.** `eyebrow` labels a **value inside a compact tag**. It is not a +decorative kicker above a heading — a heading under an eyebrow makes the eyebrow chrome, which +is why www and docs carry none at all. This is the Data-Label Rule wearing different clothes: +an eyebrow names a datum, exactly as manila colours one. *Mirrors assets/DESIGN.md:175-179 — +change it there first.* + +**The Ramp Rule.** Every text size comes from the eight variants above. An inline `fontSize:` +is a defect unless it carries a comment naming the reason no ramp step fits. + +## Layout + +`PageContainer` is the scaffold: a max-width column with gutters that widen at `md` (768) and +`lg` (1024), plus `fullBleed` and `phoneFullBleed` escape hatches for galleries and hero media. + +Breakpoints are **web-only by design** — `useBreakpoint()` hard-gates `isMd`/`isLg` on +`Platform.OS === 'web'`, so a native tablet deliberately reads as phone-tier. The adaptation axis +is viewport width on web, never operating system. + +The chrome swap at `lg` is the one structural change: the persistent `TopNav` app bar appears +and the stack header hides, never both. Below `lg`, stack headers plus the bottom tab bar. +Detail screens follow the same line — a horizontal chip row on phone, a 200px outline column at +`lg` — rendering the *same* nav item component in both, so only the container changes. + +Spacing rhythm is 4/8/16 with an 8px minimum gap. + +## Elevation & Depth + +**Flat base, one floating tier.** Inline surfaces — cards, rows, inputs, chips — are flat: a 1px +hairline border plus a `card` fill, **no shadow**. Depth comes from the hairline and the fill +step, not from light. Shadow is reserved for surfaces that genuinely float above the page. + +### Shadow Vocabulary + +- **`shadow-overlay`** (light `0 8px 24px rgba(20,40,80,.16)`, dark `0 8px 24px rgba(0,0,0,.55)`): + Menus, dialogs, bottom sheets, the FAB, toasts. This is the only shadow in the system. + +`src/theme/tokens.ts` reads this from `designTokens.rn.shadowOverlay[scheme]`. React Native +cannot consume the CSS string the generator emits for web, so the generator emits an RN-shaped +variant alongside it and the app consumes that — never re-declare the values here. The Android +`elevation` is a scheme pair (8 light / 12 dark): a dark ground needs more lift than a light one +for the same perceived depth, matching the shadow-opacity split. + +### Named Rules + +**The Inverse-Pair Rule.** `inverseSurface` may only ever carry `inverseOnSurface` (primary +text) and `tokens.text.inverseMuted` (secondary). Both inks assume an inverted backdrop, so +pairing either with a same-polarity surface inverts the contrast and the text disappears — the +failure is total, not marginal, and it is invisible to a reader of the source because each token +name looks reasonable on its own. + +The rule is enforced by construction: take the ground and both inks together from +`useInverseSurface()` (`src/theme/inverseSurface.ts`) rather than reading the tokens separately. +Tooltips, toasts and the live-stream banner all use it. Measured on the current palette, the pair +gives 10.5:1 dark / 11.8:1 light for primary ink and 5.4:1 / 5.8:1 for muted. + +**The One Tier Rule.** There is exactly one shadow. Inline surfaces get a hairline and no +shadow; floating surfaces get `shadow-overlay`. A second elevation tier, a coloured glow, or a +shadow stacked on an already-floating element is a defect, not an emphasis technique. *Mirrors +assets/DESIGN.md:121-129 — change it there first.* + +## Shapes + +Flat and sharp — the geometry of an engineering document, deliberately replacing the MD3/Paper +era of pill buttons, ambient shadows, and oversized radii the app was born in. + +| Token | Value | Use | +| ---------------- | ------ | ------------------------------------------ | +| `radius.control` | 6px | Buttons, inputs, chips, segmented controls | +| `radius.card` | 8px | Cards, panels, list rows | +| `radius.overlay` | 12px | Dialogs, bottom sheets, menus, FAB | +| `radius.full` | 9999px | Avatars and **true pills only** | + +All four map through `src/constants.ts:41`. Use the token, never a literal. + +### Named Rules + +**The True-Pill Rule.** `radius.full` is for avatars and genuine pills only. A square icon +button is not a pill — a 44×44 control with `rounded-full` is a circle pretending to be one, and +it breaks the sharp geometry everything else maintains. *Mirrors assets/DESIGN.md:113-116 — +change it there first.* + +## Components + +### Buttons + +- **Shape:** Gently squared (6px, `radius.control`), 44px minimum height. +- **Primary:** Solid Prussian blue, white text. Pressed: `active:bg-primary/90`. +- **Outline / Ghost / Tonal:** Blue ink, transparent or 12% tinted fill, hairline border on + outline. +- **Destructive:** Solid `#BA1A1A`, white text. Never the keyboard default in a dialog. +- **Loading:** Inline spinner tinted to the variant's foreground; the label stays. + +### Chips + +- **Style:** Two segments — a title segment in blue on `card`, and a value segment on solid + primary with white ink. 6px radius, 44px minimum height. +- **Error:** Danger-tinted fill **plus** a border **plus** an alert icon **plus** ", required" + composed into the accessible name. Never colour alone. + +### Cards / Containers + +- **Corner:** 8px (`radius.card`). +- **Background:** `card`. **Border:** 1px hairline. **Shadow:** none, ever. + +### Inputs / Fields + +- **Style:** Borderless by default on a `card` fill; bordered variant available. 6px radius, + 44px minimum height. +- **Error:** Message rendered by `FormField` with a `nativeID` linked via + `accessibilityLabelledBy`, so the error is programmatically associated, not merely adjacent. + +### Navigation + +- **Below `lg`:** stack header plus custom bottom tab bar. +- **At `lg` (web):** persistent `TopNav`, stack header hidden. +- **Items:** `min-h-11`, active `bg-primary/12` with blue ink, inactive at 70% opacity, web + `focus-visible:ring-2`. + +### Focus indicators + +Every interactive control takes `WEB_FOCUS_RING` from `src/constants.ts`: +`focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-ring` — a 2px Prussian-blue outline, 2px clear of the control. + +### Named Rules + +**The Painted-Focus Rule.** A focus indicator is only real if it *paints*, and in this codebase +a plausible-looking one may not. Every control here also carries a base-layer reset — +`shadow-none` for the flat form language, `outline-none` for the resting state — and those resets +silently disarm the usual mechanisms: a Tailwind ring compiles to a box-shadow layer that +`shadow-none` flattens, and `outline-2` compiles to `outline-style: var(--tw-outline-style)`, +which `outline-none` sets to `none` unconditionally. In both cases the width and colour compute +correctly and nothing appears. + +So the indicator is an explicit `outline` with an explicit `outline-solid`, and: **never assert a +focus indicator by its utility class.** Assert the computed result — `outlineStyle !== 'none'` +while `:focus-visible` matches. `app/e2e/accessibility.spec.ts` does this; keep it. Never add a +focus style to a control without measuring it in a browser. + +### Status Pill + +24px tall, `radius.control`, `label` type. Solid or soft variant. The **live** pill is the one +sanctioned manila fill in the entire app — and it is a small pill, which is the entire +justification. + +### Signature: the Spec Row + +Monospace value, manila eyebrow label, hairline separator. This is the app's most characteristic +pattern and where the whole system is legible at once: data in mono, its label in manila, and +nothing else competing. + +## Do's and Don'ts + +### Do: + +- **Do** use `tokens.surface.accent` (or `bg-primary/12`) for every selected or tinted fill. +- **Do** use the `data` variant for every measurement, ID, count, and code — tabular figures + make columns of numbers comparable. +- **Do** pair every colour-carried meaning with a second signal: an icon, a border, or text. +- **Do** apply `MIN_TAP_TARGET` (44) to every interactive control, including icon-only ones. +- **Do** pass `ReduceMotion.System` on every Reanimated animation. Currently 19 of 19 sites do; + keep it perfect. +- **Do** keep entrance motion in the 150–300ms band, with exits deliberately shorter. +- **Do** comment any deliberate departure from a token, naming what it departs from. + +### Don't: + +- **Don't** paint manila on anything large — no bars, no big figures, no glows, no banners. It + labels data; it does not decorate. +- **Don't** use manila or any accent for hover, pressed, focus, or selection. Interaction is + blue. +- **Don't** add a second shadow tier, a coloured glow, or a shadow on an inline surface. +- **Don't** use `rounded-full` on anything that isn't an avatar or a true pill. +- **Don't** introduce an inline `fontSize:` without a comment explaining why no ramp step fits. +- **Don't** use MD3 `*Container` roles (`errorContainer`, `primaryContainer`, …) or the tonal + `elevation.level*` surfaces. They are Paper-era residue; the flat/sharp system replaces them. +- **Don't** pair `inverseOnSurface` or `inverseMuted` with anything except `inverseSurface` — + take all three from `useInverseSurface()`. A same-polarity ground makes the text vanish. +- **Don't** render an empty or unconfirmed field as an error, a warning, a red state, or a + completeness penalty. Uncertainty is first-class research data — "likely polypropylene, + unconfirmed" is a good observation. **Never add a completeness meter or a progress-to-100% + bar**: it would reward false precision and damage the dataset. +- **Don't** reintroduce react-native-paper, or add a new icon family alongside + `lucide-react-native`. diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..5af590279 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,95 @@ +# syntax=docker/dockerfile:1 + +# Multi-target Dockerfile for the Expo frontend app +# - `dev` → Metro dev server (use with `docker compose watch`) +# - `runtime` → caddy-served expo web export (default) + +ARG WORKDIR=/opt/relab + +# --- Shared deps stage --- +FROM node:26-slim@sha256:a1d9d671994fc2d26e297ac56b4b1522a8bc7fa71c43b14cd1b1fe6c5116f7dc AS deps + +ARG WORKDIR +WORKDIR $WORKDIR + +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + PNPM_HOME=/pnpm \ + PATH=/pnpm:$PATH + +COPY package.json pnpm-lock.yaml ./ +COPY pnpm-workspace.yaml ./ +COPY app/package.json app/package.json +RUN npm install -g corepack@0.35.0 && corepack enable +RUN --mount=type=cache,target=/pnpm/store \ + pnpm install --frozen-lockfile --filter ./app... --prefer-offline + +# --- Dev stage (Metro dev server) --- +FROM deps AS dev + +ARG WORKDIR +WORKDIR $WORKDIR + +ENV CHOKIDAR_USEPOLLING=true \ + CHOKIDAR_INTERVAL=500 \ + WATCHPACK_POLLING=true + +COPY app/ app/ + +WORKDIR $WORKDIR/app + +EXPOSE 8081 + +# Keep Metro's cache across restarts; use the explicit dev-clean task when we +# actually need a cold cache. +CMD ["pnpm", "exec", "expo", "start", "--port", "8081", "--web"] + +# --- Build stage (expo web export) --- +FROM deps AS build + +ARG WORKDIR +WORKDIR $WORKDIR + +ENV CI=1 + +COPY app/app.json app/babel.config.js app/tsconfig.json app/metro.config.js app/postcss.config.mjs app/global.css app/ +COPY app/plugins/ app/plugins/ +COPY app/public/ app/public/ +COPY app/src/ app/src/ + +ARG ENVIRONMENT=prod +ARG EXPO_PUBLIC_API_URL +ARG EXPO_PUBLIC_WEBSITE_URL +ARG EXPO_PUBLIC_DOCS_URL +ENV ENVIRONMENT=${ENVIRONMENT} \ + EXPO_PUBLIC_API_URL=${EXPO_PUBLIC_API_URL} \ + EXPO_PUBLIC_WEBSITE_URL=${EXPO_PUBLIC_WEBSITE_URL} \ + EXPO_PUBLIC_DOCS_URL=${EXPO_PUBLIC_DOCS_URL} + +WORKDIR $WORKDIR/app + +RUN pnpm exec expo export -p web -c + +# --- Runtime stage (default) --- +FROM caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648 AS runtime + +ARG WORKDIR + +# Upgrade packages to get latest security patches (Caddy image is pretty minimal, so this is low-risk). +# Relab serves Caddy on high ports, so remove the inherited low-port bind capability +# to work with no-new-privileges and dropped runtime capabilities. +RUN apk upgrade --no-cache \ + && apk add --no-cache libcap \ + && setcap -r /usr/bin/caddy \ + && apk del libcap \ + && install -d -o 1001 -g 1001 /config /data + +COPY --link --from=build ${WORKDIR}/app/dist /srv +COPY --link app/Caddyfile /etc/caddy/Caddyfile + +USER 1001:1001 + +EXPOSE 8081 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:8081/health || exit 1 diff --git a/app/README.md b/app/README.md new file mode 100644 index 000000000..e046fbcb9 --- /dev/null +++ b/app/README.md @@ -0,0 +1,334 @@ +# Relab App + +The `app` subrepo contains the Expo / React Native app used for authenticated data collection. + +## Quick Start + +```bash +just install +just dev +``` + +The Expo dev server runs on . + +You will usually want the backend running as well. If the API is not on localhost, set +`EXPO_PUBLIC_API_URL` in `.env.local`. + +Docker development ports are localhost-only. If you want to open the Expo app from another phone, +tablet, or computer on your LAN, run the Expo server directly from this subrepo with `just dev` +instead of using the Docker app service. + +## Stack + +- **Runtime:** Expo SDK 57, React 19, React Native 0.86, React Native Web. +- **Routing:** [Expo Router](https://docs.expo.dev/router/introduction/) (file-based, typed routes). +- **Data fetching:** [TanStack Query](https://tanstack.com/query) against a + FastAPI backend. Types are generated from the backend's OpenAPI schema. +- **Client state:** React context + feature-local hooks/reducers. +- **Forms:** React Hook Form + Zod resolvers. +- **UI kit:** Uniwind (Tailwind v4 for React Native) + vendored react-native-reusables + primitives in src/components/base/. + Theme (colors, type scale, semantic tokens) is delivered via `AppThemeProvider`/`useAppTheme()` + (src/theme/); react-native-paper is not used — do not reintroduce it. +- **Compiler:** React Compiler enabled via `babel-plugin-react-compiler`. + +## Source Layout + +```text +src/ +├── app/ # Expo Router tree, one file per route. +├── components/ # Feature folders (auth, cameras, product, profile, base). +├── features/ # Feature hooks/logic per domain. +├── navigation/ # Shared destination definitions. +├── services/ # Backend integration: api/, media/, storage, domain stores. +├── context/ # React context providers (auth session, theme, etc.). +├── theme/ # Theme provider, tokens, generated palette. +├── types/ # Hand-written types + api.generated.ts (do not edit). +├── config.ts # App configuration. +├── constants.ts # Static values (routes, colors, env-derived constants). +├── utils/ # Framework-agnostic helpers, incl. router/ (Expo Router glue). +├── test-utils/ # Shared test fixtures, MSW handlers, render helpers. +├── hooks/ # Cross-feature custom hooks. +└── assets/ # Fonts, images, icons. +``` + +`base/` components are generic primitives (incl. vendored react-native-reusables +under `base/ui/`); other component folders are domain-scoped. Feature logic +lives in `src/features/`. Keep imports flowing inward (features may use `base`, +not the reverse). + +Where two features share cache state, the contract gets its own neutral module +rather than a mutual import: `features/product-entity/` holds the single-product +query options and invalidation that `features/cameras` writes and +`features/products` reads, so the products→cameras dependency stays one-way. + +`base/` holds two kinds of component. Most are generic primitives. A few are app +chrome — `TopNav`, `HeaderRightPill`, `StaticBackground` — which read app +context (auth session, theme mode) and are not reusable outside this app. They +live in `base/` because `_layout.tsx` composes them into the shell; the rule +they still keep is the import direction: chrome renders what it's given (e.g. +`useVisibleDestinations()` from `src/navigation/`) rather than importing from +`src/features/`. + +`src/components/base/ui/` is vendored react-native-reusables output — regenerate via the RNR CLI +rather than hand-refactoring. + +Adoption of that kit is deliberately partial. Call sites use a vendored primitive directly when its +look and behavior are what they want (`ui/input` in Searchbar and capture, `ui/badge` for read-only +tags, `ui/button`, `ui/text`). The hand-rolled `base/` components that look like counterparts — +`TextInput`, `InfoTooltip`, `Chip`, `AppDialog`, `Menu` — each carry behavior the primitive does +not (theme-driven error states, auto-dismiss and a mobile-web modal fallback, pressable two-segment +pills, RN-core Modal focus traps and anchor measuring). Each was assessed in August 2026 as a +net-addition if rewritten on the primitive. Each keeps a dated `NOTE:` at the top recording that; +re-open the question only when the primitive gains the missing behavior. + +## Routing + +`src/app/` is the Expo Router tree. Groups in parens (`(auth)`) don't affect +the URL. Layouts (`_layout.tsx`) wrap their siblings. Typed routes are on, so +links are type-checked against the file tree. + +For the full screen inventory, the sign-in and capture flows as diagrams, and +the rules a routing change has to keep, see +[App navigation flow](https://docs.cml-relab.org/architecture/app-flow/) in the +docs site. This section stays as the in-tree summary. + +The three primary destinations are tabs: `(tabs)/(products)`, +`(tabs)/(cameras)` and `(tabs)/(account)`, each a group holding its own Stack, +so every tab keeps its trail while you are on another one. BottomNav is that +navigator's `tabBar`. The products tab owns both the `/products` and +`/components` trees — a component is a product's child, and cards, breadcrumbs +and post-create redirects hop between them constantly; splitting them across +navigators would make every hop a cross-navigator `replace`, which React +Navigation resolves by swapping the whole tab navigator out and resetting every +tab. For the same reason, a link that leaves one tab for another must use +`navigate`, never `replace`. The root stack keeps only what sits outside the +tabs: the entry redirect, `(auth)`, `category-selection` and `users/[username]`. + +Detail screens are anchored-scroll documents: sections self-register with +SectionNavContext; chips (phone) / outline (lg web) navigate via +useSectionNav. + +The account screen uses the same anchored-scroll document pattern. On web at +lg and above, TopNav (src/components/base/TopNav.tsx) renders the persistent +app bar from src/navigation/destinations.ts and the stack header is hidden for +the screens it covers; phones keep the stack headers. + +Creation is capture-first: /products/new and components/new render +CaptureScreen (photos/name/type), POST immediately, then land on the detail +screen in edit mode. + +## Data Flow + +1. Runtime API helpers call the backend at `$EXPO_PUBLIC_API_URL`, appending `/v1` for application + routes. +1. `just backend/openapi` exports the canonical schema to + [src/types/openapi.json](src/types/openapi.json); `just codegen` regenerates + [src/types/api.generated.ts](src/types/api.generated.ts) from it and runs + `scripts/redact_api.mjs` to strip JWT examples before commit. +1. Request helpers live in [src/services/api](src/services/api); feature hooks + wrap them with TanStack Query, returning typed data. +1. MSW handlers in `src/test-utils/` mock the same surface in unit/integration + tests so component code is identical in prod and test. + +Client-only state (wizard progress, transient UI) lives in feature-local +hooks/reducers or React context. Server state stays in TanStack Query; don't +mirror it into client state stores. + +### Picking an image size + +Read schemas carry `thumbnail_url` (the 200px list thumbnail) *and* +`thumbnail_urls`, the API's pre-computed derivatives keyed by width — 200/800/1600, generated at +upload, minus any width at or above the original, so the map is sparse. `resolveApiMediaUrlMap` +resolves it through the same safety checks as any other media URL. + +Read schemas also carry `width_px`/`height_px` — the stored original's size after EXIF rotation, +recorded at upload from the header the processor already parses. Every derivative is a scaled copy, +so they all share that aspect ratio and each one's height follows from its width. Rows uploaded +before the columns existed are null until +`python -m scripts.maintenance.backfill_image_dimensions` measures them. + +With dimensions, the gallery pager hands expo-image a `source` **array** and lets it match the +candidate to the container at the screen's scale — which becomes a real `srcset` on web. Without +them a width-only array would leave that selection guessing, so `sourceSet` is empty and the +explicit pick below takes over. The lightbox always picks explicitly: there, zoom decides the +source, not the container. + +React Native has no `srcset`, so a view picks once from its own known layout: +`pickThumbnailUrl(sources, layoutPt * PixelRatio.get())` takes the narrowest derivative that +covers the need, or the widest there is. Where each surface lands today: + +| Surface | Layout | Source | +| ------------------------------------------------ | ----------- | ---------------------------------------------------------------------- | +| `ComponentRow`, gallery filmstrip, `ProductCard` | 44–80pt | `thumbnail_url` (200px) — already right at 3x | +| Gallery pager | full width | picked in `useProductGalleryMedia`, ~1600px on a modern phone | +| Lightbox | full screen | picked the same way, swapping to the original past `ORIGINAL_AT_SCALE` | + +`useProductGalleryMedia` is the single place the screen size is known, so it narrows `mediumUrl` +and `largeUrl` there and every consumer downstream — pager, lightbox, prefetch — keeps reading the +same two fields. Both tiers previously pointed at the raw upload, which is never downscaled on the +way in, so opening a gallery pulled several megabytes per image and prefetched that for every +image in the product. + +## Regenerating API Types + +The TypeScript API types are autogenerated from the backend OpenAPI schema and written to +`src/types/api.generated.ts`. + +That generated OpenAPI output is the only supported frontend contract for the +RPi camera integration. The private backend\<->plugin seam lives in the +published `relab-rpi-cam-models` Python package and should not be imported or +re-declared directly in frontend code. + +Codegen reads the committed `src/types/openapi.json` (exported by the backend +via `just backend/openapi`), so no running backend is required: + +```bash +# regenerate types from the committed schema, redact JWT examples, and format +just codegen +``` + +## Common Commands + +```bash +just check # lint +just test # full Jest suite (unit + integration) +just test-unit # fast Jest unit tests +just test-integration # slower Jest integration tests +just test-e2e # Playwright browser E2E +just test-ci # CI-style Jest run with coverage +just format # format code +just build-web # export web build for E2E +pnpm run lint:react # strict React hooks/compiler + Fast Refresh ESLint pass +pnpm run profile:compiler:web # local web profiling with production/staging compiler transforms enabled +``` + +## Test Layers + +| Layer | Tool | Location | What it covers | +| ----------- | ---------------- | --------------------------------- | ----------------------------------------------------------------- | +| Unit | Jest + jest-expo | `src/**/*.test.ts(x)` | Pure logic, single component, MSW-mocked. | +| Integration | Jest + jest-expo | `src/**/*.integration.test.ts(x)` | Multiple components wired together, realistic nav. | +| E2E | Playwright | `e2e/` | Full-stack against the built web export + docker-compose backend. | + +`just test` runs both Jest lanes. E2E requires `just build-web` and the `compose.e2e.yaml` stack. + +Jest integration tests run in-memory with `jest-expo` and React Native Testing Library. They are +broader than unit tests, but they are not a substitute for device-native end-to-end coverage. + +**Location rule:** co-locate every Jest test in a `__tests__/` folder beside the +code it covers; the Jest lane is chosen by the filename **suffix**, not the +folder (`.integration.test.*` → integration lane, everything else → unit). The +unit lane auto-mocks `expo-router` (see `config/setup.unit.ts`); the integration +lane doesn't, so integration tests mock it locally. Root-level/cross-cutting +tests with no single home (config, security policy, theme regressions) live in +`src/__tests__/`. + +**E2E environmental failures (not code bugs):** `just test-e2e` needs the +`compose.e2e.yaml` stack running against a clean database. A few known-failing +specs are environmental, not regressions — do **not** edit specs to accommodate +them: + +- **"Mine" empty-state / seeded-product-not-on-page-1** — the e2e Postgres has + accumulated test data. Fix by resetting/reseeding the e2e DB, not the spec. +- **OAuth sign-in specs** — `compose.e2e.yaml` doesn't inject Google/GitHub OAuth + credentials, so those flows can't complete in CI-local runs. + +If a spec fails, first confirm the stack is up and the DB is freshly seeded +before treating it as a code regression. + +## Timing Jest Suites + +To profile slow test files, run Jest with JSON output and inspect the report: + +```bash +pnpm test -- --runInBand --json --outputFile=.jest-timings.json +``` + +## Styling And Theming + +Styling in `app` is built on Uniwind, with colors, type scale, and semantic tokens delivered +through `AppThemeProvider` (see [Stack](#stack)). Uniwind reads `global.css` through its Metro +plugin; theme variables live in `src/theme/brand.generated.css` as `@variant light`/`@variant dark` +blocks, and `Uniwind.setTheme()` in `src/app/_layout.tsx` is what switches between them. + +- Import theme values from `@/theme`, not from `src/assets/themes/*` +- Use `useAppTheme()` as the default hook for theme access +- Prefer semantic tokens like `theme.tokens.status.live`, `theme.tokens.text.muted`, and + `theme.tokens.surface.accent` over raw hex or `rgba(...)` literals +- Keep static layout in `StyleSheet.create()` +- For theme-dependent styles, use small colocated factories like `createStyles(theme)` +- Keep `src/app/` route-only; router helpers belong under `src/utils/router/` + +In practice, that means: + +- `src/theme/` is the only supported theme entrypoint +- shared visual primitives should live under `src/components/base/` +- new hard-coded color literals in app code should be treated as regressions unless they belong in + the theme layer, generated assets, or tests + +## React Performance Profiling + +Memoization changes in this app should be validated in a release-like build, not only in Metro dev +mode. + +1. Run `pnpm run profile:compiler:web` to start Expo web with `ENVIRONMENT=production`, which keeps + the React Compiler enabled locally without changing the normal `dev` workflow. +1. Open the app in a browser with React DevTools installed and record the interaction in the + Profiler tab. +1. Confirm the slow interaction in the profiler before changing memoization. +1. Re-profile after the change and keep manual `useMemo` / `useCallback` / `React.memo` only where + the compiled build still benefits. + +The default `pnpm run dev` flow keeps compiler transforms off for faster Metro feedback while you +iterate. + +## Build And Deploy + +- **Dev (web):** `just dev`: Expo Metro on :8081. +- **Dev (native):** `pnpm android` / `pnpm ios`. +- **Web build:** `just build-web` runs `expo export -p web -c` → `dist/`. +- **Runtime:** Caddy serves `dist/` with CSP templated from + `CADDY_API_ORIGIN`. The enforced policy keeps temporary Expo web allowances for + inline/eval script execution, permits product embeds only from + `https://www.youtube-nocookie.com`, and sends a stricter report-only policy as + the hardening target. See [Dockerfile](Dockerfile) and + [Caddyfile](Caddyfile). +- **Native releases:** not containerised; use Expo's native build flow from a + developer machine. + +## Lint Ownership + +Biome is the primary formatter/linter for this app, including the React rules it supports: + +- hook ordering and dependency linting +- React prop assignment safety +- general React module/export safety where configured + +ESLint is intentionally narrow: it covers the React hooks and compiler checks that Biome does not +handle, Fast Refresh export-only structure via `eslint-plugin-react-refresh`, and a small set of +React Native accessibility rules via `eslint-plugin-react-native-a11y`. This overlap is deliberate; +when Biome ships equivalents for these rules, remove ESLint and its plugins. + +The React ESLint pass is blocking, not advisory. `pnpm run lint:react` must pass with zero warnings. + +Key ESLint coverage includes: + +- `react-hooks/preserve-manual-memoization` +- `react-hooks/static-components` +- `react-hooks/config` +- `react-hooks/gating` +- `react-hooks/unsupported-syntax` +- `react-hooks/globals` +- `react-hooks/error-boundaries` +- `react-hooks/set-state-in-render` +- `react-refresh/only-export-components` +- `react-native-a11y/*` accessibility prop checks (see `eslint.config.mjs` for the enabled rules) + +Beyond those accessibility rules, React Native-specific linting is intentionally minimal: Biome does +not expose an RN-specific rule surface. + +## More + +For emulator and device setup, testing patterns, and app-specific development notes, see +[CONTRIBUTING.md](../.github/CONTRIBUTING.md#frontend-development). diff --git a/app/app.json b/app/app.json new file mode 100644 index 000000000..ce0f67a0b --- /dev/null +++ b/app/app.json @@ -0,0 +1,84 @@ +{ + "expo": { + "name": "relab-app", + "slug": "relab-app", + "version": "0.2.0", + "orientation": "portrait", + "icon": "./src/assets/images/favicon.png", + "scheme": "relab-app", + "userInterfaceStyle": "automatic", + "ios": { + "supportsTablet": true, + "config": { + "usesNonExemptEncryption": false + }, + "infoPlist": { + "NSAppTransportSecurity": { + "NSAllowsLocalNetworking": true + }, + "NSLocalNetworkUsageDescription": "Relab connects directly to your Raspberry Pi camera on this network." + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./src/assets/images/favicon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.cml.relabFrontendApp", + "allowBackup": false + }, + "web": { + "bundler": "metro", + "output": "single", + "favicon": "./src/assets/images/favicon.png" + }, + "plugins": [ + [ + "expo-router", + { + "asyncRoutes": { + "web": true, + "default": "development" + } + } + ], + [ + "expo-splash-screen", + { + "image": "./src/assets/images/logo.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "image": "./src/assets/images/logo-dark.png", + "backgroundColor": "#0a0f1a" + } + } + ], + "expo-web-browser", + [ + "expo-secure-store", + { + "configureAndroidBackup": true + } + ], + [ + "expo-image-picker", + { + "cameraPermission": "Allow Relab to use the camera for product photos.", + "photosPermission": "Allow Relab to select product photos from your library.", + "microphonePermission": false + } + ], + "expo-image", + "expo-video", + "expo-font", + "expo-asset", + "expo-status-bar", + "./plugins/withLocalNetworkSecurity" + ], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/app/babel.config.js b/app/babel.config.js new file mode 100644 index 000000000..82423fed8 --- /dev/null +++ b/app/babel.config.js @@ -0,0 +1,17 @@ +module.exports = (api) => { + // Respect explicit Babel/NODE envs, then ENVIRONMENT (used by Docker builds), + // otherwise default to development. + const env = + process.env.BABEL_ENV ?? process.env.NODE_ENV ?? process.env.ENVIRONMENT ?? 'development'; + api.cache.using(() => env); + + // Treat staging as production-like for build optimizations. + const isProduction = env === 'production' || env === 'prod' || env === 'staging'; + + return { + presets: ['babel-preset-expo'], + // React Compiler is useful, but running it during every dev transform slows + // Metro feedback noticeably on this app. Keep it for production bundles. + plugins: isProduction ? [['babel-plugin-react-compiler', { target: '19' }]] : [], + }; +}; diff --git a/app/biome.jsonc b/app/biome.jsonc new file mode 100644 index 000000000..dc0fc994a --- /dev/null +++ b/app/biome.jsonc @@ -0,0 +1,130 @@ +{ + "$schema": "../node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["../biome.base.jsonc"], + "files": { + "includes": [ + "**", + "!.expo", + "!coverage", + "!dist", + "!!node_modules", + + // Generated fixtures are intentionally machine-managed. + "!src/assets/data/*.json", + + // Canonical OpenAPI schema is emitted by the backend exporter (Python + // json.dumps); keep biome from reformatting it so `openapi-check` stays green. + "!src/types/openapi.json", + + // Generated palette artifacts are emitted by scripts/sync_brand_assets.py; + // keep biome from reformatting them so `--check` stays byte-stable. + "!src/theme/brand.generated.css", + "!src/theme/palette.generated.ts", + "!src/theme/tokens.generated.ts" + ] + }, + "linter": { + "rules": { + "complexity": { + "noExcessiveLinesPerFunction": { + "level": "warn", + "options": { + "maxLines": 100 + } + } + }, + "correctness": { + "noReactPropAssignments": "error", + "useExhaustiveDependencies": "error", + "useHookAtTopLevel": "error", + "useImportExtensions": "off" + }, + "style": { + "useComponentExportOnlyModules": "warn" + }, + "suspicious": { + "noSkippedTests": "error" + } + } + }, + "overrides": [ + { + // Tooling config files run in Node and occasionally need interop imports. + "includes": ["eslint.config.mjs"], + "linter": { + "rules": { + "performance": { + "noNamespaceImport": "off" + } + } + } + }, + { + // Test files routinely contain dummy secrets and looser fixture setup. + "includes": [ + "babel.config.*", + "config/**", + "e2e/**", + "jest.config.*", + "jest.setup.*", + "**/__tests__/**", + "**/*.test.ts", + "**/*.test.tsx" + ], + "linter": { + "rules": { + "complexity": { + "noExcessiveLinesPerFunction": "off" + }, + "security": { + "noSecrets": "off" + } + } + } + }, + { + // Style modules are flat StyleSheet token maps: declarative data, not + // control flow. The line-count heuristic misfires on them. + "includes": ["**/styles.ts", "**/styles.tsx"], + "linter": { + "rules": { + "complexity": { + "noExcessiveLinesPerFunction": "off" + } + } + } + }, + { + // NativeWind's global.css uses Tailwind CSS v4 at-rules (@source, @theme) + // that Biome's CSS parser rejects unless explicitly enabled. + "includes": ["global.css"], + "css": { + "parser": { + "tailwindDirectives": true + } + } + }, + { + // Vendored react-native-reusables (shadcn-style) primitives — upstream code we + // don't hand-refactor. Namespace imports, mixed component/variant-constant + // exports, and decorative web-only fragments are inherent to the template. + "includes": ["src/components/base/ui/**"], + "linter": { + "rules": { + "complexity": { + "noUselessFragments": "off" + }, + "performance": { + "noNamespaceImport": "off" + }, + "security": { + "noSecrets": "off" + }, + "style": { + "useComponentExportOnlyModules": "off" + } + } + } + } + ] +} diff --git a/app/components.json b/app/components.json new file mode 100644 index 000000000..c31fbf094 --- /dev/null +++ b/app/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "global.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/components/base", + "utils": "@/utils/cn", + "ui": "@/components/base/ui", + "lib": "@/utils", + "hooks": "@/hooks" + } +} diff --git a/app/config/cssMock.js b/app/config/cssMock.js new file mode 100644 index 000000000..80b6a42aa --- /dev/null +++ b/app/config/cssMock.js @@ -0,0 +1,4 @@ +// Jest has no CSS loader; Uniwind applies global.css via the +// Metro/Babel transform at build time, not in the Jest environment, so a plain +// import can be mapped to nothing here. +module.exports = {}; diff --git a/app/config/setup.node.ts b/app/config/setup.node.ts new file mode 100644 index 000000000..24ccb3cdb --- /dev/null +++ b/app/config/setup.node.ts @@ -0,0 +1,22 @@ +// Runs before the test framework and before any setup module is required. +// +// MSW's cookie store reads `globalThis.localStorage` at import time. Node's +// built-in web storage is flag-gated (`--localstorage-file`), so merely touching +// the getter prints an ExperimentalWarning in every worker. An in-memory stub +// shadows it — and gives MSW a real place to keep cookies. +const store = new Map(); + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: { + get length() { + return store.size; + }, + key: (index: number) => [...store.keys()][index] ?? null, + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, String(value)), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), + }, +}); diff --git a/app/config/setup.shared.ts b/app/config/setup.shared.ts new file mode 100644 index 000000000..26c8c0dfe --- /dev/null +++ b/app/config/setup.shared.ts @@ -0,0 +1,332 @@ +import { afterAll, afterEach, beforeEach, jest } from '@jest/globals'; +import { cleanup } from '@testing-library/react-native'; +import type React from 'react'; +import { server } from '@/test-utils/server'; + +process.env.EXPO_PUBLIC_API_URL = 'http://127.0.0.1:18010'; + +if (typeof window !== 'undefined' && typeof window.dispatchEvent !== 'function') { + Object.defineProperty(window, 'dispatchEvent', { + configurable: true, + writable: true, + value: jest.fn(() => true), + }); +} + +if (typeof window !== 'undefined' && typeof window.history?.replaceState !== 'function') { + Object.defineProperty(window, 'history', { + configurable: true, + writable: true, + value: { + replaceState: jest.fn(), + }, + }); +} + +// ── MSW server lifecycle ─────────────────────────────────────────────────── +// Open a fresh interceptor layer per test so worker processes don't keep +// long-lived network hooks around until suite teardown. +beforeEach(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + server.resetHandlers(); + server.close(); + cleanup(); + jest.clearAllTimers(); +}); +afterAll(() => { + cleanup(); + jest.clearAllTimers(); + jest.useRealTimers(); + server.close(); +}); + +// Mock expo-secure-store (replaces AsyncStorage for token persistence on native) +jest.mock('expo-secure-store', () => ({ + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), +})); + +// Mock AsyncStorage so tests never touch the native module implementation. +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock'), +); + +// Mock expo-linear-gradient +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: 'LinearGradient', +})); + +// The library's own Jest mock — its native module isn't linked under Jest, so +// any screen using AuthScreen's KeyboardAvoidingView would fail to load. +jest.mock('react-native-keyboard-controller', () => + require('react-native-keyboard-controller/jest'), +); + +// Mock Expo Auth Session Google hook to avoid browser-session side effects in Jest. +jest.mock('expo-auth-session/providers/google', () => ({ + useAuthRequest: jest.fn(() => [null, null, jest.fn()]), +})); + +function mockCreateAnimatedStyleHook() { + return (fn: () => Record) => { + try { + return fn(); + } catch { + return {}; + } + }; +} + +function mockCreateAnimatedPropsHook() { + return (fn: () => Record) => { + try { + return fn(); + } catch { + return {}; + } + }; +} + +function mockCreateSharedValue(initialValue: unknown, noopFn: jest.Mock) { + let currentValue = initialValue; + return { + get value() { + return currentValue; + }, + set value(nextValue: unknown) { + currentValue = nextValue; + }, + get: () => currentValue, + set: (nextValue: unknown) => { + currentValue = nextValue; + }, + modify: noopFn, + }; +} + +function mockCreateAnimatedComponent() { + const React = require('react'); + const { View, Text, Image, ScrollView, FlatList } = require('react-native'); + const AnimatedComponent = ({ + children, + style, + ...props + }: { + children?: React.ReactNode; + style?: Record; + [key: string]: unknown; + }) => React.createElement(View, { style, ...props }, children); + + Object.assign(AnimatedComponent, { + View, + Text, + Image, + ScrollView, + FlatList, + createAnimatedComponent: (c: React.ComponentType) => c, + }); + + return AnimatedComponent; +} + +// Entering/exiting animation builders (FadeIn.duration(150) and friends): the +// real ones are worklet-backed classes, so the mock only has to be chainable +// and land on something a component can hold as a prop. +function mockCreateAnimationBuilder() { + const builder: Record unknown> = {}; + for (const method of [ + 'duration', + 'delay', + 'springify', + 'easing', + 'withInitialValues', + 'reduceMotion', + 'build', + ]) { + builder[method] = () => builder; + } + return builder; +} + +// Mock react-native-reanimated +// react-native-reanimated 4.x uses react-native-worklets which requires native +// initialisation; unusable in Jest. We provide a minimal inline mock instead. +jest.mock('react-native-reanimated', () => { + const noopFn = jest.fn(); + const AnimatedComponent = mockCreateAnimatedComponent(); + + return { + FadeIn: mockCreateAnimationBuilder(), + FadeOut: mockCreateAnimationBuilder(), + FadeInUp: mockCreateAnimationBuilder(), + FadeInDown: mockCreateAnimationBuilder(), + FadeOutUp: mockCreateAnimationBuilder(), + ZoomIn: mockCreateAnimationBuilder(), + SlideInDown: mockCreateAnimationBuilder(), + LinearTransition: mockCreateAnimationBuilder(), + __esModule: true, + default: AnimatedComponent, + useAnimatedStyle: mockCreateAnimatedStyleHook(), + useAnimatedProps: mockCreateAnimatedPropsHook(), + useSharedValue: (initialValue: unknown) => mockCreateSharedValue(initialValue, noopFn), + useAnimatedSensor: () => ({ + sensor: { value: { pitch: 0, roll: 0, yaw: 0 } }, + unregister: noopFn, + }), + useDerivedValue: (fn: () => unknown) => ({ + value: (() => { + try { + return fn(); + } catch { + return; + } + })(), + }), + useAnimatedRef: () => ({ current: null }), + useAnimatedScrollHandler: () => () => {}, + withSpring: (value: number) => value, + withTiming: (value: number) => value, + withDelay: (_: number, value: number) => value, + withRepeat: (value: number) => value, + withSequence: (...values: number[]) => values[values.length - 1], + interpolate: (value: number) => value, + clamp: (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)), + Extrapolation: { CLAMP: 'clamp', EXTEND: 'extend', IDENTITY: 'identity' }, + ReduceMotion: { System: 'system', Always: 'always', Never: 'never' }, + SensorType: { + ROTATION: 'ROTATION', + GRAVITY: 'GRAVITY', + GYROSCOPE: 'GYROSCOPE', + }, + runOnJS: unknown>(fn: T): T => fn, + runOnUI: unknown>(fn: T): T => fn, + cancelAnimation: noopFn, + measure: noopFn, + Easing: { + linear: (t: number) => t, + ease: (t: number) => t, + bezier: () => (t: number) => t, + in: (fn: (t: number) => number) => fn, + out: (fn: (t: number) => number) => fn, + inOut: (fn: (t: number) => number) => fn, + }, + }; +}); + +jest.mock('react-native-worklets', () => ({ + scheduleOnRN: (fn: (...args: unknown[]) => unknown, ...args: unknown[]) => fn(...args), +})); + +// Mock expo-image (ImageBackground, Image) +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + Image: (props: { [key: string]: unknown }) => + React.createElement(View, { testID: 'expo-image', ...props }), + ImageBackground: (props: { children?: React.ReactNode; [key: string]: unknown }) => + React.createElement(View, { testID: 'expo-image-bg', ...props }, props.children), + }; +}); + +// Mock expo-video (used by LivePreview) to avoid importing native components +jest.mock('expo-video', () => { + const React = require('react'); + const noop = jest.fn(); + return { + useVideoPlayer: ( + _src: string, + init?: (instance: { muted: boolean; loop: boolean; play: () => void }) => void, + ) => { + // status + addListener: expo's useEvent subscribes to the player in an + // effect; without these, any test rendering NativeHlsVideo crashes in + // useEventListener instead of naming this mock. + const instance = { + muted: false, + loop: false, + play: noop, + replaceAsync: () => Promise.resolve(), + status: 'readyToPlay', + addListener: () => ({ remove: noop }), + }; + try { + if (typeof init === 'function') init(instance); + } catch { + /* ignore */ + } + return instance; + }, + VideoView: (props: { [key: string]: unknown }) => + React.createElement('View', { testID: 'expo-video-view', ...props }), + }; +}); + +// Mock react-native-gesture-handler +jest.mock('react-native-gesture-handler', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + GestureHandlerRootView: ({ + children, + style, + }: { + children?: React.ReactNode; + style?: Record; + }) => React.createElement(View, { style }, children), + GestureDetector: ({ children }: { children?: React.ReactNode }) => children, + Gesture: { + Tap: () => { + const tap = { + numberOfTaps: () => tap, + onEnd: () => tap, + onStart: () => tap, + }; + return tap; + }, + Pan: () => { + const pan = { + minPointers: () => pan, + onUpdate: () => pan, + onEnd: () => pan, + onStart: () => pan, + enabled: () => pan, + }; + return pan; + }, + Pinch: () => { + const pinch = { + onUpdate: () => pinch, + onEnd: () => pinch, + onStart: () => pinch, + }; + return pinch; + }, + Simultaneous: (..._args: unknown[]) => ({}), + Exclusive: (..._args: unknown[]) => ({}), + }, + }; +}); + +// Allow shadow style props through react-native's Animated allowlist: some +// libraries use the native driver for shadows, which otherwise logs warnings +// under Jest. +try { + const allowlistModuleId = [ + 'react-native', + 'Libraries', + 'Animated', + 'NativeAnimatedAllowlist', + ].join('/'); + const { allowStyleProp } = require(allowlistModuleId); + if (typeof allowStyleProp === 'function') { + ['shadowColor', 'shadowOffset'].forEach(allowStyleProp); + } +} catch { + // Path might vary by RN version; skip if not found +} + +afterEach(async () => { + const AsyncStorage = require('@react-native-async-storage/async-storage'); + await AsyncStorage.clear(); +}); diff --git a/app/config/setup.unit.ts b/app/config/setup.unit.ts new file mode 100644 index 000000000..4905a4fa9 --- /dev/null +++ b/app/config/setup.unit.ts @@ -0,0 +1,58 @@ +import { jest } from '@jest/globals'; +import type React from 'react'; + +// Keep the broad Expo Router mock in the fast unit lane. Integration tests +// can then opt into expo-router/testing-library with the real router module. +jest.mock('expo-router', () => { + const React = require('react'); + // Theme values/provider live on expo-router itself since SDK 57. Pull them from + // the lightweight `expo-router/react-navigation` compat module rather than the + // real `expo-router` entry point — that entry eagerly evaluates `ExpoRoot`, + // which reads `window.location` and crashes outside a real router tree. + const { DefaultTheme, DarkTheme, ThemeProvider } = jest.requireActual< + typeof import('expo-router/react-navigation') + >('expo-router/react-navigation'); + return { + DefaultTheme, + DarkTheme, + ThemeProvider, + useRouter: jest.fn().mockReturnValue({ + push: jest.fn(), + replace: jest.fn(), + back: jest.fn(), + setParams: jest.fn(), + }), + // Defaults to segments matching no group, so useBottomNavVisible() reads + // false unless a test opts in with its own expo-router mock (as + // BottomNav.test.tsx and ActiveStreamBanner's do). + useSegments: () => [], + useFocusEffect: jest.fn(), + useIsFocused: jest.fn().mockReturnValue(true), + useLocalSearchParams: jest.fn().mockReturnValue({}), + useGlobalSearchParams: jest.fn().mockReturnValue({}), + usePathname: jest.fn().mockReturnValue('/'), + useNavigation: jest.fn().mockReturnValue({ + setOptions: jest.fn(), + canGoBack: jest.fn().mockReturnValue(false), + goBack: jest.fn(), + }), + Link: ({ children }: { children: React.ReactNode }) => children, + Redirect: ({ href }: { href: string }) => { + const { Text } = require('react-native'); + return React.createElement(Text, null, `Redirect to ${href}`); + }, + }; +}); + +// The tabs navigator lives on its own entry point since SDK 57 (the `Tabs` +// re-export from `expo-router` is deprecated). Same shim shape as Stack's. +jest.mock('expo-router/js-tabs', () => { + const React = require('react'); + return { + Tabs: Object.assign( + ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + { Screen: () => null }, + ), + }; +}); diff --git a/app/e2e/accessibility.spec.ts b/app/e2e/accessibility.spec.ts new file mode 100644 index 000000000..4563c8958 --- /dev/null +++ b/app/e2e/accessibility.spec.ts @@ -0,0 +1,97 @@ +/** + * Accessibility E2E: runs axe against the Expo web build. + * + * Scoped to the guest-accessible core screens so it needs no login/seeding + * beyond the running full-stack (see e2e-full-stack in validate.yml). + * + * We gate on serious + critical violations only. RN-Web rendering emits + * minor/moderate axe noise (and theme-token color-contrast) that the app + * can't meaningfully fix, so gating on those would make CI red on library + * internals rather than real regressions. color-contrast is disabled for the + * same reason (mirrors docs/e2e/accessibility.spec.ts). + */ + +import AxeBuilder from '@axe-core/playwright'; +import { expect, type Page, test } from '@playwright/test'; +import { openSeededProductFromProductsPage, reachProductsPage } from './helpers'; + +// Aligned across www/docs/app: WCAG 2.0-2.2, level A + AA — the stated target. +// target-size (2.5.8) is the only 2.2-only rule axe-core ships; 2.4.11 and +// 2.4.13 have no axe coverage and are verified by hand. +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa']; +const GATED_IMPACTS = new Set(['serious', 'critical']); + +async function seriousViolations(page: Page) { + // Neutralize animations so results are deterministic (mirrors www/docs). + await page.addStyleTag({ + content: ` + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } + `, + }); + + const results = await new AxeBuilder({ page }) + .withTags(WCAG_TAGS) + .disableRules(['color-contrast']) + .analyze(); + return results.violations.filter((v) => v.impact && GATED_IMPACTS.has(v.impact)); +} + +test.describe('Accessibility', () => { + test('products list has no serious a11y violations', async ({ page }) => { + await reachProductsPage(page); + expect(await seriousViolations(page)).toEqual([]); + }); + + test('product detail has no serious a11y violations', async ({ page }) => { + await reachProductsPage(page); + await openSeededProductFromProductsPage(page); + expect(await seriousViolations(page)).toEqual([]); + }); + + /** + * WCAG 2.2 SC 2.4.7 Focus Visible (A) and 2.4.13 Focus Appearance (AA). + * + * axe has no focus-appearance rule, so nothing above catches this. Two + * successive implementations of the focus indicator shipped completely + * invisible while every class-string test stayed green: + * + * 1. `focus-visible:ring-*` compiles to a box-shadow layer, which the + * `shadow-none` in the same class string flattened away. + * 2. `focus-visible:outline-2` compiles to + * `outline-style: var(--tw-outline-style)`, and the `outline-none` in + * the same class string sets that variable to `none` unconditionally. + * + * Both times width and colour computed correctly and nothing painted. The + * only assertion that would have caught either is this one: that the + * indicator has a real, non-`none` computed style while focused. Assert the + * painted result, never the utility that is supposed to produce it. + */ + test('keyboard focus paints a visible indicator @auth', async ({ page }) => { + await page.goto('/login'); + const signIn = page.getByRole('button', { name: 'Sign in' }); + await expect(signIn).toBeVisible({ timeout: 15_000 }); + + await signIn.focus(); + + const focus = await signIn.evaluate((el) => { + const style = getComputedStyle(el); + return { + matchesFocusVisible: el.matches(':focus-visible'), + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + outlineColor: style.outlineColor, + boxShadow: style.boxShadow, + }; + }); + + expect(focus.matchesFocusVisible).toBe(true); + // The load-bearing assertion: a width and a colour are not an indicator. + expect(focus.outlineStyle).not.toBe('none'); + expect(focus.outlineWidth).not.toBe('0px'); + }); +}); diff --git a/app/e2e/account.spec.ts b/app/e2e/account.spec.ts new file mode 100644 index 000000000..cf8f41ed6 --- /dev/null +++ b/app/e2e/account.spec.ts @@ -0,0 +1,76 @@ +/** + * Account screen E2E tests: section navigation and dialog keyboard a11y. + * + * The account screen (src/components/profile/AccountScreen.tsx) reuses the + * same anchored-scroll document pattern as the product detail screen + * (SectionNavLayout + useAnchoredSectionNav) — see e2e/product-detail.spec.ts + * for the origin of the chip-click regression net this file mirrors. + */ + +import { expect, test } from '@playwright/test'; +import { loginAndGoToProfile } from './helpers'; + +test.setTimeout(60_000); + +// ─── Section nav anchors (phone) ─────────────────────────────────────────── +// Same regression net as product-detail.spec.ts: Section registers its +// onLayout y relative to its parent View, not the scroll content, so a chip +// tap can land short of the section it targets. +test.describe('Account: section navigation', () => { + test.use({ viewport: { width: 375, height: 800 } }); + + test('clicking the Security & sessions chip scrolls that section to the top of the viewport', async ({ + page, + }) => { + await loginAndGoToProfile(page); + + // Phone viewport renders chips (SectionNavLayout), not the lg outline. + await expect(page.getByTestId('section-nav-chips')).toBeVisible(); + await page.getByRole('button', { name: 'Security & sessions' }).click(); + + // The chip and the Section heading share the same text; the Section + // heading is the last match in DOM order (nav renders first). + const heading = page.getByText('Security & sessions', { exact: true }).last(); + await expect(heading).toBeVisible({ timeout: 5_000 }); + await expect + .poll(async () => (await heading.boundingBox())?.y ?? Number.POSITIVE_INFINITY, { + timeout: 5_000, + }) + .toBeLessThan(200); + }); +}); + +// ─── Dialog keyboard a11y ─────────────────────────────────────────────────── +// The edit-username dialog (src/components/profile/Dialogs.tsx) is built on +// React Native's core Modal, which react-native-web backs with its own focus +// trap and Escape → onRequestClose (no react-native-paper involved here +// anymore — see DialogProvider.tsx for the rationale). Verify both against +// the account screen's edit-username dialog. +test.describe('Account: dialog keyboard a11y', () => { + test('focus stays trapped inside the edit-username dialog and Escape closes it', async ({ + page, + }) => { + await loginAndGoToProfile(page); + + const hiText = page.getByText('Hi,'); + await expect(hiText).toBeVisible(); + await hiText.locator('xpath=following-sibling::*[1]').click(); + await expect(page.getByText('Edit username')).toBeVisible({ timeout: 3_000 }); + + // react-native-web's core Modal marks its root role="dialog" while active. + // Tab well past the dialog's three focusables (username input, Cancel, + // Save) — every stop must stay inside the dialog's DOM subtree. + const dialogRoot = page.getByRole('dialog'); + for (let i = 0; i < 6; i++) { + // biome-ignore lint/performance/noAwaitInLoops: sequential — each Tab must land before the next is pressed. + await page.keyboard.press('Tab'); + const insideDialog = await dialogRoot.evaluate((root) => + Boolean(document.activeElement && root.contains(document.activeElement)), + ); + expect(insideDialog).toBe(true); + } + + await page.keyboard.press('Escape'); + await expect(page.getByText('Edit username')).not.toBeVisible({ timeout: 3_000 }); + }); +}); diff --git a/app/e2e/auth.spec.ts b/app/e2e/auth.spec.ts new file mode 100644 index 000000000..884257819 --- /dev/null +++ b/app/e2e/auth.spec.ts @@ -0,0 +1,158 @@ +/** + * Full-stack auth E2E tests. + * + * Prerequisites: + * - compose.e2e.yaml services are running + * - the Expo web app has been built for E2E + * + * Test user credentials come from backend/.env.test: + * BOOTSTRAP_SUPERUSER_EMAIL=e2e-admin@example.com + * BOOTSTRAP_SUPERUSER_PASSWORD=E2eTestPass123! + */ + +import { expect, test } from '@playwright/test'; +import { + dismissTermsPrompt, + EMAIL, + finishOnboardingIfVisible, + PASSWORD, + suppressGuestWelcomeCard, +} from './helpers'; + +const PRODUCTS_URL_PATTERN = /products/; +const ONBOARDING_OR_PRODUCTS_URL_PATTERN = /onboarding|products/; +const LOGIN_URL_PATTERN = /login/; +const NEW_ACCOUNT_URL_PATTERN = /new-account/; +const FORGOT_PASSWORD_URL_PATTERN = /forgot-password/; +const FORGOT_PASSWORD_SUCCESS_PATTERN = /If an account exists with this email/; + +test.describe('Authentication flow', () => { + test('unauthenticated user can browse the products page without signing in', async ({ page }) => { + // The guest welcome card carries its own "Sign in" button, so suppressing it + // leaves the header pill as the only match for the assertion below. + await suppressGuestWelcomeCard(page); + await page.goto('/'); + // Root redirects to /products; publicly accessible without login + await expect(page).toHaveURL(PRODUCTS_URL_PATTERN, { timeout: 5_000 }); + // Header shows Sign in pill for guests + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); + }); + + test('login page shows expected fields and navigation links', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByLabel('Email or username')).toBeVisible(); + await expect(page.getByLabel('Password', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Forgot password?' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Create a new account' })).toBeVisible(); + }); + + test('login with wrong password shows an error', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Email or username').fill(EMAIL); + await page.getByLabel('Password', { exact: true }).fill('wrong-password'); + await page.getByRole('button', { name: 'Sign in' }).click(); + // The app shows a "Couldn't sign in" dialog on bad credentials + await expect(page.getByText("Couldn't sign in")).toBeVisible({ + timeout: 10_000, + }); + }); + + test('login with correct credentials succeeds and leaves the login screen', { + tag: ['@cross-browser', '@auth'], + }, async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Email or username').fill(EMAIL); + await page.getByLabel('Password', { exact: true }).fill(PASSWORD); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL(ONBOARDING_OR_PRODUCTS_URL_PATTERN, { timeout: 30_000 }); + }); + + test('full new-user flow: login → onboarding → products', { + tag: ['@cross-browser', '@auth'], + }, async ({ page }) => { + await page.goto('/login'); + + // ── Login ─────────────────────────────────────────────────────────────── + await page.getByLabel('Email or username').fill(EMAIL); + await page.getByLabel('Password', { exact: true }).fill(PASSWORD); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page).toHaveURL(ONBOARDING_OR_PRODUCTS_URL_PATTERN, { timeout: 30_000 }); + // The contributor-terms prompt is modal and arrives first, so the onboarding + // wizard below cannot be clicked through while it is up. + await dismissTermsPrompt(page); + await finishOnboardingIfVisible(page); + + // ── Verify products screen loaded ──────────────────────────────────────── + await expect(page.getByPlaceholder('Search products')).toBeVisible({ + timeout: 10_000, + }); + }); +}); + +test.describe('Account registration', () => { + test('registration page is accessible from the login screen', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('button', { name: 'Create a new account' }).click(); + await expect(page).toHaveURL(NEW_ACCOUNT_URL_PATTERN, { timeout: 5_000 }); + await expect(page.getByLabel('Username', { exact: true })).toBeVisible(); + }); + + test('full registration flow: username → email → password → verify prompt', { + tag: '@cross-browser', + }, async ({ page }) => { + // Use a timestamp-based unique identity to avoid collisions across runs + const unique = Date.now(); + const username = `e2e${unique}`; + const email = `e2e-${unique}@example.com`; + const password = 'correct-horse-battery-staple-v42'; + + await page.goto('/new-account'); + + // Step 1: choose a username + await page.getByLabel('Username', { exact: true }).fill(username); + await page.getByTestId('username-next').click(); + + // Step 2: enter an email address + await expect(page.getByLabel('Email address')).toBeVisible({ + timeout: 3_000, + }); + await page.getByLabel('Email address').fill(email); + await page.getByTestId('email-next').click(); + + // Step 3: choose a password + await expect(page.getByLabel('Password', { exact: true })).toBeVisible({ + timeout: 3_000, + }); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Create account' }).click(); + + // Registration is non-enumerable: no auto-login. The app shows a verify-email + // prompt and returns to /login (see useNewAccountScreen). + await expect(page.getByText('Check your email')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: 'OK' }).click(); + await expect(page).toHaveURL(LOGIN_URL_PATTERN, { timeout: 5_000 }); + }); +}); + +test.describe('Forgot password', () => { + test('forgot password page renders and accepts a valid email', async ({ page }) => { + await page.goto('/forgot-password'); + await expect(page.getByText('Forgot password')).toBeVisible(); + + // Fill in a known email and submit + // React Native Paper's label prop is visual-only and not an ARIA label + await page.getByRole('textbox').fill(EMAIL); + await page.getByRole('button', { name: 'Send reset link' }).click(); + + await expect(page.getByText(FORGOT_PASSWORD_SUCCESS_PATTERN)).toBeVisible({ + timeout: 15_000, + }); + }); + + test('forgot password page is accessible from the login screen', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('button', { name: 'Forgot password?' }).click(); + await expect(page).toHaveURL(FORGOT_PASSWORD_URL_PATTERN, { timeout: 5_000 }); + }); +}); diff --git a/app/e2e/fixtures/test-image.png b/app/e2e/fixtures/test-image.png new file mode 100644 index 000000000..f94b7085f Binary files /dev/null and b/app/e2e/fixtures/test-image.png differ diff --git a/frontend-app/e2e/gallery.spec.ts b/app/e2e/gallery.spec.ts similarity index 95% rename from frontend-app/e2e/gallery.spec.ts rename to app/e2e/gallery.spec.ts index 5fa520d40..f79c2853e 100644 --- a/frontend-app/e2e/gallery.spec.ts +++ b/app/e2e/gallery.spec.ts @@ -63,7 +63,7 @@ const galleryListProduct = { }; async function mockGalleryProductApi(page: Page) { - await page.route('http://localhost:18432/products?*', async (route) => { + await page.route('http://127.0.0.1:18010/products?*', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', @@ -77,7 +77,7 @@ async function mockGalleryProductApi(page: Page) { }); }); - await page.route('http://localhost:18432/products/1?*', async (route) => { + await page.route('http://127.0.0.1:18010/products/1?*', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', diff --git a/app/e2e/helpers.ts b/app/e2e/helpers.ts new file mode 100644 index 000000000..b47dcfc2d --- /dev/null +++ b/app/e2e/helpers.ts @@ -0,0 +1,314 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +const EMAIL = 'e2e-admin@example.com'; +const PASSWORD = 'E2eTestPass123!'; +const PRODUCTS_URL_PATTERN = /products/; +const ONBOARDING_OR_PRODUCTS_URL_PATTERN = /onboarding|products/; +const PROFILE_URL_PATTERN = /account/; +const NEW_PRODUCT_URL_PATTERN = /\/products\/new$/; +// One specific seeded product rather than "either of two": the lookup searches +// for it by name, which needs an exact term. +const SEEDED_PRODUCT_NAME = 'Dell XPS 13'; +const PRODUCT_DETAIL_URL_PATTERN = /products\/\d+/; +// The gallery trigger is labelled `View ${altText}` (ProductImageGalleryContent), +// where altText is the image's description or the product name — never the +// literal "image N" this used to match, so the lightbox tests could not find it. +const VIEW_IMAGE_LABEL_PATTERN = /^View .+/; +// ProductsWelcomeCard's dismiss affordance: "Maybe later" for guests, "Got it" +// once signed in. "Continue" covers the onboarding variant. +const FILTERS_TOGGLE_NAME_PATTERN = /^Filters/; +const WELCOME_CARD_DISMISS_PATTERN = /^(Got it|Maybe later|Continue)$/; +// The menu is an RN-core Modal (Menu.tsx) that measures its anchor position on +// open; under parallel-worker CPU load an open can occasionally land before the +// items lay out. Each attempt is an independent chance at a clean open, so a +// generous budget keeps first-pass reliability high without full-test retries. +const MENU_OPEN_ATTEMPTS = 8; + +function makeOnboardingUsername() { + return `e2e_${Date.now()}_${Math.floor(Math.random() * 1000)}`; +} + +/** + * Pre-dismiss the guest welcome card via localStorage so it never renders. + * Must be called before any goto() on this page. The key matches + * GUEST_INFO_CARD_STORAGE_KEY in useProductsWelcomeCard.ts. + */ +export async function suppressGuestWelcomeCard(page: Page) { + await page.addInitScript(() => { + try { + localStorage.setItem('products_info_card_dismissed_guest', 'true'); + } catch { + // Non-fatal: some contexts (e.g. opaque origin) forbid localStorage. + } + }); +} + +export async function dismissProductsInfoCard(page: Page) { + // Fallback dismissal for authenticated users (whose preference lives server-side, + // so the localStorage suppression above can't reach them). + // + // Keyed on the dismiss button, not the card's title: ProductsWelcomeCard shows + // three different titles (guest / verified / unverified) and matching one of + // them silently no-ops for the other two, leaving the card covering the list. + // The card also renders a beat after the search bar, so this waits rather than + // probing once. + const dismissButton = page + .getByRole('button', { name: WELCOME_CARD_DISMISS_PATTERN }) + .filter({ visible: true }) + .first(); + + if (!(await dismissButton.isVisible({ timeout: 5_000 }).catch(() => false))) return; + + await dismissButton.click(); + await expect(dismissButton).not.toBeVisible({ timeout: 5_000 }); +} + +/** + * Pre-dismiss the contributor-terms prompt via sessionStorage so it never + * renders. Must be called before any goto() on this page. The key matches + * DISMISSED_SESSION_KEY in useTermsAcceptance.ts. + * + * Suppression rather than clicking it away: the prompt is modal, and a spec that + * navigates with goto() after login would meet it again on every page load. + */ +export async function suppressTermsPrompt(page: Page) { + await page.addInitScript(() => { + try { + sessionStorage.setItem('terms_prompt_dismissed', 'true'); + } catch { + // Non-fatal: some contexts (e.g. opaque origin) forbid sessionStorage. + } + }); +} + +export async function dismissTermsPrompt(page: Page) { + // Seeded accounts are created programmatically, and programmatic creation + // deliberately records no terms acceptance — stamping one would fabricate + // evidence of a licence grant nobody made. So every authenticated run meets the + // contributor-terms prompt. + // + // It is a modal with a scrim, so leaving it up does not merely add noise: later + // clicks hit the scrim instead of their target, while `toBeVisible` assertions + // still pass because visibility is not occlusion. That combination is why this + // has to be dismissed rather than ignored. + // + // "Not now" rather than Accept: accepting mutates the seeded account for every + // other test in the run, and the prompt's own behaviour is covered in + // terms.spec.ts against an account nothing else uses. + const notNow = page.getByRole('button', { name: 'Not now' }).filter({ visible: true }).first(); + + if (!(await notNow.isVisible({ timeout: 5_000 }).catch(() => false))) return; + await notNow.click(); + await expect(notNow).toBeHidden({ timeout: 5_000 }); +} + +export async function reachProductsPage(page: Page) { + await suppressGuestWelcomeCard(page); + await page.goto('/products'); + await dismissProductsInfoCard(page); + await expect(page.getByPlaceholder('Search products')).toBeVisible({ + timeout: 15_000, + }); +} + +/** + * The sort/filter chips (Sort, Mine, Date, Brand, Type) sit behind one + * "Filters" toggle beside the search bar; it opens by itself only when the URL + * already carries a filter. Idempotent: leaves an open row alone. + */ +export async function openProductFilters(page: Page) { + const toggle = page.getByRole('button', { name: FILTERS_TOGGLE_NAME_PATTERN }); + await expect(toggle).toBeVisible({ timeout: 10_000 }); + if ((await toggle.getAttribute('aria-expanded')) !== 'true') { + await toggle.click(); + } + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); +} + +export async function finishOnboardingIfVisible(page: Page) { + if (!page.url().includes('onboarding')) { + return; + } + + const usernameInput = page.getByPlaceholder('e.g. awesome_user'); + await expect(usernameInput).toBeVisible({ timeout: 10_000 }); + await usernameInput.fill(makeOnboardingUsername()); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page).toHaveURL(PRODUCTS_URL_PATTERN, { timeout: 30_000 }); +} + +/** + * A seeded, verified, NON-superuser account (see backend dummy_data.json). + * Use it where the point is that an ordinary contributor can do something — + * `e2e-admin` is a superuser and so proves less. + */ +export const SEEDED_MEMBER = { login: 'alice@example.com', password: 'fake_password_1' }; + +export async function loginAndReachProducts( + page: Page, + credentials: { login: string; password: string } = { login: EMAIL, password: PASSWORD }, +) { + await suppressGuestWelcomeCard(page); + await suppressTermsPrompt(page); + await page.goto('/login'); + // Auth fields are addressed by their visible label, not their placeholder: + // the redesign moved the field name out of the placeholder (now an example + // value, e.g. "you@university.edu") into a label that survives typing. + await page.getByLabel('Email or username').fill(credentials.login); + await page.getByLabel('Password', { exact: true }).fill(credentials.password); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page).toHaveURL(ONBOARDING_OR_PRODUCTS_URL_PATTERN, { timeout: 30_000 }); + // Before the other two: the terms prompt is modal, so while it is up neither the + // onboarding wizard nor the welcome card can be clicked away. + await dismissTermsPrompt(page); + await finishOnboardingIfVisible(page); + await dismissProductsInfoCard(page); + await expect(page.getByPlaceholder('Search products')).toBeVisible({ + timeout: 10_000, + }); +} + +export async function loginAndGoToProfile(page: Page) { + await loginAndReachProducts(page); + await page.goto('/account'); + await expect(page).toHaveURL(PROFILE_URL_PATTERN, { timeout: 10_000 }); +} + +export async function openNewProductPage(page: Page) { + await page.getByRole('button', { name: 'New product' }).click(); + await expect(page).toHaveURL(NEW_PRODUCT_URL_PATTERN, { + timeout: 10_000, + }); + // Capture-first creation screen: a bare Name field, not the old full form. + await expect(page.getByRole('textbox', { name: 'Name' })).toBeVisible({ + timeout: 10_000, + }); +} + +/** + * Click a menu anchor and wait for items to mount. Retries on failure: under + * parallel-worker CPU load the Modal's open can briefly attach items before the + * anchor-position measurement settles — a second click re-opens and typically + * succeeds. + */ +export async function openMenu(page: Page, anchor: Locator) { + // Ensure the anchor is attached and actionable before we start dispatching clicks. + await anchor.waitFor({ state: 'visible', timeout: 10_000 }); + + for (let attempt = 0; attempt < MENU_OPEN_ATTEMPTS; attempt++) { + // Alternate click strategies: Playwright's trusted click first, then a + // synthetic DOM click via element.click(). The anchor's IconButton + // occasionally drops the first pointer event under parallel-worker CPU + // load; a direct element.click() bypasses any pointer-events quirks. + // biome-ignore lint/performance/noAwaitInLoops: sequential retry — each attempt must observe the previous one's outcome. + await (attempt % 2 === 0 + ? anchor.click({ force: true }) + : anchor.evaluate((el) => (el as HTMLElement).click())); + try { + // Poll in-browser for attached menu items. The menu Modal can briefly + // attach items before layout settles; attachment is the earliest reliable + // signal that onPress fired and the menu mounted. + // In-browser polling at 50ms is fast enough to catch the window before + // measurement tears items down; Playwright's network-hop locator polling + // is too slow and would cause us to press Escape on a menu that just opened. + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="menu-item-title"]').length > 0, + null, + { timeout: 2_500, polling: 50 }, + ); + return; + } catch { + await page.keyboard.press('Escape').catch(() => {}); + } + } + throw new Error(`Menu anchor did not open a menu after ${MENU_OPEN_ATTEMPTS} attempts`); +} + +/** + * Open a menu via its anchor and click the item with the given label. + * Combining open + select into one retried operation is required because the + * menu can dismiss itself between separate calls (a stray pointer event on the + * Modal backdrop closes it). We retry the full sequence until the item is + * clicked or we exhaust attempts. + */ +export async function selectMenuItem(page: Page, anchor: Locator, label: string) { + await anchor.waitFor({ state: 'visible', timeout: 10_000 }); + + for (let attempt = 0; attempt < MENU_OPEN_ATTEMPTS; attempt++) { + // biome-ignore lint/performance/noAwaitInLoops: sequential retry. + await (attempt % 2 === 0 + ? anchor.click({ force: true }) + : anchor.evaluate((el) => (el as HTMLElement).click())); + + try { + await page.waitForFunction( + (targetLabel) => { + const titles = Array.from( + document.querySelectorAll('[data-testid="menu-item-title"]'), + ) as HTMLElement[]; + const titleNode = titles.find((node) => node.textContent?.trim() === targetLabel); + if (!titleNode) return false; + const clickable = (titleNode.closest('[role="menuitem"]') ?? titleNode) as HTMLElement; + clickable.click(); + return true; + }, + label, + { timeout: 3_500, polling: 50 }, + ); + return; + } catch { + await page.keyboard.press('Escape').catch(() => {}); + } + } + throw new Error( + `Could not open menu and click item "${label}" after ${MENU_OPEN_ATTEMPTS} attempts`, + ); +} + +/** + * Narrow the products list to one name before picking a row. + * + * Nothing clears the database between tests in a run — only teardown does + * (`down -v`) — so every test that creates a product leaves it behind. Reading a + * row straight off the rendered list therefore works until enough products + * accumulate to push the target off the first page, at which point unrelated + * tests start failing. Searching keeps the lookup independent of how much ran + * before it. + */ +async function searchProducts(page: Page, name: string) { + const search = page.getByPlaceholder('Search products'); + await expect(search).toBeVisible({ timeout: 15_000 }); + await search.fill(name); + // The query is debounced and refetched, so the row is the settle signal. + await expect(page.getByText(name, { exact: true }).first()).toBeVisible({ timeout: 20_000 }); +} + +export async function openSeededProductFromProductsPage(page: Page) { + await searchProducts(page, SEEDED_PRODUCT_NAME); + const seededProduct = page.getByText(SEEDED_PRODUCT_NAME, { exact: true }).first(); + await seededProduct.click(); + await expect(page).toHaveURL(PRODUCT_DETAIL_URL_PATTERN, { timeout: 10_000 }); +} + +export async function openProductByNameFromProductsPage(page: Page, name: string) { + await searchProducts(page, name); + const product = page.getByText(name, { exact: true }).first(); + await product.click(); + await expect(page).toHaveURL(PRODUCT_DETAIL_URL_PATTERN, { timeout: 15_000 }); + // Wait for the product detail page to fully load + await expect(page.getByRole('heading', { name, level: 1 })).toBeVisible({ + timeout: 15_000, + }); +} + +export async function openGalleryLightbox(page: Page) { + const productImageTrigger = page.getByRole('button', { name: VIEW_IMAGE_LABEL_PATTERN }).first(); + await expect(productImageTrigger).toBeVisible({ timeout: 10_000 }); + await productImageTrigger.click({ force: true }); + await expect(page.getByLabel('Close lightbox')).toBeVisible({ + timeout: 10_000, + }); +} + +export { EMAIL, PASSWORD }; diff --git a/app/e2e/journey.spec.ts b/app/e2e/journey.spec.ts new file mode 100644 index 000000000..1e593e589 --- /dev/null +++ b/app/e2e/journey.spec.ts @@ -0,0 +1,133 @@ +/** + * The critical journey, chained in one run. + * + * The rest of the suite covers these steps as fragments, each starting from a + * fresh login as the seeded superuser. That never proves the steps compose, and + * never proves an ordinary contributor can do them: `e2e-admin` is a superuser, + * so it passes every ownership and permission check by construction. This runs + * as `alice`, a seeded verified non-superuser. + * + * Registration is deliberately NOT chained in here. A password signup is created + * unverified, and creating a product requires a verified account + * (`createProductAction`). The E2E stack has no mail transport, the verification + * token is a stateless JWT that never leaves the API process, and `is_verified` + * is intentionally not settable through the admin API (`NoPublicAccountControls` + * on `UserUpdate`). So there is no way to verify a fresh account from a browser + * test today — `auth.spec.ts` covers registration up to the verify-email prompt, + * and this picks up from an already-verified account. + */ + +import { expect, test } from '@playwright/test'; +import { + dismissProductsInfoCard, + loginAndReachProducts, + openNewProductPage, + SEEDED_MEMBER, + suppressGuestWelcomeCard, +} from './helpers'; + +test.setTimeout(120_000); + +const PRODUCT_DETAIL_URL_PATTERN = /products\/\d+/; +const SAVED_PRODUCT_URL_PATTERN = /\/products\/\d+$/; +const COMPONENT_DETAIL_URL_PATTERN = /components\/\d+/; +const NEW_COMPONENT_URL_PATTERN = /\/products\/\d+\/components\/new$/; +const BACK_CONTROL_NAME_PATTERN = /back/i; +const URL_QUERY_STRING_PATTERN = /\?.*$/; +const PRODUCT_IMAGE_UPLOAD_PATH_PATTERN = /\/v1\/products\/\d+\/images$/; + +test('an ordinary member can create, populate and publish a product', async ({ page, browser }) => { + const stamp = Date.now(); + const productName = `Journey ${stamp}`; + const componentName = `Journey Part ${stamp}`; + + await loginAndReachProducts(page, SEEDED_MEMBER); + + // ── Create ────────────────────────────────────────────────────────────── + await openNewProductPage(page); + await page.getByRole('textbox', { name: 'Name' }).fill(productName); + await page.getByRole('button', { name: 'Create product' }).click(); + await expect(page).toHaveURL(PRODUCT_DETAIL_URL_PATTERN, { timeout: 15_000 }); + const productUrl = page.url().replace(URL_QUERY_STRING_PATTERN, ''); + + // ── Populate: a required dimension and a real image ────────────────────── + await page.getByRole('button', { name: 'Add properties' }).click(); + const weight = page.getByPlaceholder('e.g. 12').first(); + await weight.fill('42'); + await weight.blur(); + + const storedImages = page.locator('img[src*="/uploads/"]'); + const before = new Set( + await storedImages.evaluateAll((els) => els.map((el) => (el as HTMLImageElement).src)), + ); + + const [chooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('button', { name: 'Add photos from gallery' }).click(), + ]); + await chooser.setFiles('e2e/fixtures/test-image.png'); + // setFiles returns before the app has read the file into the form. Saving + // first would persist a product with no images and never issue the upload. + await expect(page.getByRole('button', { name: `View ${productName}` })).toBeVisible({ + timeout: 20_000, + }); + + // Picking stages the file; Save is what uploads it. + const [upload] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === 'POST' && PRODUCT_IMAGE_UPLOAD_PATH_PATTERN.test(r.url()), + { timeout: 30_000 }, + ), + page.getByRole('button', { name: 'Save Product' }).click(), + ]); + expect(upload.status()).toBeLessThan(300); + + // ── Compose: a child component ────────────────────────────────────────── + await expect(page).toHaveURL(SAVED_PRODUCT_URL_PATTERN, { timeout: 15_000 }); + await page.getByRole('button', { name: 'Add component' }).click(); + // Wait for the capture screen rather than assuming it. The press is + // occasionally swallowed at the navigation layer under parallel load — see + // product-detail.spec.ts — and without this the failure surfaces much later + // against a button that only exists on the screen we never reached. + await expect(page).toHaveURL(NEW_COMPONENT_URL_PATTERN, { timeout: 15_000 }); + await page.getByRole('textbox', { name: 'Name' }).fill(componentName); + await page.getByRole('button', { name: 'Create component' }).click(); + await expect(page).toHaveURL(COMPONENT_DETAIL_URL_PATTERN, { timeout: 15_000 }); + + await page.getByRole('button', { name: BACK_CONTROL_NAME_PATTERN }).click(); + await expect(page).toHaveURL(SAVED_PRODUCT_URL_PATTERN, { timeout: 15_000 }); + await expect(page.getByRole('button', { name: componentName, exact: true })).toBeVisible({ + timeout: 15_000, + }); + + // ── Publish: reachable without the session that created it ────────────── + // The whole point of the platform is that the record is public. Everything + // above ran with alice's cookie; a brand-new context has none. + const guestContext = await browser.newContext(); + try { + const guest = await guestContext.newPage(); + await suppressGuestWelcomeCard(guest); + await guest.goto(productUrl); + await dismissProductsInfoCard(guest); + + await expect(guest.getByText(productName).first()).toBeVisible({ timeout: 20_000 }); + await expect(guest.getByText(componentName).first()).toBeVisible({ timeout: 20_000 }); + + // Pin down that this really is an anonymous visitor. Without it the whole + // guest block would keep passing if the context ever inherited a session. + await expect(guest.getByRole('button', { name: 'Edit Product' })).toBeHidden(); + + // The image must be served to an anonymous visitor too, not just rendered + // from the authenticated session's cache. + const guestImages = guest.locator('img[src*="/uploads/"]'); + await expect(guestImages.first()).toBeAttached({ timeout: 20_000 }); + const srcs = await guestImages.evaluateAll((els) => + els.map((el) => (el as HTMLImageElement).src), + ); + const uploaded = srcs.find((src) => !before.has(src)); + expect(uploaded).toBeTruthy(); + expect((await guest.request.get(uploaded as string)).status()).toBe(200); + } finally { + await guestContext.close(); + } +}); diff --git a/app/e2e/product-detail.spec.ts b/app/e2e/product-detail.spec.ts new file mode 100644 index 000000000..33c3dea2a --- /dev/null +++ b/app/e2e/product-detail.spec.ts @@ -0,0 +1,430 @@ +/** + * Product detail page E2E tests. + * + * Covers: creating a new product via capture-first creation (name → Create), + * verifying the detail page loads in edit mode, editing fields, and the + * unsaved-changes guard. + * + * The test user (e2e-admin) is a verified superuser, so the "Create New + * Product" dialog is always accessible without the email-verification gate. + */ + +import { expect, test } from '@playwright/test'; +import { + loginAndReachProducts, + openNewProductPage, + openSeededProductFromProductsPage, + reachProductsPage, +} from './helpers'; + +test.setTimeout(60_000); + +const SEEDED_PRODUCT_NAME_PATTERN = /^(Dell XPS 13|iPhone 12)$/; +const PRODUCT_DETAIL_URL_PATTERN = /products\/\d+/; +const PRODUCTS_LIST_URL_PATTERN = /\/products$|\/products\?/; +// The header back affordance is a Pressable (accessibilityRole="button", label "Go back"), +// not a link — see HeaderBackButton. +const BACK_CONTROL_NAME_PATTERN = /back/i; +const PRODUCT_ID_TEXT_PATTERN = /Product ID: \d+/; +const PRODUCT_IMAGE_UPLOAD_PATH_PATTERN = /\/v1\/products\/\d+\/images$/; +// Empty optional sections collapse to a single "Add …" row in edit mode +// (Section.tsx showAddRow); pressing it reveals the real fields. +const ADD_DESCRIPTION_LABEL = 'Add a description'; +// Measurements and circularity notes share one Properties section, so one +// add-row opens both blocks. +const ADD_PROPERTIES_LABEL = 'Add properties'; +const DESCRIPTION_PLACEHOLDER = 'Add a product description'; + +// Stage 1 of capture-first creation: fill the name on the capture screen and +// press Create. The backend saves immediately and the app redirects to the +// new product's detail page in edit mode (?edit=1) — granular capture-form +// validation (short names, Create disabled/enabled) is unit-tested on +// CaptureScreen itself; this only proves the real navigation round-trip. +async function createProduct(page: import('@playwright/test').Page, name: string): Promise { + await openNewProductPage(page); + await page.getByRole('textbox', { name: 'Name' }).fill(name); + await page.getByRole('button', { name: 'Create product' }).click(); + await expect(page).toHaveURL(PRODUCT_DETAIL_URL_PATTERN, { timeout: 15_000 }); +} + +// Stage 2: detail-in-edit. The product already exists at this point, so this +// is an ordinary existing-record edit — same "Add …" row pattern as any other +// empty section (see the 2a add-row coverage below). +async function fillRequiredProductFields( + page: import('@playwright/test').Page, + name: string, +): Promise { + await createProduct(page, name); + await page.getByRole('button', { name: ADD_PROPERTIES_LABEL }).click(); + const weightInput = page.getByPlaceholder('e.g. 12').first(); + await weightInput.fill('42'); + await weightInput.blur(); +} + +async function saveNewProduct(page: import('@playwright/test').Page, name: string): Promise { + await fillRequiredProductFields(page, name); + await expect(page.getByRole('button', { name: 'Save Product' })).toBeEnabled({ + timeout: 5_000, + }); + await page.getByRole('button', { name: 'Save Product' }).click(); +} + +// ─── Product detail navigation ───────────────────────────────────────────────── + +test.describe('Product detail: navigation', () => { + test('clicking a product card navigates to the detail page', { tag: '@cross-browser' }, async ({ + page, + }) => { + await reachProductsPage(page); + await openSeededProductFromProductsPage(page); + }); + + test('product detail page shows the product name in the header', async ({ page }) => { + await reachProductsPage(page); + await openSeededProductFromProductsPage(page); + await expect( + page.getByRole('heading', { name: SEEDED_PRODUCT_NAME_PATTERN }).last(), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +// ─── Section nav anchors ──────────────────────────────────────────────────── +// Regression net for the section-anchor coordinate bug: Section registered +// its onLayout y relative to its parent View, not the scroll content, so +// chip taps landed roughly one section short (missing the gallery height). +test.describe('Product detail: section navigation', () => { + test('clicking the Properties chip scrolls that section to the top of the viewport', async ({ + page, + }) => { + await reachProductsPage(page); + await openSeededProductFromProductsPage(page); + + // Both seeded products have physical properties set, so the section (and + // its nav chip/outline entry) is visible in view mode without editing. + await page.getByRole('button', { name: 'Properties' }).click(); + + // The chip/outline entry and the Section heading share the same text; the + // Section heading is the last match in DOM order (nav renders first). + const heading = page.getByText('Properties', { exact: true }).last(); + await expect(heading).toBeVisible({ timeout: 5_000 }); + // The heading settles just under the sticky chrome (top nav + product header + // + chip bar), so the ceiling is measured from that chrome rather than a + // magic number that drifts every time the header changes height. Pre-fix the + // scroll landed ~a full section short — hundreds of px below this bound. + const chipBar = await page.getByRole('button', { name: 'Properties' }).boundingBox(); + const chromeBottom = (chipBar?.y ?? 0) + (chipBar?.height ?? 0); + // Poll: scrollTo animates, so the heading needs a moment to settle. + await expect + .poll(async () => (await heading.boundingBox())?.y ?? Number.POSITIVE_INFINITY, { + timeout: 5_000, + }) + .toBeLessThan(chromeBottom + 100); + }); +}); + +// ─── Chunking at phone width ──────────────────────────────────────────────── +// Six sections used to make six chips, of which two sat off-screen at 390pt. +// Assert the painted result: every chip's right edge inside the viewport, in +// edit mode (where every section, empty or not, is present). +test.describe('Product detail: phone chunking', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('all section chips fit one row at 390pt in edit mode', async ({ page }) => { + await loginAndReachProducts(page); + await createProduct(page, `E2E Chunk ${Date.now()}`); + const chips = page.getByTestId('section-nav-chips').getByRole('button'); + await expect(chips).toHaveCount(4); + const boxes = await Promise.all((await chips.all()).map((chip) => chip.boundingBox())); + for (const box of boxes) { + expect(box).not.toBeNull(); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390); + } + }); + + test('edit mode has exactly one name control, at display scale', async ({ page }) => { + await loginAndReachProducts(page); + await createProduct(page, `E2E Name ${Date.now()}`); + const nameInputs = page.getByRole('textbox', { name: 'Product name' }); + await expect(nameInputs).toHaveCount(1); + const fontSize = await nameInputs.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + // The display step is the largest text on the screen (DESIGN.md §Hierarchy). + expect(fontSize).toBeGreaterThanOrEqual(30); + }); + + test('compact empty gallery keeps the product name in the first viewport', async ({ page }) => { + await loginAndReachProducts(page); + await createProduct(page, `E2E Gallery ${Date.now()}`); + + const gallery = page.getByTestId('empty-gallery-actions'); + const productName = page.getByRole('textbox', { name: 'Product name' }); + const [galleryBox, nameBox] = await Promise.all([ + gallery.boundingBox(), + productName.boundingBox(), + ]); + expect(galleryBox).not.toBeNull(); + expect(galleryBox?.height ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(112); + expect(nameBox).not.toBeNull(); + expect((nameBox?.y ?? 0) + (nameBox?.height ?? 0)).toBeLessThanOrEqual(844); + }); + + test('Properties has internal hierarchy and its fields clear the flow Save bar', async ({ + page, + }) => { + await loginAndReachProducts(page); + await createProduct(page, `E2E Properties ${Date.now()}`); + await page.getByRole('button', { name: ADD_PROPERTIES_LABEL }).click(); + + await expect(page.getByText('Measurements', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Circularity notes' })).toBeVisible(); + + const saveBar = page.getByTestId('save-bar-dock'); + const noteFields = page.locator('textarea'); + await expect(noteFields).toHaveCount(3); + const fields = await noteFields.all(); + await Promise.all(fields.map((noteField) => noteField.scrollIntoViewIfNeeded())); + const [fieldBoxes, saveBox] = await Promise.all([ + Promise.all(fields.map((noteField) => noteField.boundingBox())), + saveBar.boundingBox(), + ]); + expect(saveBox).not.toBeNull(); + for (const fieldBox of fieldBoxes) { + expect(fieldBox).not.toBeNull(); + expect((fieldBox?.y ?? 0) + (fieldBox?.height ?? 0)).toBeLessThanOrEqual(saveBox?.y ?? 0); + } + }); +}); + +// ─── Product creation flow ───────────────────────────────────────────────────── +// Capture-form-level validation (short names, Create disabled/enabled, 100-char +// behavior) is unit-tested on CaptureScreen; these only prove the real +// capture → detail round-trip through the app and backend. + +test.describe('Product creation', () => { + test('creating a product via capture lands on its saved detail page in edit mode', { + tag: ['@cross-browser', '@auth'], + }, async ({ page }) => { + await loginAndReachProducts(page); + const productName = `E2E Test ${Date.now()}`; + await createProduct(page, productName); + + // In edit mode the header *is* the name field (a textbox), not a + // static heading — see productPageHelpers.tsx's useProductPageHeader. + await expect(page.getByRole('textbox', { name: 'Product name' })).toHaveValue(productName, { + timeout: 10_000, + }); + await expect(page.getByRole('button', { name: 'Save Product' })).toBeVisible(); + }); + + test('discarding the capture draft returns to the products page', async ({ page }) => { + await loginAndReachProducts(page); + await openNewProductPage(page); + await page.getByRole('textbox', { name: 'Name' }).fill('Discard me'); + // Unlike the detail screen (a custom Pressable back button), the capture + // screen uses expo-router's default web back control, which renders as a + // link rather than a button. + await page.getByRole('link', { name: BACK_CONTROL_NAME_PATTERN }).click(); + await expect(page.getByText('Discard changes?')).toBeVisible({ + timeout: 10_000, + }); + await page.getByRole('button', { name: 'Discard' }).click(); + await expect(page).toHaveURL(PRODUCTS_LIST_URL_PATTERN, { + timeout: 10_000, + }); + }); + + test('saving physical properties on a freshly created product persists them', async ({ + page, + }) => { + await loginAndReachProducts(page); + await saveNewProduct(page, `E2E Test ${Date.now()}`); + + await expect(page.getByRole('button', { name: 'Properties' })).toBeVisible({ + timeout: 10_000, + }); + }); +}); + +// ─── Product detail edit mode ────────────────────────────────────────────────── +// Carried over from 2a-T7 almost verbatim: once a product exists (whether just +// created via capture or opened from the list) its detail-in-edit behavior — +// collapsed "Add …" rows, the unsaved-changes guard — is identical. + +test.describe('Product detail: edit mode', () => { + test('a freshly created product opens in edit mode with collapsed optional sections', async ({ + page, + }) => { + await loginAndReachProducts(page); + await createProduct(page, `E2E Test ${Date.now()}`); + + // A fresh product's optional sections are all empty, so they collapse to a + // single "Add …" row (Section.tsx showAddRow) instead of their full + // content. Assert the row for Overview, then press it to prove it + // actually reveals the description field. + const addDescriptionRow = page.getByRole('button', { name: ADD_DESCRIPTION_LABEL }); + await expect(addDescriptionRow).toBeVisible({ timeout: 10_000 }); + await addDescriptionRow.click(); + await expect(page.getByPlaceholder(DESCRIPTION_PLACEHOLDER)).toBeVisible({ + timeout: 5_000, + }); + + // Other empty sections stay collapsed but present. + await expect(page.getByRole('button', { name: ADD_PROPERTIES_LABEL })).toBeVisible({ + timeout: 5_000, + }); + // The metadata footer (dates, owner, id) is not a section and never + // collapses; its always-present "Product ID: N" line proves it rendered. + await expect(page.getByText(PRODUCT_ID_TEXT_PATTERN)).toBeVisible({ timeout: 5_000 }); + }); + + test('unsaved-changes guard blocks navigation mid-edit', async ({ page }) => { + await loginAndReachProducts(page); + await createProduct(page, `E2E Test ${Date.now()}`); + + // Overview is empty on a fresh product, so the description field sits + // behind the "Add a description" row until pressed. + await page.getByRole('button', { name: ADD_DESCRIPTION_LABEL }).click(); + const descriptionInput = page.getByPlaceholder(DESCRIPTION_PLACEHOLDER); + await expect(descriptionInput).toBeVisible({ timeout: 10_000 }); + // Make the form dirty so the unsaved-changes guard fires (form starts pristine after creation) + await descriptionInput.fill('test description'); + + // Attempt to leave via the in-app header back control; the unsaved-changes guard should intercept. + await page.getByRole('button', { name: BACK_CONTROL_NAME_PATTERN }).click(); + await expect(page.getByText('Discard changes?')).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByRole('button', { name: "Don't leave" })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Discard' })).toBeVisible(); + + // Choose "Don't leave"; stays on the product page + await page.getByRole('button', { name: "Don't leave" }).click(); + await expect(page.getByText('Discard changes?')).not.toBeVisible(); + await expect(page).toHaveURL(PRODUCT_DETAIL_URL_PATTERN); + }); +}); + +// ─── Adding a child component ────────────────────────────────────────────────── +// Composing a product out of parts is the whole point of the data model, and no +// spec drove it end to end: the add affordance, the child capture screen, the +// parent-child link, and the parent's rendering of it were all only unit-tested +// against mocks. + +const COMPONENT_DETAIL_URL_PATTERN = /components\/\d+/; +const NEW_COMPONENT_URL_PATTERN = /\/products\/\d+\/components\/new$/; +// No ?edit=1: the product detail page after the save round-trip has completed. +const SAVED_PRODUCT_URL_PATTERN = /\/products\/\d+$/; + +test.describe('Product detail: components', () => { + test('adding a component links it to the parent product', async ({ page }) => { + await loginAndReachProducts(page); + const componentName = `E2E Component ${Date.now()}`; + await saveNewProduct(page, `E2E Parent ${Date.now()}`); + + // "Add component" only renders once the save has dropped ?edit=1 — a + // component needs a persisted parent — so this also waits out the save. + // The name is unambiguous: the Components section's info tooltip, whose + // label used to contain this one, is edit-mode only. + const addComponent = page.getByRole('button', { name: 'Add component' }); + await expect(addComponent).toBeVisible({ timeout: 15_000 }); + await addComponent.click(); + + // Wait for the capture screen before touching it. Without this a lost + // navigation surfaces 60s later as "Create component not found", pointing at + // the wrong step entirely — the parent page has no such button. + await expect(page).toHaveURL(NEW_COMPONENT_URL_PATTERN, { timeout: 15_000 }); + + // The child capture screen is the same CaptureScreen as product creation, + // with entityRole="component" — hence "Create component" rather than + // "Create product". + await page.getByRole('textbox', { name: 'Name' }).fill(componentName); + await page.getByRole('button', { name: 'Create component' }).click(); + await expect(page).toHaveURL(COMPONENT_DETAIL_URL_PATTERN, { timeout: 15_000 }); + + // Back to the parent: the component must now show up in its Components + // section. This is the assertion that proves the parent link persisted + // server-side rather than just the child record being created. + await page.getByRole('button', { name: BACK_CONTROL_NAME_PATTERN }).click(); + await expect(page).toHaveURL(SAVED_PRODUCT_URL_PATTERN, { timeout: 15_000 }); + // The row itself, not just the text: it is the pressable that navigates + // back down into the child, so its presence proves the parent actually + // holds the link rather than the name merely appearing somewhere. + // exact: the row sits beside a "Show components of " expander, whose + // accessible name also contains the component's. + await expect(page.getByRole('button', { name: componentName, exact: true })).toBeVisible({ + timeout: 15_000, + }); + }); +}); + +// ─── Image upload ────────────────────────────────────────────────────────────── +// The gallery spec mocks /products and the image URLs outright, so a broken +// upload, storage write, or thumbnail pipeline could not fail the suite. This +// drives the real multipart upload and then fetches the stored bytes back. + +test.describe('Product detail: image upload', () => { + test('uploads an image and serves it back from storage', async ({ page }) => { + await loginAndReachProducts(page); + const productName = `E2E Upload ${Date.now()}`; + await createProduct(page, productName); + + // Seeded products already put /uploads/ thumbnails in the DOM, so record + // them first — otherwise an assertion on "an uploaded image exists" passes + // without this test having uploaded anything. + const storedImages = page.locator('img[src*="/uploads/"]'); + const before = new Set( + await storedImages.evaluateAll((els) => els.map((el) => (el as HTMLImageElement).src)), + ); + + // expo-image-picker builds its on click rather than + // rendering one, so intercept the chooser instead of locating an input. + const [chooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('button', { name: 'Add photos from gallery' }).click(), + ]); + await chooser.setFiles('e2e/fixtures/test-image.png'); + // setFiles returns before the app has read the file into the form. Saving + // first would persist a product with no images and never issue the upload. + await expect(page.getByRole('button', { name: `View ${productName}` })).toBeVisible({ + timeout: 20_000, + }); + + // Picking only stages the file client-side; Save is what uploads it. + const [upload] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === 'POST' && PRODUCT_IMAGE_UPLOAD_PATH_PATTERN.test(r.url()), + { timeout: 30_000 }, + ), + page.getByRole('button', { name: 'Save Product' }).click(), + ]); + expect(upload.status()).toBeLessThan(300); + + // A URL that was not on the page before: proof this product's own image was + // stored, rather than a seeded one being re-read. + await expect + .poll( + async () => { + const srcs = await storedImages.evaluateAll((els) => + els.map((el) => (el as HTMLImageElement).src), + ); + return srcs.filter((src) => !before.has(src)); + }, + { timeout: 30_000 }, + ) + .not.toHaveLength(0); + + const srcs = await storedImages.evaluateAll((els) => + els.map((el) => (el as HTMLImageElement).src), + ); + const uploadedSrc = srcs.find((src) => !before.has(src)); + + // Fetch the bytes back out of storage. This is what separates "the client + // rendered something" from "the pipeline actually stored it". + const response = await page.request.get(uploadedSrc as string); + expect(response.status()).toBe(200); + expect((await response.body()).byteLength).toBeGreaterThan(0); + + await expect(page.getByRole('button', { name: `View ${productName}` })).toBeVisible({ + timeout: 15_000, + }); + }); +}); diff --git a/app/e2e/products.spec.ts b/app/e2e/products.spec.ts new file mode 100644 index 000000000..475ef098d --- /dev/null +++ b/app/e2e/products.spec.ts @@ -0,0 +1,192 @@ +/** + * Full-stack products E2E smoke tests. + * + * These tests verify that the frontend ↔ backend ↔ database integration is + * working for the core product data flow. They run after auth.spec.ts has + * already completed the onboarding step (so the test user has a username). + * + * If auth.spec.ts is skipped or run in isolation, these tests will also go + * through the login/onboarding flow via the shared helper. + */ + +import { expect, test } from '@playwright/test'; +import { + dismissProductsInfoCard, + finishOnboardingIfVisible, + loginAndReachProducts, + openNewProductPage, + openProductFilters, + suppressGuestWelcomeCard, +} from './helpers'; + +const LOGIN_URL_PATTERN = /login/; +const ONBOARDING_OR_PRODUCTS_URL_PATTERN = /onboarding|products/; +// The seeded iPhone 12 is the one list product carrying a photograph. +const THUMBNAIL_URL_PATTERN = /\/uploads\/images\/.+/; +const PRODUCT_TYPE_BUTTON_PATTERN = /Product type/; +const PRODUCT_COUNT_PATTERN = /\d+ of \d+ products/; + +async function registerNewUserAndReachProducts(page: import('@playwright/test').Page) { + const unique = Date.now(); + const username = `empty${unique}`; + const email = `empty-${unique}@example.com`; + const password = 'correct-horse-battery-staple-v42'; + + await page.goto('/new-account'); + await page.getByLabel('Username', { exact: true }).fill(username); + await page.getByTestId('username-next').click(); + await page.getByLabel('Email address').fill(email); + await page.getByTestId('email-next').click(); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Create account' }).click(); + + // Registration no longer auto-logs-in (non-enumerable signup): dismiss the + // verify-email prompt, then log in with the new credentials to reach products. + await expect(page.getByText('Check your email')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: 'OK' }).click(); + await expect(page).toHaveURL(LOGIN_URL_PATTERN, { timeout: 5_000 }); + + await page.getByLabel('Email or username').fill(email); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL(ONBOARDING_OR_PRODUCTS_URL_PATTERN, { timeout: 30_000 }); + await finishOnboardingIfVisible(page); + await dismissProductsInfoCard(page); + await expect(page.getByPlaceholder('Search products')).toBeVisible({ timeout: 10_000 }); +} + +test.describe('Guest access', () => { + test('products page is publicly accessible without signing in', { + tag: '@cross-browser', + }, async ({ page }) => { + // The guest welcome card carries its own "Sign in" button, so suppressing it + // leaves the header pill as the only match for the assertion below. + await suppressGuestWelcomeCard(page); + await page.goto('/products'); + await dismissProductsInfoCard(page); + await expect(page.getByPlaceholder('Search products')).toBeVisible({ + timeout: 10_000, + }); + // Header shows "Sign in" pill for guests + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); + }); +}); + +test.describe('Products page: phone layout', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('filter controls fit the viewport and the guest creation action names its gate', async ({ + page, + }) => { + await suppressGuestWelcomeCard(page); + await page.goto('/products'); + await dismissProductsInfoCard(page); + + await expect(page.getByRole('button', { name: 'Filters' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in to add product' })).toBeVisible(); + await page.getByRole('button', { name: 'Filters' }).click(); + + const productType = page.getByRole('button', { name: PRODUCT_TYPE_BUTTON_PATTERN }); + await expect(productType).toBeVisible(); + const box = await productType.boundingBox(); + expect(box).not.toBeNull(); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390); + }); + + test('terminal result count clears the floating creation action', async ({ page }) => { + await suppressGuestWelcomeCard(page); + await page.goto('/products'); + await dismissProductsInfoCard(page); + + const count = page.getByText(PRODUCT_COUNT_PATTERN); + await count.scrollIntoViewIfNeeded(); + const fab = page.getByRole('button', { name: 'Sign in to add product' }); + // Retried rather than measured once: the count text is the list's terminal + // row, so scrolling to it can pull in another page of products and shift it + // between the two boundingBox() calls. + await expect(async () => { + const [countBox, fabBox] = await Promise.all([count.boundingBox(), fab.boundingBox()]); + expect(countBox).not.toBeNull(); + expect(fabBox).not.toBeNull(); + expect((countBox?.y ?? 0) + (countBox?.height ?? 0)).toBeLessThanOrEqual(fabBox?.y ?? 0); + }).toPass({ timeout: 10_000 }); + }); +}); + +test.describe('Products page', () => { + test('products page loads with correct filter tabs and search bar', { + tag: ['@cross-browser', '@auth'], + }, async ({ page }) => { + await loginAndReachProducts(page); + await expect(page.getByPlaceholder('Search products')).toBeVisible(); + // The chip row is collapsed by default — a first visit is search + records. + await expect(page.getByText('Date', { exact: true })).not.toBeVisible(); + await openProductFilters(page); + await expect(page.getByText('Mine', { exact: true })).toBeVisible(); + await expect(page.getByText('Date', { exact: true })).toBeVisible(); + }); + + test('empty state is shown when no products exist', async ({ page }) => { + await registerNewUserAndReachProducts(page); + await openProductFilters(page); + await page.getByText('Mine', { exact: true }).click(); + await expect(page.getByText("You haven't created any products yet. Tap the")).toBeVisible({ + timeout: 10_000, + }); + }); + + test('new product page opens for a verified user', async ({ page }) => { + await loginAndReachProducts(page); + await openNewProductPage(page); + }); + + test('a seeded product thumbnail renders as a real image', async ({ page }) => { + // Every other test here would pass against a wall of broken images: a + // failed load swaps the for a placeholder (ProductCard's onError), + // and nothing asserts which one it got. This walks the whole chain — seeded + // image row, API-built thumbnail URL, app-side URL resolution, decoded + // bytes. The API relaxes its Cross-Origin-Resource-Policy for /uploads + // under `testing`, so the rig's cross-port 127.0.0.1 origins load exactly + // as the deployed ones do. + await page.goto('/products'); + await dismissProductsInfoCard(page); + + const thumbnail = page.getByTestId('product-thumbnail').locator('img').first(); + await expect(thumbnail).toHaveAttribute('src', THUMBNAIL_URL_PATTERN); + await expect + .poll(() => thumbnail.evaluate((image: HTMLImageElement) => image.naturalWidth)) + .toBeGreaterThan(0); + }); +}); + +test.describe('Search', () => { + test('typing in the search bar shows the no-results message for an unlikely query', async ({ + page, + }) => { + await loginAndReachProducts(page); + await page.getByPlaceholder('Search products').fill('xyz_no_match_99999'); + // searchQuery state updates immediately, so the no-match message appears before + // the debounced API call even fires + await expect(page.getByText('No products match your search.')).toBeVisible({ + timeout: 5_000, + }); + }); + + test('clearing the search bar restores the default empty-state message', async ({ page }) => { + // A fresh account, not the shared admin: the "Mine" empty state is only + // reachable for a user who owns nothing, and the product-creation specs add + // products under the admin during the same run. + await registerNewUserAndReachProducts(page); + const searchBar = page.getByPlaceholder('Search products'); + await searchBar.fill('xyz_no_match_99999'); + await expect(page.getByText('No products match your search.')).toBeVisible({ + timeout: 5_000, + }); + await searchBar.clear(); + await openProductFilters(page); + await page.getByText('Mine', { exact: true }).click(); + await expect(page.getByText("You haven't created any products yet. Tap the")).toBeVisible({ + timeout: 5_000, + }); + }); +}); diff --git a/app/e2e/profile.spec.ts b/app/e2e/profile.spec.ts new file mode 100644 index 000000000..8aa4462d6 --- /dev/null +++ b/app/e2e/profile.spec.ts @@ -0,0 +1,173 @@ +/** + * Profile page E2E tests. + * + * Prerequisites: + * - compose.e2e.yaml services are running + * - the Expo web app has been built for E2E + * + * Test user credentials come from backend/.env.test. + */ + +import { expect, test } from '@playwright/test'; +import { + EMAIL, + finishOnboardingIfVisible, + loginAndGoToProfile, + PASSWORD, + suppressTermsPrompt, +} from './helpers'; + +const LOGIN_URL_PATTERN = /login/; +const ONBOARDING_OR_PRODUCTS_URL_PATTERN = /onboarding|products/; +// The profile page is served at the /account route. +const PROFILE_URL_PATTERN = /account/; +const EMAIL_UPDATES_STATUS_PATTERN = /Currently (enabled|disabled)\./; +// Matches AccountSections.tsx's ProfileLinkedAccountsSection titles exactly +// (lowercase "account" in the not-yet-linked case — see its own unit test). +const GOOGLE_LINK_PATTERN = /^(Link Google account|Unlink Google)$/; +const GITHUB_LINK_PATTERN = /^(Link GitHub account|Unlink GitHub)$/; +const PRODUCTS_URL_PATTERN = /products/; + +test.describe('Profile: access', () => { + test('unauthenticated visit redirects to login', async ({ page }) => { + await page.goto('/account'); + await expect(page).toHaveURL(LOGIN_URL_PATTERN, { timeout: 5_000 }); + }); + + test('header shows username pill (not Sign in) after login', async ({ page }) => { + await suppressTermsPrompt(page); + await page.goto('/login'); + await page.getByLabel('Email or username').fill(EMAIL); + await page.getByLabel('Password', { exact: true }).fill(PASSWORD); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL(ONBOARDING_OR_PRODUCTS_URL_PATTERN, { timeout: 30_000 }); + await finishOnboardingIfVisible(page); + // Once authenticated, the header pill switches from "Sign in" to the username + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).not.toBeVisible({ + timeout: 5_000, + }); + // The header also shows the email address as part of the identity in the profile page, + // verifying the auth state is reflected. Navigate to /account to confirm it loads. + await page.goto('/account'); + await expect(page).toHaveURL(PROFILE_URL_PATTERN, { timeout: 5_000 }); + await expect(page.getByText('Hi,')).toBeVisible(); + }); +}); + +test.describe('Profile: content', () => { + test('displays user email and account status chips', { + tag: ['@cross-browser', '@auth'], + }, async ({ page }) => { + await loginAndGoToProfile(page); + await expect(page.getByText(EMAIL)).toBeVisible(); + // exact: true — "Active" as a substring also matches "End all active sessions…". + await expect(page.getByText('Active', { exact: true })).toBeVisible(); + // The e2e superuser is created with is_verified=True + await expect(page.getByText('Verified')).toBeVisible(); + // The e2e superuser is a superuser + await expect(page.getByText('Superuser')).toBeVisible(); + }); + + test('shows all expected profile sections', async ({ page }) => { + await loginAndGoToProfile(page); + // Section headers are the four ACCOUNT_SECTIONS titles (accountSections.tsx) + // — the account screen's grouped-section restructure retired the old + // standalone "Account"/"Email updates"/"Linked accounts" headings, and the + // stats row folded into the hero header rather than its own "Profile" + // section. Each title doubles as the section-nav chip/outline label, so + // exact + .last() targets the Section heading (nav renders first in DOM + // order). + await Promise.all( + ['Preferences', 'Integrations', 'Security & sessions', 'Danger zone'].map((title) => + expect(page.getByText(title, { exact: true }).last()).toBeVisible(), + ), + ); + }); + + test('email updates status text is displayed', async ({ page }) => { + await loginAndGoToProfile(page); + await expect(page.getByText(EMAIL_UPDATES_STATUS_PATTERN)).toBeVisible({ + timeout: 10_000, + }); + }); + + test('linked accounts section shows Google and GitHub options', async ({ page }) => { + await loginAndGoToProfile(page); + await expect(page.getByText(GOOGLE_LINK_PATTERN)).toBeVisible(); + await expect(page.getByText(GITHUB_LINK_PATTERN)).toBeVisible(); + }); +}); + +test.describe('Profile: username dialog', () => { + test('tapping the username heading opens the edit-username dialog', async ({ page }) => { + await loginAndGoToProfile(page); + // The "Hi," Text and the username Pressable are siblings in the hero section. + // Clicking the sibling immediately after "Hi," triggers setEditUsernameVisible. + const hiText = page.getByText('Hi,'); + await expect(hiText).toBeVisible(); + await hiText.locator('xpath=following-sibling::*[1]').click(); + await expect(page.getByText('Edit username')).toBeVisible({ + timeout: 3_000, + }); + // Dialog should have Cancel and Save buttons + await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save' })).toBeVisible(); + // Dismiss without saving + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByText('Edit username')).not.toBeVisible(); + }); +}); + +test.describe('Profile: logout dialog', () => { + test('logout button opens the confirmation dialog', async ({ page }) => { + await loginAndGoToProfile(page); + // "Sign out" appears in the Account section as a ProfileAction title + await page.getByText('Sign out', { exact: true }).first().click(); + await expect(page.getByText('Are you sure you want to sign out?')).toBeVisible({ + timeout: 3_000, + }); + await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign out', exact: true }).last()).toBeVisible(); + }); + + test('canceling the logout dialog keeps the user on the profile page', async ({ page }) => { + await loginAndGoToProfile(page); + await page.getByText('Sign out', { exact: true }).first().click(); + await expect(page.getByText('Are you sure you want to sign out?')).toBeVisible({ + timeout: 3_000, + }); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByText('Are you sure you want to sign out?')).not.toBeVisible(); + await expect(page).toHaveURL(PROFILE_URL_PATTERN); + }); + + test('confirming logout navigates to products and shows Sign in header', { + tag: ['@cross-browser', '@auth'], + }, async ({ page }) => { + await loginAndGoToProfile(page); + await page.getByRole('button', { name: 'Sign out', exact: true }).first().click(); + await expect(page.getByText('Are you sure you want to sign out?')).toBeVisible({ + timeout: 5_000, + }); + // Click the dialog's Sign out confirm button (last "Sign out" on the page) + await page.getByRole('button', { name: 'Sign out', exact: true }).last().click(); + await expect(page).toHaveURL(PRODUCTS_URL_PATTERN, { timeout: 15_000 }); + // The header should now show "Sign in" instead of the username + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible({ + timeout: 5_000, + }); + }); +}); + +test.describe('Profile: delete dialog', () => { + test('delete account dialog shows the contact email address', async ({ page }) => { + await loginAndGoToProfile(page); + await page.getByText('Delete account?').click(); + await expect(page.getByText('relab@cml.leidenuniv.nl')).toBeVisible({ + timeout: 3_000, + }); + // Dismiss the dialog + await page.getByRole('button', { name: 'OK' }).click(); + await expect(page.getByText('relab@cml.leidenuniv.nl')).not.toBeVisible(); + }); +}); diff --git a/frontend-app/e2e/sort-filter.spec.ts b/app/e2e/sort-filter.spec.ts similarity index 83% rename from frontend-app/e2e/sort-filter.spec.ts rename to app/e2e/sort-filter.spec.ts index f331a4a80..845b1144d 100644 --- a/frontend-app/e2e/sort-filter.spec.ts +++ b/app/e2e/sort-filter.spec.ts @@ -9,10 +9,18 @@ */ import { expect, test } from '@playwright/test'; -import { loginAndReachProducts, openMenu, reachProductsPage, selectMenuItem } from './helpers'; - -const SORT_CREATED_AT_URL_PATTERN = /sort=created_at/; -const SORT_NAME_URL_PATTERN = /sort=name/; +import { + loginAndReachProducts, + openMenu, + openProductFilters, + reachProductsPage, + selectMenuItem, +} from './helpers'; + +// Sort values carry a direction prefix ("+" ascending / "-" descending), +// URL-encoded as %2B. "Oldest first" and "Name A→Z" are ascending. +const SORT_CREATED_AT_URL_PATTERN = /sort=(?:%2B|\+)created_at/; +const SORT_NAME_URL_PATTERN = /sort=(?:%2B|\+)name/; const DAYS_7_URL_PATTERN = /days=7/; const DAYS_30_URL_PATTERN = /days=30/; const DAYS_90_URL_PATTERN = /days=90/; @@ -23,9 +31,10 @@ const ANY_SEARCH_QUERY_URL_PATTERN = /q=/; const FILTER_MODE_MINE_URL_PATTERN = /filterMode=mine/; const FILTER_MODE_ALL_URL_PATTERN = /filterMode=all/; -/** Navigate to /products and dismiss the welcome card if present. */ +/** Navigate to /products, dismiss the welcome card if present, open the chip row. */ async function goToProducts(page: import('@playwright/test').Page) { await reachProductsPage(page); + await openProductFilters(page); } // ─── Sort ────────────────────────────────────────────────────────────────────── @@ -33,7 +42,7 @@ async function goToProducts(page: import('@playwright/test').Page) { test.describe('Sort menu', () => { test('sort button is visible on the products page', async ({ page }) => { await goToProducts(page); - await expect(page.getByLabel('Sort products')).toBeVisible(); + await expect(page.getByLabel('Sort: Newest first')).toBeVisible(); }); test('sort button opens a menu with all expected options', async ({ page }) => { @@ -55,7 +64,7 @@ test.describe('Sort menu', () => { async () => { const snapshot = await titles.allInnerTexts(); if (snapshot.length === expected.length) return snapshot.map((t) => t.trim()); - await openMenu(page, page.getByLabel('Sort products')).catch(() => {}); + await openMenu(page, page.getByLabel('Sort: Newest first')).catch(() => {}); return (await titles.allInnerTexts()).map((t) => t.trim()); }, { timeout: 15_000, intervals: [500, 1_000, 2_000] }, @@ -65,19 +74,19 @@ test.describe('Sort menu', () => { test('selecting "Oldest first" updates the URL sort param', async ({ page }) => { await goToProducts(page); - await selectMenuItem(page, page.getByLabel('Sort products'), 'Oldest first'); + await selectMenuItem(page, page.getByLabel('Sort: Newest first'), 'Oldest first'); await expect(page).toHaveURL(SORT_CREATED_AT_URL_PATTERN, { timeout: 3_000 }); }); test('selecting "Name A→Z" updates the URL sort param', async ({ page }) => { await goToProducts(page); - await selectMenuItem(page, page.getByLabel('Sort products'), 'Name A→Z'); + await selectMenuItem(page, page.getByLabel('Sort: Newest first'), 'Name A→Z'); await expect(page).toHaveURL(SORT_NAME_URL_PATTERN, { timeout: 3_000 }); }); test('sort menu closes after selecting an option', async ({ page }) => { await goToProducts(page); - await selectMenuItem(page, page.getByLabel('Sort products'), 'Newest first'); + await selectMenuItem(page, page.getByLabel('Sort: Newest first'), 'Newest first'); // After selection the menu dismisses; items leave the DOM await expect(page.locator('[data-testid="menu-item-title"]').first()).not.toBeAttached({ timeout: 3_000, @@ -116,8 +125,10 @@ test.describe('Date filter chips', () => { await goToProducts(page); await selectMenuItem(page, page.getByText('Date', { exact: true }), 'Last 30d'); await expect(page).toHaveURL(DAYS_30_URL_PATTERN, { timeout: 3_000 }); - // Toggle off via the close (×) button on the active chip - await page.getByRole('button', { name: 'Close', exact: true }).click(); + // Toggle off via the close (×) button on the active chip. FilterChip + // (base/ui replacement for Paper's Chip) labels it "Clear {label} filter" + // rather than a bare "Close". + await page.getByRole('button', { name: 'Clear Last 30d filter', exact: true }).click(); await expect(page).not.toHaveURL(ANY_DAYS_URL_PATTERN, { timeout: 5_000 }); }); @@ -143,20 +154,20 @@ test.describe('Brand filter', () => { }) => { await goToProducts(page); await page.getByText('Brand', { exact: true }).click(); - await expect(page.getByText('Filter by Brand')).toBeVisible({ + await expect(page.getByText('Filter by brand')).toBeVisible({ timeout: 5_000, }); - await expect(page.getByPlaceholder('Search brands...')).toBeVisible(); + await expect(page.getByPlaceholder('Search brands…')).toBeVisible(); }); test('dismissing the brand modal closes it without filtering', async ({ page }) => { await goToProducts(page); await page.getByText('Brand', { exact: true }).click(); - await expect(page.getByText('Filter by Brand')).toBeVisible({ + await expect(page.getByText('Filter by brand')).toBeVisible({ timeout: 5_000, }); await page.getByRole('button', { name: 'Done' }).click(); - await expect(page.getByText('Filter by Brand')).not.toBeVisible({ + await expect(page.getByText('Filter by brand')).not.toBeVisible({ timeout: 3_000, }); // URL should not contain brands param @@ -167,11 +178,11 @@ test.describe('Brand filter', () => { test.describe('Type filter', () => { test('Type chip opens a filter modal with a search field', async ({ page }) => { await goToProducts(page); - await page.getByText('Type', { exact: true }).click(); - await expect(page.getByText('Filter by Product Type')).toBeVisible({ + await page.getByText('Product type', { exact: true }).click(); + await expect(page.getByText('Filter by product type')).toBeVisible({ timeout: 5_000, }); - await expect(page.getByPlaceholder('Search types...')).toBeVisible(); + await expect(page.getByPlaceholder('Search types…')).toBeVisible(); }); }); @@ -214,17 +225,20 @@ test.describe('My Products filter', () => { // After login: "Mine" chip appears await loginAndReachProducts(page); + await openProductFilters(page); await expect(page.getByText('Mine', { exact: true })).toBeVisible(); }); test('clicking My Products updates the filterMode URL param', async ({ page }) => { await loginAndReachProducts(page); + await openProductFilters(page); await page.getByText('Mine', { exact: true }).click(); await expect(page).toHaveURL(FILTER_MODE_MINE_URL_PATTERN, { timeout: 3_000 }); }); test('switching back to All Products clears filterMode=mine', async ({ page }) => { await loginAndReachProducts(page); + await openProductFilters(page); await page.getByText('Mine', { exact: true }).click(); await expect(page).toHaveURL(FILTER_MODE_MINE_URL_PATTERN, { timeout: 3_000 }); // Click "Mine" again to toggle back to all diff --git a/app/e2e/terms.spec.ts b/app/e2e/terms.spec.ts new file mode 100644 index 000000000..54ce11fa2 --- /dev/null +++ b/app/e2e/terms.spec.ts @@ -0,0 +1,94 @@ +/** + * Contributor-terms prompt, against the real backend. + * + * Accounts created programmatically — which is every seeded account, and every + * account that predates acceptance tracking in production — record no terms + * acceptance, because stamping one would fabricate evidence of a licence grant + * nobody made. This is the flow that lets those accounts grant it. + * + * The acceptance test uses `bob`, who no other spec touches: accepting is a real, + * persistent write, so a shared account would leak state into whatever ran next. + * Every other authenticated spec suppresses the prompt via `suppressTermsPrompt` + * before it can render, which is why both tests here log in directly. + * + * It is also one-shot per database, by design: there is deliberately no endpoint + * that un-accepts, since retracting evidence of a licence grant is not something + * an API should offer. `just test-e2e-full-stack` recreates the volumes on every + * run, so this only bites when re-running `just test-e2e` by hand against a stack + * left up — reset it with `just _e2e-backend-down` first. + */ + +import { expect, test } from '@playwright/test'; +import { EMAIL, finishOnboardingIfVisible, PASSWORD } from './helpers'; + +const BOB = { login: 'bob@example.com', password: 'fake_password_2' }; +const DECLINE_IS_FREE = /Nothing changes if you decline/; + +test.setTimeout(60_000); + +test.describe('Contributor terms prompt', () => { + test('an account that never accepted is asked, and the grant persists', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Email or username').fill(BOB.login); + await page.getByLabel('Password', { exact: true }).fill(BOB.password); + await page.getByRole('button', { name: 'Sign in' }).click(); + + // The prompt is modal, so it arrives over whatever the login lands on. + const accept = page.getByRole('button', { name: 'Accept' }); + await expect( + accept, + 'bob has already accepted — this test needs a fresh database. Run ' + + '`just _e2e-backend-down` and bring the stack back up, or use ' + + '`just test-e2e-full-stack`, which recreates the volumes itself.', + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(DECLINE_IS_FREE)).toBeVisible(); + + await accept.click(); + await expect(accept).toBeHidden({ timeout: 15_000 }); + + // The grant is server-side, so it has to survive a reload rather than living + // in the dismissal store — that is the whole difference between accepting + // and pressing "Not now". + await page.reload(); + await expect(page.getByRole('button', { name: 'Accept' })).toBeHidden({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: 'Not now' })).toBeHidden(); + }); +}); + +test.describe('Dismissing the prompt', () => { + test('"Not now" clears the way and is not re-asked on reload', async ({ page }) => { + // A direct login, not loginAndReachProducts: that helper suppresses the + // prompt outright, which is what every other authenticated spec wants and + // exactly what this test must not do. + await page.goto('/login'); + await page.getByLabel('Email or username').fill(EMAIL); + await page.getByLabel('Password', { exact: true }).fill(PASSWORD); + await page.getByRole('button', { name: 'Sign in' }).click(); + + const notNow = page.getByRole('button', { name: 'Not now' }); + await expect(notNow).toBeVisible({ timeout: 30_000 }); + await notNow.click(); + await expect(notNow).toBeHidden({ timeout: 15_000 }); + + // The modal is genuinely gone, not merely hidden: interacting with what it + // covered is the only assertion that distinguishes the two, since + // `toBeVisible` does not account for occlusion. + await finishOnboardingIfVisible(page); + const search = page.getByPlaceholder('Search products'); + await expect(search).toBeVisible({ timeout: 15_000 }); + await search.fill('drill'); + await expect(search).toHaveValue('drill'); + + // Session-scoped: a reload must not re-ask. Being re-prompted on every + // refresh is nagging rather than asking, and it blocked every authenticated + // spec that navigates with a full page load. + await page.reload(); + await expect(page.getByPlaceholder('Search products')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: 'Not now' })).toBeHidden(); + + // Dismissal records nothing, so the account still owes acceptance: the + // account screen keeps offering the way back. + await page.goto('/account'); + await expect(page.getByText('Contributor terms')).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/app/e2e/web-chrome.spec.ts b/app/e2e/web-chrome.spec.ts new file mode 100644 index 000000000..a13255009 --- /dev/null +++ b/app/e2e/web-chrome.spec.ts @@ -0,0 +1,92 @@ +/** + * Desktop web chrome E2E tests: the persistent top nav (src/components/base/ + * TopNav.tsx) shown at >=lg, and its handoff with the native stack header it + * replaces for the screens it covers (src/app/_layout.tsx's hideForTopNav). + */ + +import { expect, test } from '@playwright/test'; +import { dismissProductsInfoCard, loginAndGoToProfile, reachProductsPage } from './helpers'; + +test.setTimeout(60_000); + +// The wordmark image (accessibilityLabel="Relab") is rendered by both the +// TopNav brand pressable and the native stack header's headerTitle — never +// both at once for a TopNav-covered screen. A count of 1 is the regression +// net for hideForTopNav failing to apply. +const WORDMARK_IMAGE_NAME = /Relab/; +// HeaderRightPill's accessible name, from either TopNav or the stack header. +const HEADER_PILL_NAME = /^(Sign in|Account: .+)$/; +const CAMERAS_URL_PATTERN = /cameras/; + +test.describe('Top nav (>=lg)', () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test('renders Products, marks the active destination, and hides Cameras until RPi camera is enabled', async ({ + page, + }) => { + await loginAndGoToProfile(page); + + // The RPi setting is server-side on the shared e2e-admin account, so a + // prior run (or this test's own leftover state) can start it enabled. + // Force it off first so "hidden by default" below is deterministic. + const rpiSwitch = page.getByLabel('RPi Camera'); + await expect(rpiSwitch).toBeVisible(); + if (await rpiSwitch.isChecked()) { + await rpiSwitch.click(); + await expect(rpiSwitch).not.toBeChecked({ timeout: 10_000 }); + } + + // The brand pressable is the TopNav-only landmark (the stack header's + // wordmark has no such accessible name), so its presence proves the top + // bar itself rendered. + await expect(page.getByLabel('Relab, go to products')).toBeVisible(); + await expect(page.getByRole('img', { name: WORDMARK_IMAGE_NAME })).toHaveCount(1); + await expect(page.getByRole('button', { name: 'Products', exact: true })).toBeVisible(); + + // Cameras is gated on the RPi camera integration setting (Integrations + // section of the account screen) and is hidden by default. + await expect(page.getByRole('button', { name: 'Cameras', exact: true })).not.toBeVisible(); + + // Enable RPi camera, then confirm the nav link appears and works. + await rpiSwitch.click(); + await expect(rpiSwitch).toBeChecked({ timeout: 10_000 }); + + await page.goto('/products'); + await dismissProductsInfoCard(page); + await expect(page.getByRole('button', { name: 'Products, current page' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Cameras', exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Cameras', exact: true }).click(); + await expect(page).toHaveURL(CAMERAS_URL_PATTERN, { timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Cameras, current page' })).toBeVisible(); + + // Leave the shared account clean for other tests/runs. + await page.goto('/account'); + await expect(rpiSwitch).toBeVisible(); + await rpiSwitch.click(); + await expect(rpiSwitch).not.toBeChecked({ timeout: 10_000 }); + }); + + test('does not duplicate the stack header on a screen the top nav covers', async ({ page }) => { + await reachProductsPage(page); + + // HeaderRightPill is rendered once by TopNav and once (conditionally) by + // the products stack header; hideForTopNav should keep exactly one alive. + await expect(page.getByRole('button', { name: HEADER_PILL_NAME })).toHaveCount(1); + await expect(page.getByRole('img', { name: WORDMARK_IMAGE_NAME })).toHaveCount(1); + }); +}); + +test.describe('Top nav (phone)', () => { + test.use({ viewport: { width: 375, height: 800 } }); + + test('top bar is absent; the stack header renders instead', async ({ page }) => { + await reachProductsPage(page); + + await expect(page.getByLabel('Relab, go to products')).not.toBeVisible(); + // The stack header still shows the wordmark and the header pill — exactly + // once, from the stack header alone (TopNav renders null below lg). + await expect(page.getByRole('img', { name: WORDMARK_IMAGE_NAME })).toHaveCount(1); + await expect(page.getByRole('button', { name: HEADER_PILL_NAME })).toHaveCount(1); + }); +}); diff --git a/app/eslint.config.mjs b/app/eslint.config.mjs new file mode 100644 index 000000000..c1701b9e5 --- /dev/null +++ b/app/eslint.config.mjs @@ -0,0 +1,82 @@ +import * as tsParser from '@typescript-eslint/parser'; +import { defineConfig } from 'eslint/config'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactNativeA11y from 'eslint-plugin-react-native-a11y'; +import reactRefresh from 'eslint-plugin-react-refresh'; + +const reactHooksErrors = Object.fromEntries( + Object.keys(reactHooks.configs.flat['recommended-latest'].rules).map((ruleName) => [ + ruleName, + 'error', + ]), +); + +// High-signal RN accessibility rules — catch malformed accessibility props on +// native primitives that Biome's DOM-oriented a11y rules can't see. This runs +// in the existing lint lane (every PR), covering the app's a11y gap that the +// axe-on-web e2e only reaches post-merge. The codebase already passes these, +// so they gate regressions at zero churn. +// +// Deliberately omitted: +// - Legacy pre-RN-0.57 rules (accessibility-traits/states/component-type): +// never fire on modern code. +// - has-accessibility-hint / has-accessibility-props: prescriptive, flood +// without catching real defects. +// - has-valid-accessibility-descriptors: currently flags 38 interactive +// elements missing a role/label. Real a11y debt worth a dedicated pass — +// enabling it here would either block the gate or force an unreviewed +// mass-edit. NOTE: turn on once those elements are labelled. +// - has-valid-accessibility-ignores-invert-colors: 5 images; minor iOS +// Smart-Invert nicety, low priority for this app. NOTE: revisit with the +// descriptors pass above. +const reactNativeA11yRules = { + 'react-native-a11y/has-valid-accessibility-role': 'error', + 'react-native-a11y/has-valid-accessibility-state': 'error', + 'react-native-a11y/has-valid-accessibility-value': 'error', + 'react-native-a11y/has-valid-accessibility-actions': 'error', + 'react-native-a11y/has-valid-accessibility-live-region': 'error', + 'react-native-a11y/has-valid-important-for-accessibility': 'error', + 'react-native-a11y/no-nested-touchables': 'error', +}; + +export default defineConfig([ + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + parser: tsParser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-native-a11y': reactNativeA11y, + }, + rules: { ...reactHooksErrors, ...reactNativeA11yRules }, + }, + { + files: ['src/**/*.{tsx,jsx}'], + plugins: { + 'react-refresh': reactRefresh, + }, + rules: { + 'react-refresh/only-export-components': 'error', + }, + }, + { + // Vendored react-native-reusables (shadcn-style) primitives export cva variant + // constants alongside their component by design; not hand-refactored. + files: ['src/components/base/ui/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + { + ignores: [ + '.expo/**', + 'coverage/**', + 'dist/**', + 'node_modules/**', + 'src/**/__tests__/**', + 'src/app/**/__tests__/**', + 'src/assets/data/*.json', + ], + }, +]); diff --git a/app/global.css b/app/global.css new file mode 100644 index 000000000..0da2e72bf --- /dev/null +++ b/app/global.css @@ -0,0 +1,22 @@ +@import "tailwindcss"; +@import "uniwind"; +@import "./src/theme/brand.generated.css"; + +@source "./src"; + +@theme { + --breakpoint-md: 768px; + --breakpoint-lg: 1024px; +} + +/* NOTE: the vendored RNR/shadcn primitives use `accent` as their NEUTRAL + * hover/pressed state-layer, but the brand `--accent` is manila, which + * assets/DESIGN.md reserves for text — never a fill or an interaction state. + * The palette now emits a neutral `--state` pair for that role; map the class + * names the primitives use onto it. Values come from the generator only. */ +@theme inline { + --color-accent: var(--state); + --color-accent-foreground: var(--state-foreground); + --color-manila: var(--accent); + --color-manila-foreground: var(--accent-foreground); +} diff --git a/app/jest.config.cjs b/app/jest.config.cjs new file mode 100644 index 000000000..f37d2d0be --- /dev/null +++ b/app/jest.config.cjs @@ -0,0 +1,80 @@ +// jest-expo exposes its preset as CommonJS. +const expoPreset = require('jest-expo/jest-preset'); + +// Shared base for both test projects, spread into each project below so +// transform, module resolution, and coverage collection apply identically to +// the unit and integration lanes. +const base = { + ...expoPreset, + rootDir: __dirname, + fakeTimers: { + enableGlobally: true, + doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], + }, + testPathIgnorePatterns: ['/node_modules/', '/e2e/'], + setupFiles: [...(expoPreset.setupFiles ?? []), '/config/setup.node.ts'], + transformIgnorePatterns: [ + 'node_modules/(?!(?:.pnpm/.*?/node_modules/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|standard-navigation|msw|@mswjs|@open-draft/.*|until-async|rettime|@rn-primitives/.*|lucide-react-native|uniwind))', + ], + transform: { + ...expoPreset.transform, + '^.+\\.mjs$': expoPreset.transform['\\.[jt]sx?$'], + }, + moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node', 'mjs'], + moduleNameMapper: { + ...expoPreset.moduleNameMapper, + '^@/(.*)$': '/src/$1', + '^msw/node$': '/node_modules/msw/lib/node/index.js', + // Jest has no CSS loader; NativeWind applies global.css via Metro/Babel at + // build time, not in tests, so map .css imports to a no-op stub. + '\\.css$': '/config/cssMock.js', + }, +}; + +// Coverage and maxWorkers are global (root-only) concerns in multi-project mode — +// Jest ignores these keys if set per project — so they live here, not in `base`. +// Run a single lane with `jest --selectProjects unit` (or integration). +module.exports = { + rootDir: __dirname, + testTimeout: 15_000, + watchman: false, + // CI runners are dedicated, so use every core; locally leave headroom for the dev's machine. + maxWorkers: process.env.CI ? '100%' : '50%', + // In CI also emit JUnit XML for Codecov Test Analytics (flaky/slow-test tracking). + reporters: process.env.CI + ? ['default', ['jest-junit', { outputDirectory: '', outputName: 'junit.xml' }]] + : ['default'], + coverageProvider: 'v8', + coverageDirectory: 'coverage', + coverageReporters: ['lcov', 'text'], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/__tests__/**', + '!src/assets/**', + '!src/types/**', + '!src/test-utils/**', + '!**/coverage/**', + '!**/node_modules/**', + '!src/app/_layout.tsx', + '!src/components/base/SVGCube.tsx', + '!src/components/product/ProductCardSkeleton.tsx', + ], + coverageThreshold: { + global: { statements: 70, branches: 65, functions: 65 }, + }, + projects: [ + { + ...base, + displayName: 'unit', + setupFilesAfterEnv: ['/config/setup.shared.ts', '/config/setup.unit.ts'], + testMatch: ['**/*.test.[jt]s?(x)'], + testPathIgnorePatterns: [...base.testPathIgnorePatterns, '\\.integration\\.test\\.[jt]sx?$'], + }, + { + ...base, + displayName: 'integration', + setupFilesAfterEnv: ['/config/setup.shared.ts'], + testMatch: ['**/*.integration.test.[jt]s?(x)'], + }, + ], +}; diff --git a/app/justfile b/app/justfile new file mode 100644 index 000000000..80fee29ea --- /dev/null +++ b/app/justfile @@ -0,0 +1,124 @@ +# Frontend App Tasks + +import '../playwright.just' + +# Show available recipes +default: + @just --list + +# Install dependencies +install: + pnpm install --frozen-lockfile + +# Regenerate the workspace lockfile without installing dependencies +lockfile: + pnpm install --lockfile-only --ignore-scripts + +# Update Expo-managed dependencies to versions compatible with the current SDK +update: + pnpm exec expo install --fix + pnpm update -D + pnpm dedupe + pnpm dlx expo-doctor + +# Run lint checks +lint: + pnpm run lint + +# Run static type checks +typecheck: + pnpm run typecheck + +# Run the app quality gate +check: lint typecheck codegen-check + @echo "✅ Frontend-app checks passed" + +# Auto-format code +format: + pnpm run format + +# Auto-fix lint issues and format code +fix: + pnpm run fix + +# Run tests (unit + integration) +test: + pnpm run test + +# Run the fast unit test suite +test-unit: + pnpm run test:unit + +# Run the slower app-level Jest integration test suite +test-integration: + pnpm run test:integration + +# Run the app CI test suite +test-ci: + pnpm run test:ci + +# Run the full app CI pipeline locally +ci: check test-ci + @echo "✅ Frontend-app CI pipeline passed" + +# Start dev server +dev PORT="8011": + pnpm exec expo start --port {{ quote(PORT) }} --web + +# Start dev server with a cleared Metro cache — fixes stale-bundle errors +# (an edited module still serving its old code after a reload). +dev-clean PORT="8011": + pnpm exec expo start --port {{ quote(PORT) }} --web --clear + +# Scan dependencies for known vulnerabilities +audit: + pnpm run audit + +# Static React Compiler coverage report — which components compile vs bail out. +# `just lint` already flags bailouts inline; this gives the coverage summary. +compiler-health: + pnpm dlx react-compiler-healthcheck + +# Serve the production web export (React Compiler ON, as shipped) for profiling. +# Open the served URL with the React DevTools Profiler ("Highlight updates when +# components render" + record) to see which components re-render on real flows. +# Prefer a zero-config overlay? `pnpm dlx react-scan@latest ` against it. +# Compare against dev (compiler OFF) via `just dev`. +profile: build-web + pnpm exec expo serve + +# Port the E2E backend is mapped to on the host (must match compose.e2e.yaml / E2E_BACKEND_PORT) +e2e_backend_port := env("E2E_BACKEND_PORT", "18010") +e2e_api_url := "http://localhost:" + e2e_backend_port + +# Build Expo web app for E2E testing (exports static SPA to dist/) +# Only the API URL is required for these tests; the public website URL falls back in-app. +build-web: + @printf '%s\n' {{ quote("Building Expo web for E2E with EXPO_PUBLIC_API_URL=" + e2e_api_url) }} + EXPO_PUBLIC_API_URL={{ quote(e2e_api_url) }} pnpm run build:web + +# The suite runs against the static export in dist/, so build-web is a dependency: +# a stale build silently tests old code. +# +# Run full-stack E2E tests (rebuilds dist/; requires compose.e2e.yaml running) +test-e2e: build-web _browsers + pnpm run test:e2e {{ if env("CI", "") != "" { "--project=chromium" } else { "" } }} + +# Run the full cross-browser E2E matrix (see ../playwright.just for INSTALL=force) +test-e2e-cross-browser: build-web _browsers + pnpm run test:e2e:cross-browser + +# Clean caches and build artifacts +clean: + rm -rf .expo .expo-shared coverage dist web-build playwright-report test-results + +# Regenerate TypeScript API types from the committed OpenAPI schema (offline). +# The schema itself is produced by `just backend/openapi` (src/types/openapi.json). +codegen: + pnpm run codegen + @echo "✅ Generated src/types/api.generated.ts (redacted)" + +# Fail if committed API types are stale vs the committed OpenAPI schema (runs in `check`) +codegen-check: + pnpm run codegen + git diff --exit-code src/types/api.generated.ts diff --git a/app/metro.config.js b/app/metro.config.js new file mode 100644 index 000000000..4e2c2347d --- /dev/null +++ b/app/metro.config.js @@ -0,0 +1,12 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const { withUniwindConfig } = require('uniwind/metro'); + +const config = getDefaultConfig(__dirname); + +// Uniwind defaults `rem` to 16, which is what Tailwind's scale assumes, so no +// `polyfills.rem` override is needed here (NativeWind defaulted to 14 and had to be +// pinned back to 16). +module.exports = withUniwindConfig(config, { + cssEntryFile: './global.css', + dtsFile: './src/uniwind-types.d.ts', +}); diff --git a/app/package.json b/app/package.json new file mode 100644 index 000000000..45deecf56 --- /dev/null +++ b/app/package.json @@ -0,0 +1,121 @@ +{ + "name": "relab-app", + "main": "expo-router/entry", + "version": "0.2.0", + "scripts": { + "dev": "[ \"$(uname)\" = Linux ] && export ELECTRON_DISABLE_SANDBOX=1; BROWSER=none expo start --web --port 8011", + "start": "[ \"$(uname)\" = Linux ] && export ELECTRON_DISABLE_SANDBOX=1; expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "[ \"$(uname)\" = Linux ] && export ELECTRON_DISABLE_SANDBOX=1; BROWSER=none expo start --web --port 8011", + "check": "pnpm run lint && pnpm run typecheck", + "lint": "biome check . && pnpm run lint:react", + "lint:react": "eslint --cache --max-warnings=0 src --ext .ts,.tsx", + "typecheck": "tsc --noEmit", + "format": "biome format --write .", + "fix": "biome check --write . && eslint --cache --fix src --ext .ts,.tsx", + "test": "jest --config jest.config.cjs", + "test:unit": "jest --config jest.config.cjs --selectProjects unit", + "test:integration": "jest --config jest.config.cjs --selectProjects integration", + "test:watch": "jest --config jest.config.cjs --watchAll", + "test:ci": "jest --config jest.config.cjs --ci --coverage", + "build": "pnpm run build:web", + "build:web": "expo export -p web -c", + "profile:compiler:web": "[ \"$(uname)\" = Linux ] && export ELECTRON_DISABLE_SANDBOX=1; ENVIRONMENT=production expo start --web --port 8011 --clear", + "audit": "pnpm audit --prod --audit-level moderate", + "test:e2e": "playwright test", + "test:e2e:cross-browser": "playwright test --project=chromium --project=firefox --project=webkit --project=mobile-chrome --project=mobile-safari", + "test:e2e:ui": "playwright test --ui", + "ci": "pnpm run check && pnpm run test:ci", + "codegen:api": "openapi-typescript src/types/openapi.json -o src/types/api.generated.ts", + "codegen:api:redact": "pnpm run codegen:api && node scripts/redact_api.mjs", + "codegen": "pnpm run codegen:api:redact && biome check --write src/types/api.generated.ts" + }, + "dependencies": { + "@expo/metro-runtime": "~57.0.9", + "@hookform/resolvers": "^5.2.2", + "@react-native-async-storage/async-storage": "2.2.0", + "@rn-primitives/separator": "^1.5.2", + "@rn-primitives/slot": "^1.5.2", + "@rn-primitives/switch": "^1.5.2", + "@rn-primitives/toggle": "^1.5.2", + "@rn-primitives/toggle-group": "^1.5.2", + "@tanstack/query-async-storage-persister": "^5.101.4", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-persist-client": "^5.101.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "expo": "~57.0.12", + "expo-asset": "~57.0.10", + "expo-auth-session": "~57.0.6", + "expo-blur": "~57.0.2", + "expo-clipboard": "~57.0.1", + "expo-constants": "~57.0.10", + "expo-document-picker": "~57.0.1", + "expo-font": "~57.0.1", + "expo-haptics": "~57.0.1", + "expo-image": "~57.0.2", + "expo-image-manipulator": "~57.0.9", + "expo-image-picker": "~57.0.9", + "expo-linear-gradient": "~57.0.1", + "expo-linking": "~57.0.5", + "expo-router": "~57.0.12", + "expo-secure-store": "~57.0.1", + "expo-splash-screen": "~57.0.6", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.2", + "expo-video": "~57.0.2", + "expo-web-browser": "~57.0.2", + "hls.js": "^1.6.16", + "lucide-react-native": "^1.24.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-hook-form": "^7.73.1", + "react-native": "0.86.2", + "react-native-gesture-handler": "~2.32.0", + "react-native-keyboard-controller": "1.21.9", + "react-native-qrcode-svg": "^6.3.21", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.2", + "react-native-svg": "15.15.4", + "react-native-web": "~0.21.2", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.1", + "tailwind-merge": "^3.6.0", + "uniwind": "^1.10.1", + "use-debounce": "^10.1.1", + "zod": "^4.3.6", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@axe-core/playwright": "catalog:", + "@babel/core": "^7.29.7", + "@babel/runtime": "^7.29.7", + "@biomejs/biome": "catalog:", + "@jest/globals": "~29.7.0", + "@playwright/test": "catalog:", + "@react-native/jest-preset": "^0.86.2", + "@tailwindcss/postcss": "4.3.2", + "@testing-library/react-native": "^13.3.3", + "@types/jest": "^29.5.14", + "@types/react": "~19.2.18", + "@typescript-eslint/parser": "^8.66.0", + "babel-plugin-react-compiler": "^1.0.0", + "baseline-browser-mapping": "^2.11.13", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-native-a11y": "^3.5.1", + "eslint-plugin-react-refresh": "^0.5.3", + "jest": "~29.7.0", + "jest-expo": "~57.0.4", + "jest-junit": "^17.0.0", + "msw": "^2.15.0", + "openapi-typescript": "^7.13.0", + "postcss": "^8.5.26", + "serve": "^14.2.6", + "tailwindcss": "catalog:", + "typescript": "catalog:" + }, + "private": true +} diff --git a/app/playwright.config.ts b/app/playwright.config.ts new file mode 100644 index 000000000..fa2f9eaf0 --- /dev/null +++ b/app/playwright.config.ts @@ -0,0 +1,72 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Full-stack E2E configuration for the Expo web app. + * + * Assumes the Docker backend stack (compose.e2e.yaml) is already running and + * the Expo web build has already been exported to dist/ before this runs. + * + * Preferred local usage: + * just test-e2e-full-stack + * + * CI: see the e2e-full-stack job in .github/workflows/validate.yml + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: 2, + workers: process.env.CI ? 1 : 4, + reporter: process.env.CI ? 'github' : 'list', + use: { + // Must share a host with the baked EXPO_PUBLIC_API_URL (localhost) so the + // SameSite=Lax session cookies are treated as first-party; 127.0.0.1 vs + // localhost is cross-site and the browser drops the auth cookie. + baseURL: process.env.BASE_URL ?? 'http://localhost:18011', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + grep: /@cross-browser/, + }, + // WebKit refuses __Host-/Secure cookies over http://localhost (Chromium and + // Firefox treat localhost as a secure context; Safari does not), so the + // session never persists and auth-gated flows can't run. Exclude @auth tests + // on the two WebKit-backed projects. CI runs this suite on chromium only. + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + grep: /@cross-browser/, + grepInvert: /@auth/, + }, + { + name: 'mobile-chrome', + use: { ...devices['Pixel 5'] }, + grep: /@cross-browser/, + }, + { + name: 'mobile-safari', + use: { ...devices['iPhone 13'] }, + grep: /@cross-browser/, + grepInvert: /@auth/, + }, + ], + // Serves the pre-built Expo web dist/ unless BASE_URL is already set. + // --single is load-bearing: app.json sets web.output "single", so dist/ holds + // one index.html and every route is resolved client-side. Without the SPA + // fallback every deep link (/products, /login, …) 404s here, while prod is + // fine because Caddy does `try_files {path} /index.html`. + webServer: process.env.BASE_URL + ? undefined + : { + command: 'pnpm exec serve dist -l 18011 --single --no-clipboard', + url: 'http://localhost:18011', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/app/plugins/withLocalNetworkSecurity.js b/app/plugins/withLocalNetworkSecurity.js new file mode 100644 index 000000000..20cab20aa --- /dev/null +++ b/app/plugins/withLocalNetworkSecurity.js @@ -0,0 +1,57 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { withDangerousMod } = require('expo/config-plugins'); + +/** + * Scoped cleartext for the RPi camera's local path. + * + * Release Android blocks http:// outright, but a paired camera is reached + * directly at `USB_GADGET_DEFAULT` (192.168.7.1) or on the LAN over plain http. + * This permits cleartext for those hosts only — never app-wide + * (`android:usesCleartextTraffic`), which would expose every request. + * + * NOTE: Android matches hostnames, not CIDR ranges, so a private range cannot be + * expressed. Listed: the fixed USB-gadget address, loopback, and mDNS `*.local` + * names. A camera reached by a manually typed numeric LAN IP still needs its + * address added below; the upgrade path is mDNS naming on the Pi. + * + * The manifest attribute is written into the release variant only: it overrides + * `usesCleartextTraffic`, and the debug variant needs cleartext for Metro. + */ +const NETWORK_SECURITY_CONFIG = ` + + + 192.168.7.1 + localhost + 127.0.0.1 + local + + +`; + +const RELEASE_MANIFEST = ` + + + +`; + +async function writeFile(filePath, contents) { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, contents, 'utf8'); +} + +module.exports = function withLocalNetworkSecurity(config) { + return withDangerousMod(config, [ + 'android', + async (config) => { + const main = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main'); + const release = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'release'); + await writeFile( + path.join(main, 'res', 'xml', 'network_security_config.xml'), + NETWORK_SECURITY_CONFIG, + ); + await writeFile(path.join(release, 'AndroidManifest.xml'), RELEASE_MANIFEST); + return config; + }, + ]); +}; diff --git a/app/postcss.config.mjs b/app/postcss.config.mjs new file mode 100644 index 000000000..a34a3d560 --- /dev/null +++ b/app/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/frontend-app/src/public/robots.txt b/app/public/robots.txt similarity index 100% rename from frontend-app/src/public/robots.txt rename to app/public/robots.txt diff --git a/app/scripts/redact_api.mjs b/app/scripts/redact_api.mjs new file mode 100755 index 000000000..a1725a67c --- /dev/null +++ b/app/scripts/redact_api.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/* + redact_api.mjs + + Purpose: remove any example JWTs embedded by the OpenAPI -> TypeScript + generator from the generated frontend file `src/types/api.generated.ts`. + + Why: some OpenAPI examples may contain real-looking tokens that trigger + gitleaks or other secret-scanning tools. This script performs a simple + regex replace to substitute such `access_token` example values with + the safe placeholder `` so generated files are safe to + commit and publish. + + Behavior: + - Locates `api.generated.ts` relative to the app subrepo. + - Replaces any value matching the pattern used for JWT-like strings + in `"access_token": "..."` with `"access_token": ""`. + - Only writes the file if a change was made. + + Usage: + # from app + pnpm run codegen:api:redact + # or directly + node scripts/redact_api.mjs +*/ +const main = () => { + const file = resolve('src', 'types', 'api.generated.ts'); + // Read directly instead of existsSync + read: no check-then-use race, and a + // missing file surfaces as ENOENT here. + let src; + try { + src = readFileSync(file, 'utf8'); + } catch { + console.error('api.generated.ts not found. Run this from app.'); + process.exit(2); + } + const out = src.replace(/("access_token":\s*")[A-Za-z0-9._-]+(")/g, '$1$2'); + if (out === src) { + console.log('No JWT examples found to redact.'); + process.exit(0); + } + writeFileSync(file, out, 'utf8'); + console.log('Redacted JWT examples in', file); +}; + +main(); diff --git a/app/src/__tests__/config.test.ts b/app/src/__tests__/config.test.ts new file mode 100644 index 000000000..38d10762d --- /dev/null +++ b/app/src/__tests__/config.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from '@jest/globals'; + +import { normalizeOptionalHttpUrl, normalizeRequiredHttpUrl } from '@/config'; + +describe('URL config helpers', () => { + it('normalizes required http URLs and rejects executable schemes', () => { + expect(normalizeRequiredHttpUrl('https://example.com/path/', 'EXAMPLE_URL')).toBe( + 'https://example.com/path/', + ); + expect(() => normalizeRequiredHttpUrl('javascript:alert(1)', 'EXAMPLE_URL')).toThrow( + 'EXAMPLE_URL must be an http(s) URL', + ); + }); + + it('keeps optional URLs empty but rejects non-http values when set', () => { + expect(normalizeOptionalHttpUrl(undefined, 'OPTIONAL_URL')).toBe(''); + expect(normalizeOptionalHttpUrl(' ', 'OPTIONAL_URL')).toBe(''); + expect(normalizeOptionalHttpUrl('https://docs.example.com', 'OPTIONAL_URL')).toBe( + 'https://docs.example.com/', + ); + expect(() => + normalizeOptionalHttpUrl('data:text/html,', 'OPTIONAL_URL'), + ).toThrow('OPTIONAL_URL must be an http(s) URL'); + }); +}); diff --git a/app/src/__tests__/mobile-security-config.test.ts b/app/src/__tests__/mobile-security-config.test.ts new file mode 100644 index 000000000..9ad5b24cd --- /dev/null +++ b/app/src/__tests__/mobile-security-config.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +type ExpoPlugin = string | [string, Record]; +const HTTPS_API_URL_PATTERN = /^API_PUBLIC_URL='?https:\/\//m; +const HTTPS_WEBSITE_URL_PATTERN = /^SITE_PUBLIC_URL='?https:\/\//m; +const HTTPS_DOCS_URL_PATTERN = /^DOCS_PUBLIC_URL='?https:\/\//m; + +const appConfig = JSON.parse(readFileSync(resolve(__dirname, '../../app.json'), 'utf8')) as { + expo: { + android?: Record; + ios?: { config?: Record }; + plugins?: ExpoPlugin[]; + }; +}; + +function pluginConfig(name: string): Record { + const plugin = appConfig.expo.plugins?.find((entry) => + Array.isArray(entry) ? entry[0] === name : entry === name, + ); + if (!Array.isArray(plugin)) return {}; + return plugin[1]; +} + +function readRootEnv(name: string): string { + return readFileSync(resolve(__dirname, `../../../${name}`), 'utf8'); +} + +describe('mobile app security configuration', () => { + it('declares least-privilege native permissions for image capture and selection', () => { + expect(appConfig.expo.android?.allowBackup).toBe(false); + expect(appConfig.expo.ios?.config?.usesNonExemptEncryption).toBe(false); + + expect(pluginConfig('expo-secure-store')).toEqual({ configureAndroidBackup: true }); + expect(pluginConfig('expo-image-picker')).toMatchObject({ + cameraPermission: expect.stringContaining('product photos'), + photosPermission: expect.stringContaining('product photos'), + microphonePermission: false, + }); + }); + + it('keeps production-like public service URLs on HTTPS', () => { + for (const envName of ['deploy/env/prod.compose.env', 'deploy/env/staging.compose.env']) { + const env = readRootEnv(envName); + + expect(env).toMatch(HTTPS_API_URL_PATTERN); + expect(env).toMatch(HTTPS_WEBSITE_URL_PATTERN); + expect(env).toMatch(HTTPS_DOCS_URL_PATTERN); + } + }); +}); diff --git a/app/src/__tests__/security-headers.test.ts b/app/src/__tests__/security-headers.test.ts new file mode 100644 index 000000000..9f9004910 --- /dev/null +++ b/app/src/__tests__/security-headers.test.ts @@ -0,0 +1,154 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const caddyfile = readFileSync(resolve(__dirname, '../../Caddyfile'), 'utf8'); +const HSTS_POLICY = 'max-age=63072000; includeSubDomains'; +const HSTS_PATTERN = /^\s*Strict-Transport-Security\s+"([^"]+)"/m; +const REFERRER_POLICY_PATTERN = /^\s*Referrer-Policy\s+"([^"]+)"/m; +const CONTENT_TYPE_OPTIONS_PATTERN = /^\s*X-Content-Type-Options\s+"([^"]+)"/m; +const PERMISSIONS_POLICY_PATTERN = /^\s*Permissions-Policy\s+"([^"]+)"/m; +const ENFORCED_CSP_PATTERN = /^\s*Content-Security-Policy\s+"([^"]+)"/m; +const REPORT_ONLY_CSP_PATTERN = /^\s*Content-Security-Policy-Report-Only\s+"([^"]+)"/m; +const DANGEROUS_METHODS_PATTERN = + /@dangerous_methods\s+method\s+([^\n]+)\s+handle\s+@dangerous_methods\s+\{(?[\s\S]*?)\n\s*\}/m; +const METHOD_SPLIT_PATTERN = /\s+/; +const METHOD_ALLOW_HEADER_PATTERN = + /header\s+Allow\s+"GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD"/; +const METHOD_405_RESPONSE_PATTERN = /respond\s+"[^"]+"\s+405/; +const RESET_PASSWORD_REFERRER_POLICY_PATTERN = + /@reset_password_route\s+path\s+\/reset-password\*\s+handle\s+@reset_password_route\s+\{\s+header\s+Referrer-Policy/s; +const SENSITIVE_AUTH_ROUTE_PATTERN = + /@sensitive_auth_routes\s+path\s+([^\n]+)\s+handle\s+@sensitive_auth_routes\s+\{(?[\s\S]*?)\n\s*\}/m; + +function enforcedCsp() { + const match = caddyfile.match(ENFORCED_CSP_PATTERN); + if (!match) { + throw new Error('Missing enforced Content-Security-Policy header'); + } + return match[1]; +} + +function reportOnlyCsp() { + const match = caddyfile.match(REPORT_ONLY_CSP_PATTERN); + if (!match) { + throw new Error('Missing report-only Content-Security-Policy header'); + } + return match[1]; +} + +function hsts() { + const match = caddyfile.match(HSTS_PATTERN); + if (!match) { + throw new Error('Missing Strict-Transport-Security header'); + } + return match[1]; +} + +function referrerPolicy() { + const match = caddyfile.match(REFERRER_POLICY_PATTERN); + if (!match) { + throw new Error('Missing Referrer-Policy header'); + } + return match[1]; +} + +function contentTypeOptions() { + const match = caddyfile.match(CONTENT_TYPE_OPTIONS_PATTERN); + if (!match) { + throw new Error('Missing X-Content-Type-Options header'); + } + return match[1]; +} + +function permissionsPolicy() { + const match = caddyfile.match(PERMISSIONS_POLICY_PATTERN); + if (!match) { + throw new Error('Missing Permissions-Policy header'); + } + return match[1]; +} + +function dangerousMethodPolicy() { + const match = caddyfile.match(DANGEROUS_METHODS_PATTERN); + if (!match?.groups?.block) { + throw new Error('Missing dangerous method policy block'); + } + return { + block: match.groups.block, + methods: match[1].trim().split(METHOD_SPLIT_PATTERN), + }; +} + +function sensitiveAuthRoutePolicy() { + const match = caddyfile.match(SENSITIVE_AUTH_ROUTE_PATTERN); + if (!match?.groups?.block) { + throw new Error('Missing sensitive auth route policy block'); + } + return { + block: match.groups.block, + paths: match[1].trim().split(METHOD_SPLIT_PATTERN), + }; +} + +describe('Caddy security headers', () => { + it('blocks dangerous unsupported HTTP methods', () => { + const policy = dangerousMethodPolicy(); + + expect(policy.methods).toEqual(['TRACE', 'TRACK', 'CONNECT']); + expect(policy.block).toMatch(METHOD_ALLOW_HEADER_PATTERN); + expect(policy.block).toMatch(METHOD_405_RESPONSE_PATTERN); + }); + + it('sets the deployed OWASP HSTS policy', () => { + expect(hsts()).toBe(HSTS_POLICY); + }); + + it('sets the browser baseline headers recommended by OWASP', () => { + expect(contentTypeOptions()).toBe('nosniff'); + expect(referrerPolicy()).toBe('no-referrer'); + expect(caddyfile).toContain('Cross-Origin-Opener-Policy "same-origin"'); + expect(caddyfile).toContain('Cross-Origin-Resource-Policy "same-site"'); + }); + + it('allows only the browser capability the app intentionally uses', () => { + expect(permissionsPolicy()).toBe('camera=(self)'); + }); + + it('allows only the intended YouTube embed origin for frames', () => { + expect(enforcedCsp()).toContain('frame-src https://www.youtube-nocookie.com'); + }); + + it('keeps OWASP baseline CSP directives enforced', () => { + const policy = enforcedCsp(); + + expect(policy).toContain("frame-ancestors 'none'"); + expect(policy).toContain("object-src 'none'"); + expect(policy).toContain("base-uri 'none'"); + expect(policy).toContain("form-action 'self'"); + expect(policy).not.toContain('report-uri'); + }); + + it('observes a stricter script policy without unsafe eval', () => { + expect(reportOnlyCsp()).not.toContain("'unsafe-eval'"); + }); + + it('does not allow wildcard scripts or javascript URLs', () => { + expect(enforcedCsp()).not.toContain('script-src *'); + expect(reportOnlyCsp()).not.toContain('script-src *'); + expect(enforcedCsp()).not.toContain('javascript:'); + expect(reportOnlyCsp()).not.toContain('javascript:'); + }); + + it('uses the global no-referrer policy for password reset routes', () => { + expect(caddyfile).not.toMatch(RESET_PASSWORD_REFERRER_POLICY_PATTERN); + }); + + it('serves token-bearing auth routes without browser caching', () => { + const policy = sensitiveAuthRoutePolicy(); + + expect(policy.paths).toEqual(['/verify*', '/reset-password*']); + expect(policy.block).toContain('header Cache-Control "no-store"'); + expect(policy.block).toContain('try_files {path} /index.html'); + expect(policy.block).toContain('file_server'); + }); +}); diff --git a/frontend-app/src/__tests__/theme-regressions.test.ts b/app/src/__tests__/theme-regressions.test.ts similarity index 83% rename from frontend-app/src/__tests__/theme-regressions.test.ts rename to app/src/__tests__/theme-regressions.test.ts index e8574e698..8d09e3100 100644 --- a/frontend-app/src/__tests__/theme-regressions.test.ts +++ b/app/src/__tests__/theme-regressions.test.ts @@ -6,27 +6,27 @@ const SRC_ROOT = path.resolve(__dirname, '..'); const ASSET_THEME_IMPORT_PATTERN = /@\/assets\/themes\/(light|dark)/; const HARD_CODED_COLOR_PATTERN = /#[0-9A-Fa-f]{3,8}|rgba\(/; const MODERNIZED_FILES = [ - 'lib/router/styles.ts', - 'lib/router/background.ts', + 'utils/router/styles.ts', + 'utils/router/background.ts', 'components/auth/LoginSections.tsx', 'components/auth/NewAccountSections.tsx', 'components/cameras/CameraCard.tsx', 'components/cameras/TelemetryBadge.tsx', 'components/cameras/live-preview/styles.ts', - 'components/cameras/live-preview/shared.tsx', + 'components/cameras/live-preview/previewOverlays.tsx', 'components/cameras/screen/styles.ts', - 'components/common/ActiveStreamBanner.tsx', - 'components/common/StreamingContent.tsx', + 'components/cameras/ActiveStreamBanner.tsx', + 'components/cameras/StreamingContent.tsx', 'components/product/gallery/styles.ts', 'components/product/gallery/ProductImageGalleryContent.tsx', 'components/product/gallery/ProductImageEmptyEditState.tsx', 'components/product/gallery/ProductImageThumbnails.tsx', 'components/product/gallery/ProductImageLightbox.tsx', 'components/product/ProductDelete.tsx', - 'components/product/ProductMetaData.tsx', - 'components/profile/sections/styles.ts', + 'components/product/detail/ProductMetaData.tsx', + 'components/profile/styles.ts', 'app/(auth)/onboarding.tsx', - 'app/cameras/add.tsx', + 'app/(tabs)/(cameras)/cameras/add.tsx', ].map((file) => path.join(SRC_ROOT, file)); describe('theme regressions', () => { diff --git a/frontend-app/src/__tests__/screens/forgot-password.integration.test.tsx b/app/src/app/(auth)/__tests__/forgot-password.integration.test.tsx similarity index 83% rename from frontend-app/src/__tests__/screens/forgot-password.integration.test.tsx rename to app/src/app/(auth)/__tests__/forgot-password.integration.test.tsx index 5e8c85744..9e41bbee9 100644 --- a/frontend-app/src/__tests__/screens/forgot-password.integration.test.tsx +++ b/app/src/app/(auth)/__tests__/forgot-password.integration.test.tsx @@ -17,7 +17,7 @@ jest.mock('@/services/api/client', () => ({ const FORGOT_PASSWORD_INSTRUCTIONS_PATTERN = /send you instructions to reset your password/i; const VALID_EMAIL_PATTERN = /valid email/i; const ACCOUNT_EXISTS_MESSAGE_PATTERN = /If an account exists with this email/i; -const TRY_AGAIN_LATER_PATTERN = /Please try again later/i; +const TRY_AGAIN_LATER_PATTERN = /Try again later/i; const mockedApiFetch = apiFetch as jest.MockedFunction; const mockBack = jest.fn(); @@ -36,11 +36,11 @@ function renderForgotPasswordScreen() { } async function submitForgotPasswordEmail(email: string) { - fireEvent.changeText(screen.getByTestId('text-input-flat'), email); + fireEvent.changeText(screen.getByLabelText('Email'), email); await waitFor(() => { - expect(screen.getAllByTestId('button')[0].props.accessibilityState.disabled).toBe(false); + expect(screen.getByText('Send reset link')).not.toBeDisabled(); }); - fireEvent.press(screen.getAllByTestId('button')[0]); + fireEvent.press(screen.getByText('Send reset link')); } beforeEach(() => { @@ -57,14 +57,14 @@ beforeEach(() => { describe('ForgotPasswordScreen rendering', () => { it('renders the forgot password form', () => { renderForgotPasswordScreen(); - expect(screen.getByText('Forgot Password')).toBeOnTheScreen(); - expect(screen.getAllByText('Send Reset Link')).not.toHaveLength(0); + expect(screen.getByText('Forgot password')).toBeOnTheScreen(); + expect(screen.getAllByText('Send reset link')).not.toHaveLength(0); expect(screen.getByText(FORGOT_PASSWORD_INSTRUCTIONS_PATTERN)).toBeOnTheScreen(); }); it('shows a validation error for an invalid email address', async () => { renderForgotPasswordScreen(); - fireEvent.changeText(screen.getByTestId('text-input-flat'), 'not-an-email'); + fireEvent.changeText(screen.getByLabelText('Email'), 'not-an-email'); await waitFor(() => { expect(screen.getByText(VALID_EMAIL_PATTERN)).toBeOnTheScreen(); @@ -129,16 +129,17 @@ describe('ForgotPasswordScreen navigation', () => { expect(mockReplace).toHaveBeenCalledWith('/login'); }); - it('allows navigating back to login from both states', async () => { + it('navigates to login from both states (not history back, which may be invalid)', async () => { renderForgotPasswordScreen(); - fireEvent.press(screen.getAllByTestId('button')[1]); - expect(mockBack).toHaveBeenCalledTimes(1); + fireEvent.press(screen.getByText('Back to login')); + expect(mockReplace).toHaveBeenCalledWith('/login'); + expect(mockBack).not.toHaveBeenCalled(); await submitForgotPasswordEmail('user@example.com'); await screen.findByText(ACCOUNT_EXISTS_MESSAGE_PATTERN); - fireEvent.press(screen.getByText('Back to Login')); - expect(mockPush).toHaveBeenCalledWith('/login'); + fireEvent.press(screen.getByText('Back to login')); + expect(mockReplace).toHaveBeenCalledWith('/login'); }); }); diff --git a/app/src/app/(auth)/__tests__/login.integration.test.tsx b/app/src/app/(auth)/__tests__/login.integration.test.tsx new file mode 100644 index 000000000..0793a286c --- /dev/null +++ b/app/src/app/(auth)/__tests__/login.integration.test.tsx @@ -0,0 +1,791 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { openAuthSessionAsync, WebBrowserResultType } from 'expo-web-browser'; +import Login from '@/app/(auth)/login'; +import { getToken, getUser, login, markWebSessionActive } from '@/services/api/auth/authentication'; +import { claimOAuthMfaHandoff, setPendingMfaLogin } from '@/services/api/auth/authMfa'; +import { + buildOAuthAuthorizeUrl, + fetchOAuthAuthorizationUrl, + openOAuthBrowserSession, +} from '@/services/api/oauthFlow'; +import { mockPlatform, mockUser, renderWithProviders, restorePlatform } from '@/test-utils/index'; + +const mockDialogApi = { + alert: jest.fn(), + input: jest.fn(), + toast: jest.fn(), +}; +const mockAuthRefetch = jest.fn<() => Promise>(); + +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(), + useRouter: jest.fn(), +})); + +jest.mock('@/context/auth', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('@/context/themeMode', () => ({ + useEffectiveColorScheme: () => 'light', +})); + +jest.mock('@/services/api/auth/authentication', () => ({ + login: jest.fn(), + getUser: jest.fn(), + getToken: jest.fn(), + hasWebSessionFlag: jest.fn().mockReturnValue(false), + markWebSessionActive: jest.fn(), +})); + +jest.mock('@/services/api/oauthFlow', () => ({ + ...jest.requireActual('@/services/api/oauthFlow'), + buildOAuthAuthorizeUrl: jest.fn(), + fetchOAuthAuthorizationUrl: jest.fn(), + openOAuthBrowserSession: jest.fn(), +})); + +jest.mock('@/services/api/auth/authMfa', () => ({ + ...jest.requireActual( + '@/services/api/auth/authMfa', + ), + claimOAuthMfaHandoff: jest.fn(), + setPendingMfaLogin: jest.fn(), +})); + +jest.mock('expo-web-browser', () => ({ + maybeCompleteAuthSession: jest.fn(), + openAuthSessionAsync: jest.fn(), + WebBrowserResultType: { + CANCEL: 'cancel', + DISMISS: 'dismiss', + OPENED: 'opened', + LOCKED: 'locked', + }, +})); + +jest.mock('expo-linking', () => ({ + createURL: jest.fn().mockReturnValue('exp://localhost/login'), +})); + +jest.mock('@/components/base/dialogContext', () => { + const actual = jest.requireActual( + '@/components/base/dialogContext', + ); + return { + ...actual, + useDialog: jest.fn(() => mockDialogApi), + }; +}); + +const mockReplace = jest.fn(); +const mockPush = jest.fn(); +const mockedClaimOAuthMfaHandoff = claimOAuthMfaHandoff as jest.MockedFunction< + typeof claimOAuthMfaHandoff +>; +const mockedSetPendingMfaLogin = setPendingMfaLogin as jest.MockedFunction< + typeof setPendingMfaLogin +>; +const YOU_DENIED_ACCESS_PATTERN = /Access was declined/i; +const ALREADY_EXISTS_PATTERN = /already exists/i; +const ENSURE_DEVICE_INTERNET_PATTERN = /check your internet connection/i; +const ACCOUNT_SUSPENDED_PATTERN = /your account has been suspended/i; +const UNABLE_TO_RETRIEVE_USER_PATTERN = /Couldn't load your account/; +const UNEXPECTED_AUTHORIZATION_URL_PATTERN = /Unexpected authorization URL/; +const UNEXPECTED_CALLBACK_URL_PATTERN = /Unexpected OAuth callback URL/; + +const mockedLogin = jest.mocked(login); +const mockedGetUser = jest.mocked(getUser); +const mockedGetToken = jest.mocked(getToken); +const mockedMarkWebSessionActive = jest.mocked(markWebSessionActive); +const mockedOpenAuthSessionAsync = jest.mocked(openAuthSessionAsync); +const mockedBuildOAuthAuthorizeUrl = jest.mocked(buildOAuthAuthorizeUrl); +const mockedFetchOAuthAuthorizationUrl = jest.mocked(fetchOAuthAuthorizationUrl); +const mockedOpenOAuthBrowserSession = jest.mocked(openOAuthBrowserSession); +type AuthSessionResult = Awaited>; + +const expectAlert = (title: string, message: RegExp) => + waitFor(() => + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ title, message: expect.stringMatching(message) }), + ), + ); + +describe('Login screen', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDialogApi.alert.mockReset(); + mockDialogApi.input.mockReset(); + mockDialogApi.toast.mockReset(); + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + (useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + replace: mockReplace, + back: jest.fn(), + setParams: jest.fn(), + }); + mockedGetToken.mockResolvedValue(undefined); // default: guest + mockedGetUser.mockResolvedValue(undefined); + mockedOpenAuthSessionAsync.mockResolvedValue({ type: 'cancel' } as AuthSessionResult); + mockedBuildOAuthAuthorizeUrl.mockReturnValue('https://api.example.com/oauth/authorize'); + mockedFetchOAuthAuthorizationUrl.mockResolvedValue({ + ok: true, + status: 200, + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + detail: undefined, + }); + mockedOpenOAuthBrowserSession.mockResolvedValue({ type: 'cancel' } as AuthSessionResult); + const { useAuth } = require('@/context/auth.ts'); + (useAuth as jest.Mock).mockReturnValue({ + user: null, + isLoading: false, + refetch: mockAuthRefetch, + }); + mockAuthRefetch.mockResolvedValue(undefined); + restorePlatform(); + }); + + afterEach(() => { + restorePlatform(); + }); + + it('renders login form elements', async () => { + renderWithProviders(, { withDialog: true }); + expect(screen.getAllByText('Sign in').length).toBeGreaterThan(0); + expect(screen.getByLabelText('Email or username')).toBeOnTheScreen(); + expect(screen.getByLabelText('Password')).toBeOnTheScreen(); + }); + + // The OAuth buttons here provision an account on first sign-in, so the terms + // and privacy line that password signup shows has to be on this screen too — + // consenting to terms you were never shown is not consent. + it('shows the terms and privacy links alongside the OAuth buttons', async () => { + renderWithProviders(, { withDialog: true }); + expect(screen.getByText('Continue with Google')).toBeOnTheScreen(); + expect(screen.getByRole('link', { name: 'Terms' })).toBeOnTheScreen(); + expect(screen.getByRole('link', { name: 'Privacy Policy' })).toBeOnTheScreen(); + }); + + it('shows Sign in button', async () => { + renderWithProviders(, { withDialog: true }); + expect(screen.getByRole('button', { name: 'Sign in' })).toBeOnTheScreen(); + }); + + it('redirects to products when already authenticated on mount', async () => { + const { useAuth } = require('@/context/auth.ts'); + (useAuth as jest.Mock).mockReturnValue({ + user: mockUser({ username: 'existing_user', email: 'e@example.com' }), + isLoading: false, + refetch: mockAuthRefetch, + }); + renderWithProviders(, { withDialog: true }); + await waitFor( + () => { + expect(mockReplace).toHaveBeenCalledWith( + expect.objectContaining({ pathname: '/products' }), + ); + }, + { timeout: 3000 }, + ); + }); + + it('redirects already-authenticated users without a username to onboarding', async () => { + const { useAuth } = require('@/context/auth.ts'); + (useAuth as jest.Mock).mockReturnValue({ + user: mockUser({ username: null, email: 'oauth@example.com' }), + isLoading: false, + refetch: mockAuthRefetch, + }); + renderWithProviders(, { withDialog: true }); + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/onboarding'); + }); + }); + + it('calls login and redirects to products on successful login', async () => { + mockedLogin.mockResolvedValue({ status: 'authenticated' }); + mockedGetUser.mockResolvedValueOnce(mockUser()); // returned by getUser(true) inside attemptLogin + + renderWithProviders(, { withDialog: true }); + + fireEvent.changeText(screen.getByLabelText('Email or username'), 'test@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'correct-horse-battery-staple-v42'); + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }); + + await waitFor(() => { + expect(login).toHaveBeenCalledWith('test@example.com', 'correct-horse-battery-staple-v42'); + expect(mockReplace).toHaveBeenCalledWith(expect.objectContaining({ pathname: '/products' })); + }); + }); + + it('redirects to the requested route after successful login', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ redirectTo: '/account' }); + mockedLogin.mockResolvedValue({ status: 'authenticated' }); + mockedGetUser.mockResolvedValueOnce(mockUser()); + + renderWithProviders(, { withDialog: true }); + + fireEvent.changeText(screen.getByLabelText('Email or username'), 'test@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'correct-horse-battery-staple-v42'); + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/account'); + }); + }); + + it('routes successful login without username to onboarding before requested redirect', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ redirectTo: '/account' }); + mockedLogin.mockResolvedValue({ status: 'authenticated' }); + mockedGetUser.mockResolvedValueOnce(mockUser({ username: null })); + + renderWithProviders(, { withDialog: true }); + + fireEvent.changeText(screen.getByLabelText('Email or username'), 'test@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'correct-horse-battery-staple-v42'); + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/onboarding'); + expect(mockReplace).not.toHaveBeenCalledWith('/account'); + }); + }); + + it('preserves requested redirect when password login requires MFA', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ redirectTo: '/account' }); + mockedLogin.mockResolvedValue({ status: 'mfa_required', mfaToken: 'mfa-token' }); + + renderWithProviders(, { withDialog: true }); + + fireEvent.changeText(screen.getByLabelText('Email or username'), 'test@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'correct-horse-battery-staple-v42'); + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }); + + await waitFor(() => { + expect(mockedSetPendingMfaLogin).toHaveBeenCalledWith({ + status: 'mfa_required', + mfaToken: 'mfa-token', + redirectTo: '/account', + }); + expect(mockPush).toHaveBeenCalledWith('/mfa'); + }); + }); + + it('shows sign-in failure dialog when login returns null', async () => { + mockedLogin.mockResolvedValue({ status: 'invalid_credentials' }); + + renderWithProviders(, { withDialog: true }); + + fireEvent.changeText(screen.getByLabelText('Email or username'), 'bad@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'wrongpass'); + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }); + + await waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: 'Invalid email or password.', + }), + ); + }); + }); + + it('shows sign-in failure dialog on login exception', async () => { + mockedLogin.mockRejectedValue(new Error('Network error')); + + renderWithProviders(, { withDialog: true }); + + fireEvent.changeText(screen.getByLabelText('Email or username'), 't@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'pass'); + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }); + + await waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: 'Network error', + }), + ); + }); + }); + + it('navigates to forgot password on button press', async () => { + renderWithProviders(, { withDialog: true }); + await screen.findByText('Forgot password?'); + fireEvent.press(screen.getByText('Forgot password?')); + expect(mockPush).toHaveBeenCalledWith('/forgot-password'); + }); + + it('navigates to new account on button press', async () => { + renderWithProviders(, { withDialog: true }); + await screen.findByText('Create a new account'); + fireEvent.press(screen.getByText('Create a new account')); + expect(mockPush).toHaveBeenCalledWith('/new-account'); + }); + + it('on web, GitHub OAuth redirects the page instead of opening a popup', async () => { + mockPlatform('web'); + const authUrl = 'https://github.com/login/oauth/authorize?client_id=test-client'; + mockedFetchOAuthAuthorizationUrl.mockResolvedValueOnce({ + ok: true, + status: 200, + authorizationUrl: authUrl, + detail: undefined, + }); + + // Intercept window.location.href so jsdom doesn't attempt real navigation + let capturedHref = ''; + const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + get href() { + return capturedHref; + }, + set href(v: string) { + capturedHref = v; + }, + }, + }); + + try { + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with GitHub')); + }); + + await waitFor(() => { + expect(capturedHref).toBe(authUrl); + expect(openAuthSessionAsync).not.toHaveBeenCalled(); + expect(AsyncStorage.setItem).not.toHaveBeenCalled(); + }); + } finally { + if (originalLocationDescriptor) { + Object.defineProperty(window, 'location', originalLocationDescriptor); + } + } + }); + + it('hydrates a web OAuth callback returned by URL fragment', async () => { + mockPlatform('web'); + const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: 'https://app.example.test/login#status=success', + hash: '#status=success', + pathname: '/login', + }, + }); + mockedGetUser.mockResolvedValueOnce( + mockUser({ username: 'oauth_user', email: 'oauth@example.com' }), + ); + + try { + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(mockedMarkWebSessionActive).toHaveBeenCalled(); + expect(getUser).toHaveBeenCalledWith(true); + expect(mockReplace).toHaveBeenCalledWith( + expect.objectContaining({ pathname: '/products' }), + ); + }); + } finally { + if (originalLocationDescriptor) { + Object.defineProperty(window, 'location', originalLocationDescriptor); + } + } + }); + + it('claims and routes a web OAuth MFA callback handoff from URL fragment params', async () => { + mockPlatform('web'); + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + mockedClaimOAuthMfaHandoff.mockResolvedValue({ + status: 'mfa_required', + mfaToken: 'claimed-mfa-token', + }); + const replaceStateSpy = jest.spyOn(window.history, 'replaceState'); + const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: 'https://app.example.test/login#status=mfa_required&mfa_handoff=oauth-handoff-token', + hash: '#status=mfa_required&mfa_handoff=oauth-handoff-token', + pathname: '/login', + }, + }); + + try { + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/mfa'); + }); + expect(mockedClaimOAuthMfaHandoff).toHaveBeenCalledWith('oauth-handoff-token'); + expect(mockedSetPendingMfaLogin).toHaveBeenCalledWith({ + status: 'mfa_required', + mfaToken: 'claimed-mfa-token', + }); + expect(replaceStateSpy).toHaveBeenCalledWith({}, '', '/login'); + } finally { + replaceStateSpy.mockRestore(); + if (originalLocationDescriptor) { + Object.defineProperty(window, 'location', originalLocationDescriptor); + } + } + }); + + it('routes OAuth callback without username to onboarding', async () => { + mockPlatform('web'); + const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: 'https://app.example.test/login?redirectTo=%2Faccount#status=success', + hash: '#status=success', + pathname: '/login', + }, + }); + (useLocalSearchParams as jest.Mock).mockReturnValue({ + redirectTo: '/account', + }); + mockedGetUser.mockResolvedValueOnce(mockUser({ username: null, email: 'oauth@example.com' })); + + try { + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(mockedMarkWebSessionActive).toHaveBeenCalled(); + expect(mockReplace).toHaveBeenCalledWith('/onboarding'); + expect(mockReplace).not.toHaveBeenCalledWith('/account'); + }); + } finally { + if (originalLocationDescriptor) { + Object.defineProperty(window, 'location', originalLocationDescriptor); + } + } + }); + + it('shows error when OAuth provider denies access via web URL fragment', async () => { + mockPlatform('web'); + const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: 'https://app.example.test/login#status=error&error=access_denied', + hash: '#status=error&error=access_denied', + pathname: '/login', + }, + }); + + try { + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: expect.stringMatching(YOU_DENIED_ACCESS_PATTERN), + }), + ); + expect(mockReplace).not.toHaveBeenCalled(); + }); + } finally { + if (originalLocationDescriptor) { + Object.defineProperty(window, 'location', originalLocationDescriptor); + } + } + }); + + it('shows explicit account-linking guidance when OAuth account already exists', async () => { + mockPlatform('web'); + mockedFetchOAuthAuthorizationUrl.mockResolvedValueOnce({ + ok: false, + status: 400, + authorizationUrl: undefined, + detail: 'OAUTH_USER_ALREADY_EXISTS', + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with GitHub')); + }); + + await waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Email already registered', + message: expect.stringMatching(ALREADY_EXISTS_PATTERN), + }), + ); + expect(openAuthSessionAsync).not.toHaveBeenCalled(); + }); + }); + + it('shows error when OAuth provider denies access', async () => { + mockPlatform('android'); + mockedOpenOAuthBrowserSession.mockResolvedValueOnce({ + type: 'success', + url: 'exp://localhost/login#status=error&error=access_denied', + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with Google')); + }); + + await expectAlert("Couldn't sign in", YOU_DENIED_ACCESS_PATTERN); + }); + + it('shows platform-specific retry guidance on native OAuth failure', async () => { + mockPlatform('android'); + mockedOpenOAuthBrowserSession.mockResolvedValueOnce({ + type: 'success', + // No known error code; falls through to platform-specific guidance + url: 'exp://localhost/login#status=error', + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with Google')); + }); + + await expectAlert("Couldn't sign in", ENSURE_DEVICE_INTERNET_PATTERN); + }); + + it('retries session validation after OAuth success', async () => { + mockPlatform('android'); + mockedOpenOAuthBrowserSession.mockResolvedValueOnce({ + type: 'success', + url: 'exp://localhost/login#status=success', + }); + mockedGetUser + .mockRejectedValueOnce(new Error('Network error')) // first getUser attempt fails + .mockResolvedValueOnce(mockUser({ username: 'oauth_user', email: 'oauth@example.com' })); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with Google')); + }); + + await waitFor( + () => { + expect(mockReplace).toHaveBeenCalledWith( + expect.objectContaining({ pathname: '/products' }), + ); + }, + { timeout: 2000 }, + ); + }); + + it('shows account suspended message when OAuth succeeds but user is inactive', async () => { + mockPlatform('android'); + mockedOpenOAuthBrowserSession.mockResolvedValueOnce({ + type: 'success', + url: 'exp://localhost/login#status=success', + }); + mockedGetUser.mockResolvedValue( + mockUser({ username: 'suspended_user', email: 'suspended@example.com', isActive: false }), + ); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with Google')); + }); + + await expectAlert('Account suspended', ACCOUNT_SUSPENDED_PATTERN); + }); + + it('navigates back to browsing on button press', async () => { + renderWithProviders(, { withDialog: true }); + fireEvent.press(screen.getByText('Browse')); + expect(mockReplace).toHaveBeenCalledWith('/products'); + }); + + it('shows forgot password link and create account button', async () => { + renderWithProviders(, { withDialog: true }); + await waitFor(() => { + expect(screen.getByText('Forgot password?')).toBeOnTheScreen(); + expect(screen.getByText('Create a new account')).toBeOnTheScreen(); + }); + }); + + it('shows account suspended message in attemptLogin when user is inactive', async () => { + mockedLogin.mockResolvedValue({ status: 'authenticated' }); + mockedGetUser.mockResolvedValue(mockUser({ isActive: false })); + + renderWithProviders(, { withDialog: true }); + fireEvent.changeText(screen.getByLabelText('Email or username'), 'suspended@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'pass'); + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }); + + await expectAlert('Account suspended', ACCOUNT_SUSPENDED_PATTERN); + }); + + it('shows error when OAuth setup fails (auth endpoint unreachable)', async () => { + mockedFetchOAuthAuthorizationUrl.mockResolvedValueOnce({ + ok: false, + status: 404, + authorizationUrl: undefined, + detail: 'Endpoint not found', + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with Google')); + }); + + await waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: 'Endpoint not found', + }), + ); + }); + }); + + it('handles user cancellation during OAuth browser session', async () => { + mockPlatform('android'); + mockedOpenOAuthBrowserSession.mockResolvedValueOnce({ + type: WebBrowserResultType.CANCEL, + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with Google')); + }); + + await waitFor(() => { + expect(openOAuthBrowserSession).toHaveBeenCalled(); + // Should not show error or navigate + expect(mockReplace).not.toHaveBeenCalled(); + }); + }); + + it('shows dialog when getUser returns null after successful login', async () => { + mockedLogin.mockResolvedValue({ status: 'authenticated' }); + mockedGetUser.mockResolvedValue(undefined); + + renderWithProviders(, { withDialog: true }); + + fireEvent.changeText(screen.getByLabelText('Email or username'), 't@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'pass'); + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }); + + await expectAlert("Couldn't sign in", UNABLE_TO_RETRIEVE_USER_PATTERN); + }); + + it('shows error when OAuth provider returns an unsafe authorization URL on web', async () => { + mockPlatform('web'); + mockedFetchOAuthAuthorizationUrl.mockResolvedValueOnce({ + ok: true, + status: 200, + authorizationUrl: 'http://evil.example.com/phish', + detail: undefined, + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with GitHub')); + }); + + await expectAlert("Couldn't sign in", UNEXPECTED_AUTHORIZATION_URL_PATTERN); + }); + + it('shows error when OAuth provider returns an unsafe authorization URL on native', async () => { + mockPlatform('android'); + mockedFetchOAuthAuthorizationUrl.mockResolvedValueOnce({ + ok: true, + status: 200, + authorizationUrl: 'http://evil.example.com/phish', + detail: undefined, + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with GitHub')); + }); + + await waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: expect.stringMatching(UNEXPECTED_AUTHORIZATION_URL_PATTERN), + }), + ); + expect(openOAuthBrowserSession).not.toHaveBeenCalled(); + }); + }); + + it('shows error when native OAuth callback does not match the configured redirect', async () => { + mockPlatform('android'); + mockedOpenOAuthBrowserSession.mockResolvedValueOnce({ + type: 'success', + url: 'exp://evil.example/login#status=success', + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with Google')); + }); + + await waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: expect.stringMatching(UNEXPECTED_CALLBACK_URL_PATTERN), + }), + ); + expect(getUser).not.toHaveBeenCalled(); + }); + }); + + it('shows an inline field error when a required field is cleared', async () => { + renderWithProviders(, { withDialog: true }); + + const emailInput = screen.getByLabelText('Email or username'); + fireEvent.changeText(emailInput, 'test@example.com'); + fireEvent.changeText(emailInput, ''); + + await waitFor(() => { + expect(screen.getByText('Email is required')).toBeOnTheScreen(); + }); + }); + + it('initiates GitHub OAuth login', async () => { + mockedOpenOAuthBrowserSession.mockResolvedValueOnce({ + type: WebBrowserResultType.CANCEL, + }); + + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('Continue with GitHub')); + }); + + await waitFor(() => { + expect(openOAuthBrowserSession).toHaveBeenCalled(); + expect(mockDialogApi.alert).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/app/(auth)/__tests__/mfa.integration.test.tsx b/app/src/app/(auth)/__tests__/mfa.integration.test.tsx new file mode 100644 index 000000000..128e6cfa0 --- /dev/null +++ b/app/src/app/(auth)/__tests__/mfa.integration.test.tsx @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import MfaScreen from '@/app/(auth)/mfa'; +import { useAuth } from '@/context/auth'; +import { completeMfaChallenge, setPendingMfaLogin } from '@/services/api/auth/authMfa'; +import { mockUser, renderWithProviders } from '@/test-utils/index'; +import type { User } from '@/types/User'; + +let mockPendingMfaLogin: + | { status: 'mfa_required'; mfaToken: string; redirectTo?: string } + | undefined; + +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(), + useRouter: jest.fn(), +})); + +jest.mock('@/services/api/auth/authMfa', () => ({ + clearPendingMfaLogin: jest.fn(() => { + mockPendingMfaLogin = undefined; + }), + completeMfaChallenge: jest.fn(), + getPendingMfaLogin: jest.fn(() => mockPendingMfaLogin), + setPendingMfaLogin: jest.fn((pending) => { + mockPendingMfaLogin = pending as typeof mockPendingMfaLogin; + }), +})); + +jest.mock('@/context/auth', () => ({ + useAuth: jest.fn(), +})); + +const mockReplace = jest.fn(); +const mockRefetch = jest.fn<(forceRefresh?: boolean) => Promise>(); +const mockedUseLocalSearchParams = useLocalSearchParams as jest.Mock; +const mockedUseRouter = useRouter as jest.Mock; +const mockedUseAuth = useAuth as jest.Mock; +const mockedCompleteMfaChallenge = completeMfaChallenge as jest.MockedFunction< + typeof completeMfaChallenge +>; + +function renderMfaScreen() { + renderWithProviders(); +} + +beforeEach(() => { + mockPendingMfaLogin = undefined; + jest.clearAllMocks(); + mockedUseRouter.mockReturnValue({ replace: mockReplace }); + mockedUseLocalSearchParams.mockReturnValue({}); + mockRefetch.mockResolvedValue(mockUser()); + mockedUseAuth.mockReturnValue({ user: undefined, isLoading: false, refetch: mockRefetch }); + setPendingMfaLogin({ status: 'mfa_required', mfaToken: 'mfa-token' }); +}); + +describe('MfaScreen challenge flow', () => { + // Every auth field carries a visible label, not just the recovery-code + // fallback — getByLabelText below only sees the accessible name, so the + // rendered label needs its own assertion. + it('labels the code field visibly', () => { + renderMfaScreen(); + + expect(screen.getByText('Authentication code')).toBeOnTheScreen(); + }); + + it('does not submit until a six digit code is entered', () => { + renderMfaScreen(); + + expect(screen.getByText('Continue')).toBeDisabled(); + fireEvent.changeText(screen.getByLabelText('Authentication code'), '12345'); + expect(screen.getByText('Continue')).toBeDisabled(); + expect(mockedCompleteMfaChallenge).not.toHaveBeenCalled(); + }); + + it('auto-submits once six digits are entered', async () => { + mockedCompleteMfaChallenge.mockResolvedValueOnce(); + + renderMfaScreen(); + + fireEvent.changeText(screen.getByLabelText('Authentication code'), '123456'); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith(expect.objectContaining({ pathname: '/products' })); + }); + expect(mockedCompleteMfaChallenge).toHaveBeenCalledWith('mfa-token', '123456'); + }); + + it('allows retrying an MFA challenge after an invalid code', async () => { + mockedCompleteMfaChallenge + .mockRejectedValueOnce(new Error('Invalid MFA code.')) + .mockResolvedValueOnce(); + + renderMfaScreen(); + + fireEvent.changeText(screen.getByLabelText('Authentication code'), '000000'); + + await waitFor(() => { + expect(screen.getByText('Invalid MFA code.')).toBeOnTheScreen(); + }); + + fireEvent.changeText(screen.getByLabelText('Authentication code'), '123456'); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith(expect.objectContaining({ pathname: '/products' })); + }); + expect(mockedCompleteMfaChallenge).toHaveBeenNthCalledWith(1, 'mfa-token', '000000'); + expect(mockedCompleteMfaChallenge).toHaveBeenNthCalledWith(2, 'mfa-token', '123456'); + }); + + it('routes to the preserved redirect after completing MFA', async () => { + setPendingMfaLogin({ status: 'mfa_required', mfaToken: 'mfa-token', redirectTo: '/account' }); + mockedCompleteMfaChallenge.mockResolvedValueOnce(); + + renderMfaScreen(); + + fireEvent.changeText(screen.getByLabelText('Authentication code'), '123456'); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/account'); + }); + expect(mockRefetch).toHaveBeenCalled(); + }); + + it('does not read MFA tokens from route params', () => { + mockPendingMfaLogin = undefined; + mockedUseLocalSearchParams.mockReturnValue({ token: 'route-token' }); + + renderMfaScreen(); + + expect(screen.getByText('MFA session expired. Please sign in again.')).toBeOnTheScreen(); + expect(screen.getByText('Continue')).toBeDisabled(); + expect(mockedCompleteMfaChallenge).not.toHaveBeenCalled(); + }); + + it('signs in with a recovery code', async () => { + mockedCompleteMfaChallenge.mockResolvedValueOnce(); + + renderMfaScreen(); + + fireEvent.press(screen.getByText('Use a recovery code')); + fireEvent.changeText(screen.getByLabelText('Recovery code'), 'ABCDE-FGHIJ'); + fireEvent.press(screen.getByText('Sign in')); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith(expect.objectContaining({ pathname: '/products' })); + }); + expect(mockedCompleteMfaChallenge).toHaveBeenCalledWith('mfa-token', 'ABCDE-FGHIJ'); + }); +}); diff --git a/app/src/app/(auth)/__tests__/new-account.integration.test.tsx b/app/src/app/(auth)/__tests__/new-account.integration.test.tsx new file mode 100644 index 000000000..8f5ddaaba --- /dev/null +++ b/app/src/app/(auth)/__tests__/new-account.integration.test.tsx @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useRouter } from 'expo-router'; +import NewAccount from '@/app/(auth)/new-account'; +import { login, register } from '@/services/api/auth/authentication'; +import { renderWithProviders } from '@/test-utils/index'; +import type { User } from '@/types/User'; + +const mockDialogApi = { + alert: jest.fn(), + input: jest.fn(), + toast: jest.fn(), +}; + +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(), + useRouter: jest.fn(), +})); + +jest.mock('@/services/api/auth/authentication', () => ({ + login: jest.fn(), + register: jest.fn(), +})); + +const mockRefetch = jest.fn(); +const mockUseAuth = jest.fn( + (): { user: User | null; isLoading: boolean; refetch: typeof mockRefetch } => ({ + user: null, + isLoading: false, + refetch: mockRefetch, + }), +); +const AT_LEAST_2_PATTERN = /at least 2/; +const HOW_DO_WE_REACH_YOU_PATTERN = /How do we reach you/; +const VALID_EMAIL_PATTERN = /valid email/; +const AT_LEAST_12_PATTERN = /at least 12/; + +const mockedRegister = jest.mocked(register); +const mockedLogin = jest.mocked(login); + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/components/base/dialogContext', () => { + const actual = jest.requireActual( + '@/components/base/dialogContext', + ); + return { + ...actual, + useDialog: jest.fn(() => mockDialogApi), + }; +}); + +global.alert = jest.fn(); + +const mockNavigate = jest.fn(); +const mockReplace = jest.fn(); +const mockDismissTo = jest.fn(); + +describe('NewAccount screen', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDialogApi.alert.mockReset(); + mockDialogApi.input.mockReset(); + mockDialogApi.toast.mockReset(); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + navigate: mockNavigate, + dismissTo: mockDismissTo, + setParams: jest.fn(), + }); + }); + + it('renders the username section by default', () => { + renderWithProviders(, { withDialog: true }); + expect(screen.getByLabelText('Username')).toBeOnTheScreen(); + expect(screen.getByText('Who are you?')).toBeOnTheScreen(); + }); + + it('shows validation error for invalid username', async () => { + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Username'), 'a'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Username'), 'submitEditing'); + }); + + await waitFor(() => { + expect(screen.getByText(AT_LEAST_2_PATTERN)).toBeOnTheScreen(); + }); + }); + + it('chevron button is disabled for invalid username', async () => { + renderWithProviders(, { withDialog: true }); + const input = screen.getByLabelText('Username'); + await act(async () => { + fireEvent.changeText(input, ''); + }); + expect(screen.getByLabelText('Username')).toBeOnTheScreen(); + }); + + it('advances to email section with valid username', async () => { + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Username'), 'validuser'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Username'), 'submitEditing'); + }); + + expect(screen.getByText(HOW_DO_WE_REACH_YOU_PATTERN)).toBeOnTheScreen(); + }); + + it('does not advance from username when invalid', async () => { + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Username'), 'a'); + }); + + await act(async () => { + fireEvent(screen.getByLabelText('Username'), 'submitEditing'); + }); + + expect(screen.queryByText(HOW_DO_WE_REACH_YOU_PATTERN)).toBeNull(); + }); + + it('shows email validation error for invalid email', async () => { + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Username'), 'validuser'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Username'), 'submitEditing'); + }); + await screen.findByLabelText('Email address'); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Email address'), 'not_an_email'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Email address'), 'submitEditing'); + }); + + await waitFor(() => { + expect(screen.getByText(VALID_EMAIL_PATTERN)).toBeOnTheScreen(); + }); + }); + + it('returns to login with a verify prompt after registration, without logging in', async () => { + mockedRegister.mockResolvedValue({ success: true }); + mockedLogin.mockResolvedValue({ status: 'authenticated' }); + + renderWithProviders(, { withDialog: true }); + + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Username'), 'newuser'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Username'), 'submitEditing'); + }); + + await screen.findByLabelText('Email address'); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Email address'), 'user@example.com'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Email address'), 'submitEditing'); + }); + + await screen.findByLabelText('Password'); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Password'), 'correct-horse-battery-staple-v42'); + }); + await act(async () => { + fireEvent.press(screen.getByText('Create account')); + }); + + expect(mockedLogin).not.toHaveBeenCalled(); + expect(mockReplace).toHaveBeenCalledWith('/login'); + }); + + it('shows error when registration fails', async () => { + mockedRegister.mockResolvedValue({ success: false, error: 'Email already in use' }); + + renderWithProviders(, { withDialog: true }); + + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Username'), 'newuser'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Username'), 'submitEditing'); + }); + await screen.findByLabelText('Email address'); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Email address'), 'taken@example.com'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Email address'), 'submitEditing'); + }); + await screen.findByLabelText('Password'); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Password'), 'correct-horse-battery-staple-v42'); + }); + + await act(async () => { + fireEvent.press(screen.getByText('Create account')); + }); + + expect(register).toHaveBeenCalled(); + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Registration failed', + message: 'Email already in use', + }), + ); + }); + + it('shows validation error for short password', async () => { + renderWithProviders(, { withDialog: true }); + + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Username'), 'validuser'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Username'), 'submitEditing'); + }); + await screen.findByLabelText('Email address'); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Email address'), 'valid@example.com'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Email address'), 'submitEditing'); + }); + + await screen.findByLabelText('Password'); + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Password'), '123'); + }); + + await waitFor(() => { + expect(screen.getByText(AT_LEAST_12_PATTERN)).toBeOnTheScreen(); + }); + }); + + it('navigates back through sections', async () => { + renderWithProviders(, { withDialog: true }); + + await act(async () => { + fireEvent.changeText(screen.getByLabelText('Username'), 'testuser'); + }); + await act(async () => { + fireEvent(screen.getByLabelText('Username'), 'submitEditing'); + }); + + await screen.findByLabelText('Email address'); + await act(async () => { + fireEvent.press(screen.getByText('Edit username')); + }); + + expect(screen.getByLabelText('Username')).toBeOnTheScreen(); + }); + + it('navigates to login via "I already have an account"', async () => { + renderWithProviders(, { withDialog: true }); + await act(async () => { + fireEvent.press(screen.getByText('I already have an account')); + }); + expect(mockDismissTo).toHaveBeenCalledWith('/login'); + }); +}); + +describe('NewAccount – authenticated redirect', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + navigate: jest.fn(), + dismissTo: jest.fn(), + setParams: jest.fn(), + }); + }); + + it('redirects to /products when a user is already logged in', async () => { + mockUseAuth.mockReturnValue({ + user: { + id: '1', + email: 'a@b.com', + username: 'alice', + isActive: true, + isVerified: true, + isSuperuser: false, + mfaEnabled: false, + hasUsablePassword: true, + role: 'contributor' as const, + termsAcceptanceRequired: false, + uploadQuota: { files: 1000, bytes: 1024 * 1024 * 1024, usedFiles: 0, usedBytes: 0 }, + oauth_accounts: [], + preferences: {}, + }, + isLoading: false, + refetch: mockRefetch, + }); + + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/products'); + }); + }); + + it('does not redirect while auth is still loading', async () => { + mockUseAuth.mockReturnValue({ user: null, isLoading: true, refetch: mockRefetch }); + + renderWithProviders(, { withDialog: true }); + + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('does not redirect when no user is logged in', async () => { + mockUseAuth.mockReturnValue({ user: null, isLoading: false, refetch: mockRefetch }); + + renderWithProviders(, { withDialog: true }); + + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/app/(auth)/__tests__/onboarding.test.tsx b/app/src/app/(auth)/__tests__/onboarding.test.tsx new file mode 100644 index 000000000..73760f891 --- /dev/null +++ b/app/src/app/(auth)/__tests__/onboarding.test.tsx @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useRouter } from 'expo-router'; +import { Keyboard } from 'react-native'; +import Onboarding from '@/app/(auth)/onboarding'; +import { useAuth } from '@/context/auth'; +import { updateUser } from '@/services/api/auth/authentication'; +import { mockPlatform, renderWithProviders, restorePlatform } from '@/test-utils/index'; + +jest.mock('@/services/api/auth/authentication', () => ({ + updateUser: jest.fn(), +})); + +jest.mock('@/context/auth', () => ({ + useAuth: jest.fn(), +})); + +const mockUseEffectiveColorScheme = jest.fn().mockReturnValue('light'); +jest.mock('@/context/themeMode', () => ({ + useEffectiveColorScheme: () => mockUseEffectiveColorScheme(), +})); + +const mockReplace = jest.fn(); +const mockRefetch = jest.fn(); +const mockedUpdateUser = jest.mocked(updateUser); + +function renderOnboardingScreen() { + renderWithProviders(, { withDialog: true }); +} + +beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + setParams: jest.fn(), + }); + (useAuth as jest.Mock).mockReturnValue({ + refetch: mockRefetch, + user: null, + isLoading: false, + }); +}); + +describe('Onboarding screen rendering', () => { + it('renders the Welcome text, username input and Continue button', () => { + renderOnboardingScreen(); + expect(screen.getByText('Welcome!')).toBeOnTheScreen(); + expect(screen.getByPlaceholderText('e.g. awesome_user')).toBeOnTheScreen(); + expect(screen.getByText('Continue')).toBeOnTheScreen(); + }); +}); + +describe('Onboarding screen submission', () => { + it('saves the username and routes into the authenticated flow', async () => { + mockedUpdateUser.mockResolvedValue(undefined); + + renderOnboardingScreen(); + + fireEvent.changeText(screen.getByPlaceholderText('e.g. awesome_user'), 'new_user'); + fireEvent(screen.getByPlaceholderText('e.g. awesome_user'), 'submitEditing'); + + await waitFor(() => { + expect(mockedUpdateUser).toHaveBeenCalledWith({ username: 'new_user' }); + expect(mockRefetch).toHaveBeenCalledWith(false); + expect(mockReplace).toHaveBeenCalledWith({ + pathname: '/products', + params: { authenticated: 'true' }, + }); + }); + }); + + it('shows an error dialog when onboarding fails', async () => { + mockedUpdateUser.mockRejectedValue(new Error('Username already exists')); + + renderOnboardingScreen(); + + fireEvent.changeText(screen.getByPlaceholderText('e.g. awesome_user'), 'taken_name'); + fireEvent(screen.getByPlaceholderText('e.g. awesome_user'), 'submitEditing'); + + await waitFor(() => { + expect(screen.getByText("Couldn't save username")).toBeOnTheScreen(); + expect(screen.getByText('Username already exists')).toBeOnTheScreen(); + }); + }); +}); + +describe('Onboarding screen behavior', () => { + it('Continue button is disabled when username is empty', () => { + renderOnboardingScreen(); + + // No text entered — form is invalid, button should be disabled + const button = screen.getByText('Continue'); + expect(button).toBeOnTheScreen(); + // The button's parent Pressable is disabled when isValid=false + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + }); + + it('renders correctly in dark mode — covers colorScheme !== light branches', () => { + mockUseEffectiveColorScheme.mockReturnValue('dark'); + + renderOnboardingScreen(); + + expect(screen.getByText('Welcome!')).toBeOnTheScreen(); + expect(screen.getByText('Choose a username to continue.')).toBeOnTheScreen(); + }); + + it('renders on iOS with keyboard metrics — covers Platform.OS !== web branch', () => { + mockPlatform('ios'); + jest + .spyOn(Keyboard, 'metrics') + .mockReturnValue({ height: 300, screenX: 0, screenY: 0, width: 375 }); + + renderOnboardingScreen(); + + expect(screen.getByText('Welcome!')).toBeOnTheScreen(); + + restorePlatform(); + }); + + it('renders on web with bottom padding 0 — covers Platform.OS === web branch', () => { + mockPlatform('web'); + + renderOnboardingScreen(); + + expect(screen.getByText('Welcome!')).toBeOnTheScreen(); + + restorePlatform(); + }); +}); diff --git a/app/src/app/(auth)/__tests__/reset-password.integration.test.tsx b/app/src/app/(auth)/__tests__/reset-password.integration.test.tsx new file mode 100644 index 000000000..fe924aed7 --- /dev/null +++ b/app/src/app/(auth)/__tests__/reset-password.integration.test.tsx @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, screen } from '@testing-library/react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Platform } from 'react-native'; +import ResetPasswordScreen from '@/app/(auth)/reset-password'; +import { apiFetch } from '@/services/api/client'; +import { renderWithProviders } from '@/test-utils/index'; + +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(), + useRouter: jest.fn(), +})); + +jest.mock('@/services/api/client', () => ({ + apiFetch: jest.fn(), +})); + +const AT_LEAST_12_PATTERN = /at least 12/i; +const PASSWORDS_MATCH_PATTERN = /passwords must match/i; +const PASSWORD_RESET_SUCCESS_PATTERN = /Password reset\. You can now sign in\./i; +const REDIRECTING_TO_LOGIN_PATTERN = /Redirecting to login/i; + +const mockedApiFetch = apiFetch as jest.MockedFunction; +const mockPush = jest.fn(); + +function createMockResponse(ok: boolean, body: Record = {}): Response { + return { + ok, + json: async () => body, + } as unknown as Response; +} + +function renderResetPasswordScreen() { + renderWithProviders(); +} + +async function settleForm() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function submitResetPassword(password: string, confirmPassword = password) { + fireEvent.changeText(screen.getByTestId('password-input'), password); + fireEvent.changeText(screen.getByTestId('confirm-password-input'), confirmPassword); + await settleForm(); + expect(screen.getByRole('button', { name: 'Reset password' })).not.toBeDisabled(); + fireEvent.press(screen.getByRole('button', { name: 'Reset password' })); + await settleForm(); +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.replaceProperty(Platform, 'OS', 'ios'); + mockedApiFetch.mockResolvedValue(createMockResponse(true)); + (useLocalSearchParams as jest.Mock).mockReturnValue({ token: 'valid-reset-token' }); + (useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + replace: jest.fn(), + back: jest.fn(), + setParams: jest.fn(), + }); +}); + +describe('ResetPasswordScreen rendering', () => { + it('renders the reset password form', () => { + renderResetPasswordScreen(); + expect(screen.getAllByText('Reset password')).not.toHaveLength(0); + expect(screen.getByTestId('password-input')).toBeOnTheScreen(); + expect(screen.getByTestId('confirm-password-input')).toBeOnTheScreen(); + expect(screen.getByText('Back to login')).toBeOnTheScreen(); + }); + + it('removes the token from browser history on web', () => { + const replaceStateSpy = jest.spyOn(window.history, 'replaceState'); + jest.replaceProperty(Platform, 'OS', 'web'); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + hash: '#token=fragment-reset-token', + pathname: '/reset-password', + search: '', + }, + }); + + renderResetPasswordScreen(); + + expect(replaceStateSpy).toHaveBeenCalledWith({}, '', '/reset-password'); + + replaceStateSpy.mockRestore(); + }); + + it('shows a validation error for a short password', async () => { + renderResetPasswordScreen(); + + fireEvent.changeText(screen.getByTestId('password-input'), 'short'); + await settleForm(); + + expect(screen.getByText(AT_LEAST_12_PATTERN)).toBeOnTheScreen(); + }); + + it('shows a validation error when password confirmation does not match', async () => { + renderResetPasswordScreen(); + + fireEvent.changeText(screen.getByTestId('password-input'), 'correct-horse-battery-staple-v42'); + fireEvent.changeText( + screen.getByTestId('confirm-password-input'), + 'correct-horse-battery-staple-v43', + ); + await settleForm(); + + expect(screen.getByText(PASSWORDS_MATCH_PATTERN)).toBeOnTheScreen(); + expect(screen.getByRole('button', { name: 'Reset password' })).toBeDisabled(); + }); +}); + +describe('ResetPasswordScreen submission', () => { + it('shows an error when no reset token is provided', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + + renderResetPasswordScreen(); + await submitResetPassword('correct-horse-battery-staple-v42'); + + expect(screen.getByText('This reset link is invalid. Request a new one.')).toBeOnTheScreen(); + expect(mockedApiFetch).not.toHaveBeenCalled(); + }); + + it('submits the new password and shows the success state', async () => { + renderResetPasswordScreen(); + await submitResetPassword('correct-horse-battery-staple-v42'); + + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.stringContaining('/auth/reset-password'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + token: 'valid-reset-token', + password: 'correct-horse-battery-staple-v42', + }), + }), + ); + expect(screen.getByText(PASSWORD_RESET_SUCCESS_PATTERN)).toBeOnTheScreen(); + expect(screen.getByText(REDIRECTING_TO_LOGIN_PATTERN)).toBeOnTheScreen(); + }); + + it('submits a web fragment token after scrubbing it from the URL', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + hash: '#token=fragment-reset-token', + pathname: '/reset-password', + search: '', + }, + }); + const replaceStateSpy = jest.spyOn(window.history, 'replaceState').mockImplementation(() => { + window.location.hash = ''; + }); + + renderResetPasswordScreen(); + await submitResetPassword('correct-horse-battery-staple-v42'); + + expect(replaceStateSpy).toHaveBeenCalledWith({}, '', '/reset-password'); + expect(mockedApiFetch).toHaveBeenCalledWith( + expect.stringContaining('/auth/reset-password'), + expect.objectContaining({ + body: JSON.stringify({ + token: 'fragment-reset-token', + password: 'correct-horse-battery-staple-v42', + }), + }), + ); + + replaceStateSpy.mockRestore(); + }); + + it('shows the API error message when the reset fails', async () => { + mockedApiFetch.mockResolvedValue(createMockResponse(false, { detail: 'Reset token expired' })); + + renderResetPasswordScreen(); + await submitResetPassword('correct-horse-battery-staple-v42'); + + expect(screen.getByText('Reset token expired')).toBeOnTheScreen(); + }); + + it('shows a generic error when the reset request throws', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockedApiFetch.mockRejectedValue(new Error('network down')); + + renderResetPasswordScreen(); + await submitResetPassword('correct-horse-battery-staple-v42'); + + expect(screen.getByText("Couldn't reset your password. Please try again.")).toBeOnTheScreen(); + + consoleErrorSpy.mockRestore(); + }); +}); + +describe('ResetPasswordScreen navigation', () => { + it('navigates to login from the button and after success delay', async () => { + renderResetPasswordScreen(); + + fireEvent.press(screen.getByRole('button', { name: 'Back to login' })); + expect(mockPush).toHaveBeenCalledWith('/login'); + + await submitResetPassword('correct-horse-battery-staple-v42'); + await screen.findByText(PASSWORD_RESET_SUCCESS_PATTERN); + + await act(async () => { + jest.advanceTimersByTime(3000); + }); + + expect(mockPush).toHaveBeenCalledWith('/login'); + }); +}); diff --git a/app/src/app/(auth)/__tests__/verify.test.tsx b/app/src/app/(auth)/__tests__/verify.test.tsx new file mode 100644 index 000000000..a64697e43 --- /dev/null +++ b/app/src/app/(auth)/__tests__/verify.test.tsx @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { HttpResponse, http } from 'msw'; +import { Platform } from 'react-native'; +import VerifyEmailScreen from '@/app/(auth)/verify'; +import { API_URL } from '@/config'; +import { getToken, getUser } from '@/services/api/auth/authentication'; +import { renderWithProviders } from '@/test-utils/index'; +import { server } from '@/test-utils/server'; + +jest.mock('@/services/api/auth/authentication', () => ({ + getToken: jest.fn(), + getUser: jest.fn(), + hasWebSessionFlag: jest.fn().mockReturnValue(false), +})); + +const NO_VERIFICATION_TOKEN_PATTERN = /No verification token/; +const EMAIL_VERIFIED_SUCCESS_PATTERN = /Email verified!/; +const GENERIC_VERIFY_ERROR_PATTERN = /Couldn't verify your email/; +const mockedGetToken = jest.mocked(getToken); +const mockedGetUser = jest.mocked(getUser); + +function renderVerifyEmailScreen() { + renderWithProviders(, { withAuth: true }); +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.replaceProperty(Platform, 'OS', 'ios'); + mockedGetToken.mockResolvedValue(undefined); + mockedGetUser.mockResolvedValue(undefined); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: jest.fn(), + back: jest.fn(), + setParams: jest.fn(), + }); +}); + +describe('VerifyEmailScreen states', () => { + it('shows error when no token is provided', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ token: undefined }); + renderVerifyEmailScreen(); + await waitFor( + () => { + expect(screen.getByText(NO_VERIFICATION_TOKEN_PATTERN)).toBeOnTheScreen(); + }, + { timeout: 3000 }, + ); + }); + + it('shows loading indicator on mount when token is present', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ token: 'valid-token' }); + // Delay the response so the loading text stays visible long enough for waitFor to catch it. + server.use( + http.post(`${API_URL}/auth/verify`, async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + return HttpResponse.json({}, { status: 200 }); + }), + ); + renderVerifyEmailScreen(); + await waitFor(() => { + expect(screen.getByText('Verifying your email…')).toBeOnTheScreen(); + }); + }); + + it('shows success message when verification succeeds', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ token: 'valid-token' }); + server.use(http.post(`${API_URL}/auth/verify`, () => HttpResponse.json({}, { status: 200 }))); + renderVerifyEmailScreen(); + await waitFor(() => { + expect(screen.getByText(EMAIL_VERIFIED_SUCCESS_PATTERN)).toBeOnTheScreen(); + }); + }); + + it('verifies a web fragment token after scrubbing it from the URL', async () => { + const replaceStateSpy = jest.spyOn(window.history, 'replaceState'); + jest.replaceProperty(Platform, 'OS', 'web'); + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + hash: '#token=fragment-verify-token', + pathname: '/verify', + search: '', + }, + }); + server.use( + http.post(`${API_URL}/auth/verify`, async ({ request }) => { + await expect(request.json()).resolves.toEqual({ token: 'fragment-verify-token' }); + return HttpResponse.json({}, { status: 200 }); + }), + ); + + renderVerifyEmailScreen(); + + await waitFor(() => { + expect(screen.getByText(EMAIL_VERIFIED_SUCCESS_PATTERN)).toBeOnTheScreen(); + }); + expect(replaceStateSpy).toHaveBeenCalledWith({}, '', '/verify'); + + replaceStateSpy.mockRestore(); + }); + + it('shows API error when verification returns non-ok response', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ token: 'bad-token' }); + server.use( + http.post(`${API_URL}/auth/verify`, () => + HttpResponse.json({ detail: 'Token expired' }, { status: 400 }), + ), + ); + renderVerifyEmailScreen(); + await waitFor(() => { + expect(screen.getByText('Token expired')).toBeOnTheScreen(); + }); + }); + + it('shows error when fetch throws', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ token: 'valid-token' }); + server.use(http.post(`${API_URL}/auth/verify`, () => HttpResponse.error())); + renderVerifyEmailScreen(); + await waitFor(() => { + expect(screen.getByText(GENERIC_VERIFY_ERROR_PATTERN)).toBeOnTheScreen(); + }); + }); +}); + +describe('VerifyEmailScreen navigation', () => { + it('Back to home button calls router.replace on error', async () => { + const mockReplace = jest.fn(); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + setParams: jest.fn(), + }); + (useLocalSearchParams as jest.Mock).mockReturnValue({ token: undefined }); + renderVerifyEmailScreen(); + await waitFor(() => { + expect(screen.getByText('Back to home')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByText('Back to home')); + expect(mockReplace).toHaveBeenCalledWith('/'); + }); +}); diff --git a/app/src/app/(auth)/forgot-password.tsx b/app/src/app/(auth)/forgot-password.tsx new file mode 100644 index 000000000..fa3e35b5f --- /dev/null +++ b/app/src/app/(auth)/forgot-password.tsx @@ -0,0 +1,63 @@ +import { useRouter } from 'expo-router'; +import { useCallback } from 'react'; +import { View } from 'react-native'; +import { AuthBackToLoginAction, AuthCard, AuthFormError } from '@/components/auth/AuthCardSections'; +import { AuthScreen } from '@/components/auth/AuthScreen'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { ControlledTextField } from '@/components/base/ControlledTextField'; +import { useForgotPassword } from '@/features/auth/usePasswordReset'; + +export default function ForgotPasswordScreen() { + const router = useRouter(); + const { control, isValid, isSubmitting, success, error, submit } = useForgotPassword(); + const goToLogin = useCallback(() => router.replace('/login'), [router]); + + return ( + + + {success ? ( + + + If an account exists with this email, we've sent it a password reset link. + + + Back to login + + + ) : ( + <> + + Enter your email address and we'll send you instructions to reset your password. + + + + + + + + Send reset link + + + + + )} + + + ); +} diff --git a/app/src/app/(auth)/login.tsx b/app/src/app/(auth)/login.tsx new file mode 100644 index 000000000..b7993fcd0 --- /dev/null +++ b/app/src/app/(auth)/login.tsx @@ -0,0 +1,39 @@ +import { + LoginBrandHero, + LoginCard, + LoginDivider, + LoginFormSection, + LoginLayout, + LoginOAuthSection, + LoginSecondaryAction, +} from '@/components/auth/LoginSections'; +import { PrivacyPolicy } from '@/components/auth/NewAccountSections'; +import { useLoginScreen } from '@/features/auth/useLoginScreen'; + +export default function Login() { + const { form, actions } = useLoginScreen(); + const handleSubmit = async () => form.submit(); + const handleGoogleLogin = async () => actions.loginWithGoogle(); + const handleGithubLogin = async () => actions.loginWithGithub(); + + return ( + + + + + + + {/* OAuth here can create an account (first sign-in provisions one), so the + same terms/privacy line the password signup shows has to be visible + before the user presses it. */} + + + + + ); +} diff --git a/app/src/app/(auth)/mfa.tsx b/app/src/app/(auth)/mfa.tsx new file mode 100644 index 000000000..2be56512a --- /dev/null +++ b/app/src/app/(auth)/mfa.tsx @@ -0,0 +1,88 @@ +import { useCallback } from 'react'; +import { View } from 'react-native'; +import { AuthCard, AuthFormError } from '@/components/auth/AuthCardSections'; +import { AuthScreen } from '@/components/auth/AuthScreen'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { OtpInput } from '@/components/base/OtpInput'; +import { TextInput } from '@/components/base/TextInput'; +import { useMfaScreen } from '@/features/auth/useMfaScreen'; + +export default function MfaScreen() { + const { + code, + recoveryCode, + useRecoveryCode, + isSubmitting, + canSubmit, + tokenPresent, + visibleError, + handleCodeChange, + handleRecoveryCodeChange, + toggleRecoveryMode, + submit, + goToLogin, + } = useMfaScreen(); + const submitCurrent = useCallback(() => submit(), [submit]); + + return ( + + + {useRecoveryCode + ? 'Enter one of your saved recovery codes.' + : 'Enter the 6-digit code from your authenticator app.'} + + } + > + {useRecoveryCode ? ( + + Recovery code + + + ) : ( + + )} + + + + + {useRecoveryCode ? 'Sign in' : 'Continue'} + + + + {useRecoveryCode ? 'Use your authenticator app' : 'Use a recovery code'} + + + + Back to login + + + + ); +} diff --git a/frontend-app/src/app/(auth)/new-account.tsx b/app/src/app/(auth)/new-account.tsx similarity index 90% rename from frontend-app/src/app/(auth)/new-account.tsx rename to app/src/app/(auth)/new-account.tsx index 4a3175699..f364dc7dc 100644 --- a/frontend-app/src/app/(auth)/new-account.tsx +++ b/app/src/app/(auth)/new-account.tsx @@ -4,7 +4,7 @@ import { NewAccountPasswordStep, NewAccountUsernameStep, } from '@/components/auth/NewAccountSections'; -import { useNewAccountScreen } from '@/hooks/auth/useNewAccountScreen'; +import { useNewAccountScreen } from '@/features/auth/useNewAccountScreen'; export default function NewAccount() { const { ui, flow, form, actions } = useNewAccountScreen(); @@ -13,7 +13,7 @@ export default function NewAccount() { const handleCreateAccount = async () => actions.createAccount(); return ( - + {flow.section === 'username' ? ( >['control']; + submitUsername: () => void; + isSubmitting: boolean; + isValid: boolean; +}) { + return ( + // Sizing and centering come from AuthScreen; this only sets inner rhythm. + + + {/* The hero scrim is deliberately light — the card is what carries control + legibility over the photo backdrop, so the copy and field live on it. */} + + + Welcome! + + + Choose a username to continue. + + + + Continue + + + + ); +} + +export default function Onboarding() { + const { control, submitUsername, isValid, isSubmitting } = useOnboardingScreen(); + + return ( + + + + ); +} diff --git a/app/src/app/(auth)/reset-password.tsx b/app/src/app/(auth)/reset-password.tsx new file mode 100644 index 000000000..c1a83286f --- /dev/null +++ b/app/src/app/(auth)/reset-password.tsx @@ -0,0 +1,101 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { Pressable, View } from 'react-native'; +import { AuthBackToLoginAction, AuthCard, AuthFormError } from '@/components/auth/AuthCardSections'; +import { AuthScreen } from '@/components/auth/AuthScreen'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { ControlledTextField } from '@/components/base/ControlledTextField'; +import { Icon } from '@/components/base/Icon'; +import { useResetPassword } from '@/features/auth/usePasswordReset'; +import { useSensitiveAuthToken } from '@/features/auth/useSensitiveAuthToken'; +import { PASSWORD_MIN_LENGTH } from '@/services/api/validation/userSchema'; +import { useAppTheme } from '@/theme'; + +export default function ResetPasswordScreen() { + const theme = useAppTheme(); + const router = useRouter(); + const { token: tokenParam } = useLocalSearchParams<{ token: string }>(); + const token = useSensitiveAuthToken(typeof tokenParam === 'string' ? tokenParam : undefined); + const { control, isValid, isSubmitting, success, error, submit } = useResetPassword(token); + const [showPassword, setShowPassword] = useState(false); + const toggleShowPassword = useCallback(() => setShowPassword((s) => !s), []); + const goToLogin = useCallback(() => router.push('/login'), [router]); + + return ( + + + {success ? ( + + + Password reset. You can now sign in. + + Redirecting to login… + + ) : ( + <> + + New password + + + + + + + + + + + + + + Reset password + + + + + )} + + + ); +} diff --git a/app/src/app/(auth)/verify.tsx b/app/src/app/(auth)/verify.tsx new file mode 100644 index 000000000..0a1a08f4c --- /dev/null +++ b/app/src/app/(auth)/verify.tsx @@ -0,0 +1,70 @@ +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import Animated, { FadeIn, ReduceMotion } from 'react-native-reanimated'; + +// Shared by the error and success branches so the two outcomes can't drift apart. +const RESULT_FADE = FadeIn.duration(250).reduceMotion(ReduceMotion.System); + +import { AuthCard } from '@/components/auth/AuthCardSections'; +import { AuthScreen } from '@/components/auth/AuthScreen'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { useVerifyEmail } from '@/features/auth/useVerifyEmail'; + +export default function VerifyEmailScreen() { + const { isLoading, error, success, isLoggedIn, goToLogin, goHome } = useVerifyEmail(); + + return ( + + + {isLoading ? ( + + + Verifying your email… + + ) : null} + + {error && !isLoading && ( + + + + {error} + + + Back to home + + + + )} + + {success && !isLoading && ( + + + + Email verified! + + {isLoggedIn ? ( + Taking you to your products… + ) : ( + <> + + If you signed up in the app, you're still signed in there — just head back. + + + Sign in here + + + )} + + + )} + + + ); +} + +const styles = StyleSheet.create({ + cardContent: { + alignItems: 'center', + paddingVertical: 32, + }, +}); diff --git a/app/src/app/(tabs)/(account)/_layout.tsx b/app/src/app/(tabs)/(account)/_layout.tsx new file mode 100644 index 000000000..a20696c37 --- /dev/null +++ b/app/src/app/(tabs)/(account)/_layout.tsx @@ -0,0 +1,31 @@ +import { Stack, useRouter } from 'expo-router'; +import { useCallback } from 'react'; +import { HeaderBackButton } from '@/components/base/HeaderBackButton'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; + +/** + * Account tab stack. A one-screen stack rather than a header on the tab + * navigator itself, so the screen keeps the same native stack header as every + * other tab (and HeaderBackButton keeps its native-stack prop type). + */ +export default function AccountTabLayout() { + const router = useRouter(); + const { isLg } = useBreakpoint(); + // Cross-tab target: a replace would resolve above the tab navigator and swap + // the whole thing out, resetting every tab's trail. navigate() jumps to the + // products tab and shows its list, which is what this arrow has always meant. + const goToProducts = useCallback(() => router.navigate('/products'), [router]); + return ( + + {/* TopNav covers this screen on >=lg web. */} + , + headerShown: !isLg, + }} + /> + + ); +} diff --git a/app/src/app/(tabs)/(account)/account/__tests__/account.test.tsx b/app/src/app/(tabs)/(account)/account/__tests__/account.test.tsx new file mode 100644 index 000000000..05ecd147e --- /dev/null +++ b/app/src/app/(tabs)/(account)/account/__tests__/account.test.tsx @@ -0,0 +1,396 @@ +import { act, fireEvent, waitFor } from '@testing-library/react-native'; +import type { ReactNode } from 'react'; +// Imported at the top level, not require()d inside the test: a lazy require +// charges the screen's whole module-transform cost to the first test, which on +// a cold jest cache blows the 15s testTimeout. +import ProfileTab from '@/app/(tabs)/(account)/account'; +import { renderWithProviders } from '@/test-utils/render'; + +// Variables prefixed with 'mock' can be referenced inside jest.mock() factories. +// babel-jest hoists jest.mock() calls but exempts 'mock'-prefixed variables from TDZ. +const mockRefetch = jest.fn(); +const mockSetThemeMode = jest.fn(); +const mockRouterReplace = jest.fn(); +const mockRouterPush = jest.fn(); +const mockRouterNavigate = jest.fn(); +const mockLogout = jest.fn(); +const mockUpdateUser = jest.fn(); +const mockVerify = jest.fn(); +const mockStopStreamMutate = jest.fn(); +const mockUseRpiIntegration = jest.fn(); +const PRIVATE_VISIBILITY_PATTERN = /private/i; +const COMMUNITY_VISIBILITY_PATTERN = /community/i; + +jest.mock('expo-router', () => { + const React = require('react'); + return { + useRouter: () => ({ + push: mockRouterPush, + navigate: mockRouterNavigate, + replace: mockRouterReplace, + back: jest.fn(), + setParams: jest.fn(), + }), + useSegments: () => [], + useLocalSearchParams: jest.fn().mockReturnValue({}), + useNavigation: jest.fn().mockReturnValue({ setOptions: jest.fn() }), + Link: ({ children }: { children: React.ReactNode }) => children, + Redirect: () => null, + Tabs: Object.assign( + ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + { Screen: () => null }, + ), + }; +}); + +jest.mock('@/context/auth', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('@/features/cameras/rpi/useRpiIntegration', () => ({ + useRpiIntegration: () => mockUseRpiIntegration(), +})); + +jest.mock('@/features/cameras/rpi/hooks', () => ({ + useStopYouTubeStreamMutation: () => ({ + mutate: (...args: unknown[]) => mockStopStreamMutate(...args), + isPending: false, + }), +})); + +jest.mock('@/context/themeMode', () => ({ + useThemeMode: () => ({ themeMode: 'auto', setThemeMode: mockSetThemeMode }), + useEffectiveColorScheme: () => 'light', +})); + +jest.mock('@/services/api/profiles', () => ({ + getPublicProfile: jest.fn(), +})); + +// Each export forwards through an arrow rather than being the mock fn itself: +// the factory runs at import time, before the `mock*` consts initialise, so a +// direct reference would throw on TDZ and force every import here to be lazy. +jest.mock('@/services/api/auth/authentication', () => ({ + getToken: jest.fn().mockResolvedValue('mock-token'), + logout: (...args: unknown[]) => mockLogout(...args), + unlinkOAuth: jest.fn().mockResolvedValue(undefined), + updateUser: (...args: unknown[]) => mockUpdateUser(...args), + verify: (...args: unknown[]) => mockVerify(...args), +})); + +jest.mock('expo-web-browser', () => ({ + maybeCompleteAuthSession: jest.fn(), + openAuthSessionAsync: jest.fn().mockResolvedValue({ type: 'cancel' }), +})); + +jest.mock('expo-linking', () => ({ + createURL: jest.fn().mockReturnValue('relab-app://account'), + openURL: jest.fn(), +})); + +jest.mock('@/services/api/client', () => ({ + apiFetch: jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ authorization_url: 'https://oauth.example.com' }), + }), +})); + +// The real KeyboardAwareScrollView needs native modules unavailable in jest — +// mirrors the detail/capture screens' own integration test mocks. +jest.mock('react-native-keyboard-controller', () => { + const mockReact = jest.requireActual('react'); + const { ScrollView } = jest.requireActual('react-native'); + return { + KeyboardAwareScrollView: ({ + children, + ...props + }: { + children?: ReactNode; + [key: string]: unknown; + }) => mockReact.createElement(ScrollView, { ...props, testID: 'account-scroll' }, children), + }; +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const defaultUser = { + id: 'u1', + username: 'tester', + email: 'test@example.com', + isActive: true, + isSuperuser: false, + isVerified: false, + oauth_accounts: [], + preferences: { profile_visibility: 'public', theme_mode: 'auto', email_updates_enabled: false }, +}; + +function renderProfileTab() { + return renderWithProviders(, { withDialog: true }); +} + +/** Render the profile tab and wait for all initial async effects to settle. */ +async function renderProfile() { + const result = renderProfileTab(); + // Flush pending microtasks so profile stats loading effects settle + // inside act() and don't trigger "not wrapped in act" warnings. + await act(async () => {}); + return result; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ProfileTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + const { useAuth } = require('@/context/auth.ts'); + (useAuth as jest.Mock).mockReturnValue({ user: defaultUser, refetch: mockRefetch }); + mockRefetch.mockResolvedValue(undefined); + mockUpdateUser.mockResolvedValue({}); + mockVerify.mockResolvedValue(true); + mockLogout.mockResolvedValue(undefined); + mockStopStreamMutate.mockImplementation((...args: unknown[]) => { + const options = args[1] as { onSuccess?: () => void } | undefined; + options?.onSuccess?.(); + }); + mockUseRpiIntegration.mockReturnValue({ + enabled: false, + loading: false, + setEnabled: jest.fn(), + }); + + const { getPublicProfile } = require('@/services/api/profiles.ts'); + (getPublicProfile as jest.Mock).mockResolvedValue({ + username: 'tester', + created_at: '', + product_count: 3, + total_weight_kg: 1.5, + image_count: 7, + top_category: 'Electronics', + }); + }); + + it('renders username and email', async () => { + const { findByText } = await renderProfile(); + expect(await findByText('tester.')).toBeTruthy(); + expect(await findByText('test@example.com')).toBeTruthy(); + }); + + it('shows verify action when user is not verified', async () => { + const { findByText } = await renderProfile(); + expect(await findByText('Verify email address')).toBeTruthy(); + }); + + it('does not show verify action when user is already verified', async () => { + const { useAuth } = require('@/context/auth.ts'); + (useAuth as jest.Mock).mockReturnValue({ + user: { ...defaultUser, isVerified: true }, + refetch: mockRefetch, + }); + const { queryByText } = await renderProfile(); + expect(queryByText('Verify email address')).toBeNull(); + }); + + it('redirects to login when there is no authenticated user', async () => { + const { useAuth } = require('@/context/auth.ts'); + (useAuth as jest.Mock).mockReturnValue({ user: null, refetch: mockRefetch }); + + await renderProfile(); + + expect(mockRouterReplace).toHaveBeenCalledWith({ + pathname: '/login', + params: { redirectTo: '/account' }, + }); + }); + + describe('stats section', () => { + it('displays stats after they finish loading', async () => { + const { findByText } = await renderProfile(); + // product_count: 3 from the mock + expect(await findByText('3')).toBeTruthy(); + }); + + it('shows a skeleton placeholder while stats are fetching', async () => { + const { getPublicProfile } = require('@/services/api/profiles.ts'); + // Stall getPublicProfile so the loading state stays visible + (getPublicProfile as jest.Mock).mockReturnValue(new Promise(() => {})); + + const { getAllByTestId } = renderProfileTab(); + // statsLoading=true renders a Skeleton for each of the four stat values + expect(getAllByTestId('stat-value-skeleton')).toHaveLength(4); + // Settle the stats effect to avoid act() warnings + await act(async () => {}); + }); + }); + + describe('appearance / theme mode', () => { + it('calls setThemeMode("dark") when the Dark option is pressed', async () => { + const { findByLabelText } = await renderProfile(); + fireEvent.press(await findByLabelText('Dark theme')); + expect(mockSetThemeMode).toHaveBeenCalledWith('dark'); + }); + + it('calls setThemeMode("light") when the Light option is pressed', async () => { + const { findByLabelText } = await renderProfile(); + fireEvent.press(await findByLabelText('Light theme')); + expect(mockSetThemeMode).toHaveBeenCalledWith('light'); + }); + + it('calls setThemeMode("auto") when the Auto option is pressed', async () => { + const { findByLabelText } = await renderProfile(); + fireEvent.press(await findByLabelText('Auto theme')); + expect(mockSetThemeMode).toHaveBeenCalledWith('auto'); + }); + }); + + describe('profile visibility', () => { + it('calls updateUser when a visibility option is pressed', async () => { + // Visibility Pressables have accessibilityRole="radio" but no accessibilityLabel; + // RTL computes the accessible name from child text content, so we match by regex. + const { findByRole } = await renderProfile(); + fireEvent.press(await findByRole('radio', { name: PRIVATE_VISIBILITY_PATTERN })); + await waitFor(() => { + expect(mockUpdateUser).toHaveBeenCalledWith( + expect.objectContaining({ + preferences: expect.objectContaining({ profile_visibility: 'private' }), + }), + ); + }); + }); + + it('calls updateUser and refetch for the Community option', async () => { + const { findByRole } = await renderProfile(); + fireEvent.press(await findByRole('radio', { name: COMMUNITY_VISIBILITY_PATTERN })); + await waitFor(() => { + expect(mockUpdateUser).toHaveBeenCalledWith( + expect.objectContaining({ + preferences: expect.objectContaining({ profile_visibility: 'community' }), + }), + ); + expect(mockRefetch).toHaveBeenCalledWith(false); + }); + }); + }); + + describe('email updates', () => { + it('calls updateUser when the email updates switch is toggled', async () => { + const { findByRole } = await renderProfile(); + const emailSwitch = await findByRole('switch', { name: 'Receive Relab account updates' }); + fireEvent.press(emailSwitch); + await waitFor(() => { + expect(mockUpdateUser).toHaveBeenCalledWith( + expect.objectContaining({ + preferences: expect.objectContaining({ email_updates_enabled: true }), + }), + ); + }); + }); + }); + + describe('logout flow', () => { + it('opens the logout dialog when Logout is pressed', async () => { + const { findByLabelText, findByText } = await renderProfile(); + fireEvent.press(await findByLabelText('Sign out')); + expect(await findByText('Are you sure you want to sign out?')).toBeTruthy(); + }); + + it('calls logout and triggers refetch on confirm', async () => { + const { findByLabelText, findAllByText } = await renderProfile(); + // Open the logout dialog + fireEvent.press(await findByLabelText('Sign out')); + // The dialog renders a second "Sign out" button (the confirm button) + const logoutButtons = await findAllByText('Sign out'); + await act(async () => { + fireEvent.press(logoutButtons[logoutButtons.length - 1]); + }); + await waitFor(() => { + expect(mockLogout).toHaveBeenCalled(); + }); + }); + }); + + describe('delete account dialog', () => { + it('opens when Delete account? is pressed', async () => { + const { findByLabelText, findByText } = await renderProfile(); + fireEvent.press(await findByLabelText('Delete account?')); + expect(await findByText('Delete account')).toBeTruthy(); + }); + }); + + describe('edit username dialog', () => { + it('opens when the username area is pressed', async () => { + const { findByLabelText, findByText } = await renderProfile(); + fireEvent.press(await findByLabelText('Edit username')); + expect(await findByText('Save')).toBeTruthy(); + }); + }); + + describe('security section', () => { + it('offers only enrolment while two-step verification is off', async () => { + const { findByLabelText, queryByLabelText } = await renderProfile(); + + expect(await findByLabelText('Two-step verification')).toBeTruthy(); + expect(queryByLabelText('Turn off two-step verification')).toBeNull(); + }); + + it('offers the manage actions once two-step verification is on', async () => { + const { useAuth } = require('@/context/auth.ts'); + (useAuth as jest.Mock).mockReturnValue({ + user: { ...defaultUser, mfaEnabled: true }, + refetch: mockRefetch, + }); + + const { findByLabelText, findByText } = await renderProfile(); + + expect(await findByText('On — you enter a code at login')).toBeTruthy(); + expect(await findByLabelText('Generate new recovery codes')).toBeTruthy(); + expect(await findByLabelText('Reset authenticator key')).toBeTruthy(); + expect(await findByLabelText('Turn off two-step verification')).toBeTruthy(); + }); + }); + + describe('integrations', () => { + it('hides the camera and YouTube rows while the RPi integration is off', async () => { + const { queryByLabelText, queryByText } = await renderProfile(); + + expect(queryByLabelText('Manage cameras')).toBeNull(); + expect(queryByText('YouTube Live')).toBeNull(); + }); + + it('reveals the camera and YouTube rows once the RPi integration is on', async () => { + mockUseRpiIntegration.mockReturnValue({ + enabled: true, + loading: false, + setEnabled: jest.fn(), + }); + + const { findByLabelText, findByText } = await renderProfile(); + + expect(await findByText('YouTube Live')).toBeTruthy(); + fireEvent.press(await findByLabelText('Manage cameras')); + expect(mockRouterNavigate).toHaveBeenCalledWith('/cameras'); + }); + }); + + describe('linked accounts', () => { + it('shows "Link Google account" when Google is not linked', async () => { + const { findByText } = await renderProfile(); + expect(await findByText('Link Google account')).toBeTruthy(); + }); + + it('shows "Unlink Google" and opens the dialog when Google is linked', async () => { + const { useAuth } = require('@/context/auth.ts'); + (useAuth as jest.Mock).mockReturnValue({ + user: { + ...defaultUser, + oauth_accounts: [{ oauth_name: 'google', account_email: 'g@example.com' }], + }, + refetch: mockRefetch, + }); + + const { findByLabelText, findByText } = await renderProfile(); + fireEvent.press(await findByLabelText('Unlink Google')); + expect(await findByText('Unlink account')).toBeTruthy(); + }); + }); +}); diff --git a/app/src/app/(tabs)/(account)/account/index.tsx b/app/src/app/(tabs)/(account)/account/index.tsx new file mode 100644 index 000000000..8abaeafe6 --- /dev/null +++ b/app/src/app/(tabs)/(account)/account/index.tsx @@ -0,0 +1,8 @@ +import { maybeCompleteAuthSession } from 'expo-web-browser'; +import { AccountScreen } from '@/components/profile/AccountScreen'; + +maybeCompleteAuthSession({ skipRedirectCheck: true }); + +export default function ProfileTab() { + return ; +} diff --git a/app/src/app/(tabs)/(cameras)/_layout.tsx b/app/src/app/(tabs)/(cameras)/_layout.tsx new file mode 100644 index 000000000..235bb3cf2 --- /dev/null +++ b/app/src/app/(tabs)/(cameras)/_layout.tsx @@ -0,0 +1,33 @@ +import { Stack, useRouter } from 'expo-router'; +import { useCallback } from 'react'; +import { HeaderBackButton } from '@/components/base/HeaderBackButton'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; + +/** Cameras tab stack: list, pairing and camera detail. */ +export default function CamerasTabLayout() { + const router = useRouter(); + const { isLg } = useBreakpoint(); + // Same-stack target, so this stays a replace: it resolves inside this + // navigator and leaves the other tabs' trails alone. + const goToCameras = useCallback(() => router.replace('/cameras'), [router]); + return ( + + {/* TopNav covers the list on >=lg web; the other screens always keep their header. */} + + , + }} + /> + , + }} + /> + + ); +} diff --git a/app/src/app/(tabs)/(cameras)/cameras/[id].tsx b/app/src/app/(tabs)/(cameras)/cameras/[id].tsx new file mode 100644 index 000000000..70a761453 --- /dev/null +++ b/app/src/app/(tabs)/(cameras)/cameras/[id].tsx @@ -0,0 +1,114 @@ +import { useCallback, useRef } from 'react'; +import type { View } from 'react-native'; +import { CenteredSpinner } from '@/components/base/CenteredSpinner'; +import { ErrorState } from '@/components/base/ErrorState'; +import { + CameraConnectionCard, + CameraPreviewSection, +} from '@/components/cameras/detail/ConnectionPreview'; +import { CameraDetailDialogs } from '@/components/cameras/detail/Dialogs'; +import { CameraDetailLayout } from '@/components/cameras/detail/detailRows'; +import { + CameraDangerZone, + CameraDetailsCard, + CameraStreamingSection, +} from '@/components/cameras/detail/StreamingDetails'; +import { useCameraDetailScreen } from '@/features/cameras/useCameraDetailScreen'; +import { getErrorMessage } from '@/utils/errors'; + +function CameraDetailContent({ + screen, + preview, + dialogs, + actions, +}: ReturnType) { + const camera = screen.camera; + const deleteTriggerRef = useRef(null); + const manualSetupTriggerRef = useRef(null); + const onEditName = useCallback( + () => actions.promptRename(screen.renameTriggerRef), + [actions, screen.renameTriggerRef], + ); + const onEditDescription = useCallback( + () => actions.promptEditDescription(screen.descriptionTriggerRef), + [actions, screen.descriptionTriggerRef], + ); + if (!camera) return null; + + return ( + <> + + + + + + + + + + + + + + + ); +} + +export default function CameraDetailScreen() { + const { screen, preview, dialogs, actions } = useCameraDetailScreen(); + + // useCameraDetailScreen's useRequireAuth('/cameras') fires the redirect; AuthProvider + // already blocks rendering until the initial auth check resolves, so this can + // only be hit for the one-render window before that redirect completes. + if (!screen.user) return ; + if (screen.isLoading) return ; + + if (screen.isError || !screen.camera) { + return ( + + ); + } + + return ( + + ); +} diff --git a/app/src/app/(tabs)/(cameras)/cameras/__tests__/add.test.tsx b/app/src/app/(tabs)/(cameras)/cameras/__tests__/add.test.tsx new file mode 100644 index 000000000..b7589eabb --- /dev/null +++ b/app/src/app/(tabs)/(cameras)/cameras/__tests__/add.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useRouter } from 'expo-router'; +import AddCameraScreen from '@/app/(tabs)/(cameras)/cameras/add'; +import { renderWithProviders } from '@/test-utils/index'; + +const mockUseAuth = jest.fn(); +const mockUseClaimPairingMutation = jest.fn(); + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/features/cameras/rpi/hooks', () => ({ + useClaimPairingMutation: () => mockUseClaimPairingMutation(), + useCamerasQuery: jest.fn(), + useCaptureAllMutation: jest.fn(), +})); + +describe('AddCameraScreen', () => { + const mockPush = jest.fn(); + const mockReplace = jest.fn(); + const claimMutate = jest.fn(); + const alertSpy = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(global, 'alert', { + configurable: true, + value: alertSpy, + }); + (useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + replace: mockReplace, + back: jest.fn(), + setParams: jest.fn(), + dismissTo: jest.fn(), + }); + mockUseAuth.mockReturnValue({ + user: { id: 'user-1', email: 'test@example.com' }, + }); + + mockUseClaimPairingMutation.mockReturnValue({ + mutate: claimMutate, + isPending: false, + }); + }); + + it('submits the pairing flow with sanitized uppercase codes', async () => { + renderWithProviders(); + + const pairingCodeInput = screen.getByLabelText('Pairing code'); + const cameraNameInput = screen.getByLabelText('Camera name, required'); + const descriptionInput = screen.getByLabelText('Description (optional)'); + fireEvent.changeText(pairingCodeInput, 'ab-12cd9'); + fireEvent.changeText(cameraNameInput, 'Workbench Camera'); + fireEvent.changeText(descriptionInput, 'Bench setup'); + + fireEvent.press(screen.getByText('Pair camera')); + + await waitFor(() => + expect(claimMutate).toHaveBeenCalledWith( + { + code: 'AB12CD', + camera_name: 'Workbench Camera', + description: 'Bench setup', + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ), + ); + }); + + it('alerts on pairing error', async () => { + renderWithProviders(); + + const pairingCodeInput = screen.getByLabelText('Pairing code'); + const cameraNameInput = screen.getByLabelText('Camera name, required'); + fireEvent.changeText(pairingCodeInput, 'AB12CD'); + fireEvent.changeText(cameraNameInput, 'Test Camera'); + fireEvent.press(screen.getByText('Pair camera')); + + await waitFor(() => expect(claimMutate).toHaveBeenCalled()); + const pairOnError = ( + claimMutate.mock.calls[0]?.[1] as { onError?: (err: unknown) => void } | undefined + )?.onError; + pairOnError?.(new Error('pairing failed')); + expect(alertSpy).toHaveBeenCalledWith('pairing failed'); + }); + + it('dismisses the pairing success dialog and navigates to the camera list', async () => { + renderWithProviders(); + + const pairingCodeInput = screen.getByLabelText('Pairing code'); + const cameraNameInput = screen.getByLabelText('Camera name, required'); + fireEvent.changeText(pairingCodeInput, 'AB12CD'); + fireEvent.changeText(cameraNameInput, 'Test Camera'); + fireEvent.press(screen.getByText('Pair camera')); + + await waitFor(() => expect(claimMutate).toHaveBeenCalled()); + const onSuccess = (claimMutate.mock.calls[0]?.[1] as { onSuccess?: () => void } | undefined) + ?.onSuccess; + await act(async () => { + onSuccess?.(); + }); + + expect(await screen.findByText('Camera paired')).toBeOnTheScreen(); + + fireEvent.press(screen.getByText('Done')); + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/cameras')); + }); + + it('redirects unauthenticated users to login', async () => { + mockUseAuth.mockReturnValue({ user: undefined }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith({ + pathname: '/login', + params: { redirectTo: '/cameras' }, + }); + }); + }); +}); diff --git a/frontend-app/src/__tests__/screens/camera-detail.integration.test.tsx b/app/src/app/(tabs)/(cameras)/cameras/__tests__/camera-detail.integration.test.tsx similarity index 83% rename from frontend-app/src/__tests__/screens/camera-detail.integration.test.tsx rename to app/src/app/(tabs)/(cameras)/cameras/__tests__/camera-detail.integration.test.tsx index c1efb6d18..59c828701 100644 --- a/frontend-app/src/__tests__/screens/camera-detail.integration.test.tsx +++ b/app/src/app/(tabs)/(cameras)/cameras/__tests__/camera-detail.integration.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { act, fireEvent, screen } from '@testing-library/react-native'; import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; -import CameraDetailScreen from '@/app/cameras/[id]'; +import CameraDetailScreen from '@/app/(tabs)/(cameras)/cameras/[id]'; import { renderWithProviders } from '@/test-utils/index'; const FAILED_TO_LOAD_CAMERA_PATTERN = /Failed to load camera/; @@ -24,13 +24,13 @@ jest.mock('@/context/auth', () => ({ useAuth: () => mockUseAuth(), })); -jest.mock('@/hooks/useRpiCameras', () => ({ +jest.mock('@/features/cameras/rpi/hooks', () => ({ useCameraQuery: (...args: unknown[]) => mockUseCameraQuery(...args), useUpdateCameraMutation: () => mockUseUpdateCameraMutation(), useDeleteCameraMutation: () => mockUseDeleteCameraMutation(), })); -jest.mock('@/hooks/useEffectiveCameraConnection', () => ({ +jest.mock('@/features/cameras/useEffectiveCameraConnection', () => ({ useEffectiveCameraConnection: (...args: unknown[]) => mockUseEffectiveCameraConnection(...args), })); @@ -68,7 +68,6 @@ describe('Camera detail screen', () => { localConnection: { mode: 'relay', localBaseUrl: null, - localMediaUrl: null, localApiKey: null, configure: jest.fn(), clearLocalConnection: jest.fn(), @@ -114,26 +113,28 @@ describe('Camera detail screen', () => { mockUseEffectiveCameraConnection.mockReturnValue(makeEffectiveConnection()); }); - it('renders the live preview component for an online camera and sets the screen title', async () => { - renderWithProviders(); + it('does not auto-load the live preview for an online camera and sets the screen title', async () => { + renderWithProviders(, { withDialog: true }); - expect(screen.getByText('live-preview-stub')).toBeOnTheScreen(); + // Preview is opt-in: nothing streams until the user taps "Load preview". + expect(screen.queryByText('live-preview-stub')).toBeNull(); + expect(screen.getByText('Load preview')).toBeOnTheScreen(); expect(mockNavigationSetOptions).toHaveBeenCalledWith({ title: 'Workbench Camera' }); }); - it('can stop and restart the live preview without leaving the detail screen', async () => { - renderWithProviders(); - - expect(screen.getByText('live-preview-stub')).toBeOnTheScreen(); - - fireEvent.press(screen.getByText('Stop Preview')); + it('can load and stop the live preview without leaving the detail screen', async () => { + renderWithProviders(, { withDialog: true }); expect(screen.queryByText('live-preview-stub')).toBeNull(); - expect(screen.getByText('Load Preview')).toBeOnTheScreen(); - fireEvent.press(screen.getByText('Load Preview')); + fireEvent.press(screen.getByText('Load preview')); expect(screen.getByText('live-preview-stub')).toBeOnTheScreen(); + + fireEvent.press(screen.getByText('Stop preview')); + + expect(screen.queryByText('live-preview-stub')).toBeNull(); + expect(screen.getByText('Load preview')).toBeOnTheScreen(); }); it('shows websocket offline helper copy and supports retrying camera status', async () => { @@ -161,7 +162,7 @@ describe('Camera detail screen', () => { canUseRelay: false, }), ); - renderWithProviders(); + renderWithProviders(, { withDialog: true }); expect(screen.getByText('Waiting for camera to connect via WebSocket relay')).toBeOnTheScreen(); @@ -171,7 +172,7 @@ describe('Camera detail screen', () => { }); it('opens the edit-name dialog and saves the trimmed camera name', async () => { - renderWithProviders(); + renderWithProviders(, { withDialog: true }); fireEvent.press(screen.getByLabelText('Edit name')); @@ -184,7 +185,7 @@ describe('Camera detail screen', () => { expect(mockUpdateMutate).toHaveBeenCalledWith( { name: 'Studio Camera' }, expect.objectContaining({ - onSuccess: expect.any(Function), + onError: expect.any(Function), }), ); }); @@ -208,7 +209,7 @@ describe('Camera detail screen', () => { }), ); - renderWithProviders(); + renderWithProviders(, { withDialog: true }); expect(screen.queryByText('Workbench Camera')).toBeNull(); expect(screen.queryByText('Delete camera')).toBeNull(); @@ -233,7 +234,7 @@ describe('Camera detail screen', () => { }), ); - renderWithProviders(); + renderWithProviders(, { withDialog: true }); expect(screen.getByText(FAILED_TO_LOAD_CAMERA_PATTERN)).toBeOnTheScreen(); fireEvent.press(screen.getByText('Retry')); @@ -241,7 +242,7 @@ describe('Camera detail screen', () => { }); it('opens the edit-description dialog and saves the trimmed description', async () => { - renderWithProviders(); + renderWithProviders(, { withDialog: true }); fireEvent.press(screen.getByLabelText('Edit description')); @@ -253,7 +254,7 @@ describe('Camera detail screen', () => { expect(mockUpdateMutate).toHaveBeenCalledWith( { description: 'Updated description' }, - expect.objectContaining({ onSuccess: expect.any(Function) }), + expect.objectContaining({ onError: expect.any(Function) }), ); }); @@ -276,10 +277,12 @@ describe('Camera detail screen', () => { }), ); - renderWithProviders(); + renderWithProviders(, { withDialog: true }); - // String(null) = 'null' so the fallback text never shows, but the error - // view branch (isError || !camera) is still entered — confirm via Retry. + // error is null (not an Error), so getErrorMessage falls back to + // 'Camera not found.' — confirm the error view branch (isError || !camera) + // is entered via Retry. + expect(screen.getByText('Camera not found.')).toBeOnTheScreen(); expect(screen.getByText('Retry')).toBeOnTheScreen(); fireEvent.press(screen.getByText('Retry')); expect(mockRefetch).toHaveBeenCalled(); @@ -311,13 +314,13 @@ describe('Camera detail screen', () => { }), ); - renderWithProviders(); + renderWithProviders(, { withDialog: true }); expect(screen.getAllByText('Offline').length).toBeGreaterThan(0); }); it('dismisses the edit-name dialog when Cancel is pressed', async () => { - renderWithProviders(); + renderWithProviders(, { withDialog: true }); fireEvent.press(screen.getByLabelText('Edit name')); expect(screen.getByText('Edit name')).toBeOnTheScreen(); @@ -328,7 +331,7 @@ describe('Camera detail screen', () => { }); it('dismisses the edit-description dialog when Cancel is pressed', async () => { - renderWithProviders(); + renderWithProviders(, { withDialog: true }); fireEvent.press(screen.getByLabelText('Edit description')); expect(screen.getByText('Edit description')).toBeOnTheScreen(); @@ -339,7 +342,7 @@ describe('Camera detail screen', () => { }); it('dismisses the delete dialog when Cancel is pressed', async () => { - renderWithProviders(); + renderWithProviders(, { withDialog: true }); fireEvent.press(screen.getByText('Delete camera')); expect(screen.getByText('Delete camera?')).toBeOnTheScreen(); @@ -364,7 +367,7 @@ describe('Camera detail screen', () => { options?.onSuccess?.(); }); - renderWithProviders(); + renderWithProviders(, { withDialog: true }); fireEvent.press(screen.getByText('Delete camera')); expect(screen.getByText('Delete camera?')).toBeOnTheScreen(); diff --git a/app/src/app/(tabs)/(cameras)/cameras/__tests__/index.test.tsx b/app/src/app/(tabs)/(cameras)/cameras/__tests__/index.test.tsx new file mode 100644 index 000000000..a329c068f --- /dev/null +++ b/app/src/app/(tabs)/(cameras)/cameras/__tests__/index.test.tsx @@ -0,0 +1,470 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import CamerasScreen from '@/app/(tabs)/(cameras)/cameras/index'; +import { renderWithProviders } from '@/test-utils/index'; + +const SELECTED_PATTERN = /selected/; + +const mockUseAuth = jest.fn(); +const mockUseCamerasQuery = jest.fn(); +const mockUseLocalConnection = jest.fn(); +const mockCaptureMutate = jest.fn(); +const mockUseBreakpoint = jest.fn(() => ({ isMd: false, isLg: false })); + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/features/cameras/rpi/hooks', () => ({ + useCamerasQuery: (...args: unknown[]) => mockUseCamerasQuery(...args), + useCaptureAllMutation: () => ({ + mutate: mockCaptureMutate, + isPending: false, + }), +})); + +jest.mock('@/features/cameras/local-connection/useLocalConnection', () => ({ + useLocalConnection: (...args: unknown[]) => mockUseLocalConnection(...args), +})); + +jest.mock('@/hooks/useBreakpoint', () => ({ + useBreakpoint: () => mockUseBreakpoint(), +})); + +jest.mock('@/components/base/CenteredSpinner', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + CenteredSpinner: () => React.createElement(View, { testID: 'cameras-loading-state' }), + }; +}); + +jest.mock('@/components/base/ErrorState', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + return { + ErrorState: ({ message, onRetry }: { message: string; onRetry: () => void }) => + React.createElement( + View, + null, + React.createElement(Text, null, message), + React.createElement( + Pressable, + { accessibilityRole: 'button', onPress: onRetry }, + React.createElement(Text, null, 'Retry'), + ), + ), + }; +}); + +jest.mock('@/components/cameras/GoLiveDialog', () => ({ + GoLiveDialog: () => null, +})); + +describe('CamerasScreen', () => { + const mockPush = jest.fn(); + const mockReplace = jest.fn(); + const mockRefetch = jest.fn(); + const mockSetOptions = jest.fn(); + + const camerasQuery = (over: Record = {}) => ({ + data: [], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + ...over, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + replace: mockReplace, + back: jest.fn(), + setParams: jest.fn(), + dismissTo: jest.fn(), + }); + (useNavigation as jest.Mock).mockReturnValue({ + setOptions: mockSetOptions, + }); + mockUseAuth.mockReturnValue({ + user: { id: 'user-1', email: 'test@example.com' }, + }); + mockUseCamerasQuery.mockReturnValue(camerasQuery()); + mockUseLocalConnection.mockReturnValue({ + mode: 'relay', + localBaseUrl: null, + }); + mockUseBreakpoint.mockReturnValue({ isMd: false, isLg: false }); + }); + + it('shows an empty state and lets the user navigate to add a camera', () => { + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText('No cameras yet')).toBeOnTheScreen(); + expect( + screen.getByText('Tap the + button to register your first RPi camera.'), + ).toBeOnTheScreen(); + expect(mockSetOptions).toHaveBeenCalled(); + expect(mockSetOptions.mock.calls[0][0]).toEqual( + expect.objectContaining({ title: 'My cameras' }), + ); + + fireEvent.press(screen.getByLabelText('Add camera')); + + expect(mockPush).toHaveBeenCalledWith('/cameras/add'); + }); + + it('renders camera cards and navigates to the detail screen', () => { + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [ + { + id: 'cam-1', + name: 'Workbench Camera', + description: 'Bench setup', + status: { connection: 'online' }, + }, + ], + }), + ); + + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText('Workbench Camera')).toBeOnTheScreen(); + expect(screen.getByText('Online')).toBeOnTheScreen(); + + fireEvent.press(screen.getByLabelText('Camera: Workbench Camera')); + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/cameras/[id]', + params: { id: 'cam-1' }, + }); + }); + + it('treats a locally reachable camera as online even when relay status is offline', () => { + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [ + { + id: 'cam-1', + name: 'Direct Camera', + description: 'Ethernet setup', + status: { connection: 'offline', last_seen_at: null, details: null }, + }, + ], + }), + ); + mockUseLocalConnection.mockReturnValue({ + mode: 'local', + localBaseUrl: 'http://192.168.7.1:8018', + }); + + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText('Direct Camera')).toBeOnTheScreen(); + expect(screen.getByText('Online')).toBeOnTheScreen(); + expect(screen.getByText('Direct connection')).toBeOnTheScreen(); + expect(screen.queryByText('Offline')).toBeNull(); + }); + + it('shows an error state and retries loading', async () => { + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: undefined, + isError: true, + error: new Error('Broken camera list'), + }), + ); + + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText('Broken camera list')).toBeOnTheScreen(); + fireEvent.press(screen.getByText('Retry')); + + await waitFor(() => expect(mockRefetch).toHaveBeenCalled()); + }); + + it('shows loading spinner and no camera list when isLoading is true', () => { + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: undefined, + isLoading: true, + }), + ); + renderWithProviders(, { withDialog: true }); + // Loading state renders an ActivityIndicator; no list or empty-state text + expect(screen.queryByText('No cameras yet')).toBeNull(); + expect(screen.queryByText('Retry')).toBeNull(); + }); + + it('parses array product param and enables capture mode', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: ['42'] }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + }), + ); + + renderWithProviders(, { withDialog: true }); + + // Long-press a camera in capture mode → enters selection mode → SelectionBar appears + fireEvent(screen.getByLabelText('Camera: Cam'), 'longPress'); + expect(screen.getByText('1 selected')).toBeOnTheScreen(); + }); + + it('does not enable capture mode for non-numeric product param', () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: 'not-a-number' }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + }), + ); + + renderWithProviders(, { withDialog: true }); + + // Long-press should not enter selection mode (captureModeEnabled=false) + fireEvent(screen.getByLabelText('Camera: Cam'), 'longPress'); + expect(screen.queryByText(SELECTED_PATTERN)).toBeNull(); + }); + + it('shows success snackbar after capture with no failures', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: '7' }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + }), + ); + mockCaptureMutate.mockImplementation((...args: unknown[]) => { + const opts = args[1] as { + onSuccess: (r: { total: number; succeeded: number; failed: number }) => void; + }; + opts.onSuccess({ total: 2, succeeded: 2, failed: 0 }); + }); + + renderWithProviders(, { withDialog: true }); + fireEvent(screen.getByLabelText('Camera: Cam'), 'longPress'); + expect(screen.getByText('1 selected')).toBeOnTheScreen(); + fireEvent.press(screen.getByText('Capture 1')); + + expect(screen.getByText('Captured 2/2 cameras')).toBeOnTheScreen(); + }); + + it('shows partial-failure snackbar when some captures fail', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: '7' }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + }), + ); + mockCaptureMutate.mockImplementation((...args: unknown[]) => { + const opts = args[1] as { + onSuccess: (r: { total: number; succeeded: number; failed: number }) => void; + }; + opts.onSuccess({ total: 3, succeeded: 2, failed: 1 }); + }); + + renderWithProviders(, { withDialog: true }); + fireEvent(screen.getByLabelText('Camera: Cam'), 'longPress'); + expect(screen.getByText('1 selected')).toBeOnTheScreen(); + fireEvent.press(screen.getByText('Capture 1')); + + expect(screen.getByText('Captured 2/3 · 1 failed')).toBeOnTheScreen(); + }); + + it('shows error snackbar on capture mutation error', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: '7' }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + }), + ); + mockCaptureMutate.mockImplementation((...args: unknown[]) => { + const opts = args[1] as { onError: (err: Error) => void }; + opts.onError(new Error('timeout')); + }); + + renderWithProviders(, { withDialog: true }); + fireEvent(screen.getByLabelText('Camera: Cam'), 'longPress'); + expect(screen.getByText('1 selected')).toBeOnTheScreen(); + fireEvent.press(screen.getByText('Capture 1')); + + expect( + screen.getByText('Capture failed — check the cameras are online and try again.'), + ).toBeOnTheScreen(); + }); + + it('redirects unauthenticated users to login', async () => { + mockUseAuth.mockReturnValue({ user: undefined }); + + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith({ + pathname: '/login', + params: { redirectTo: '/cameras' }, + }); + }); + }); + + // ── Selection-mode behaviour ─────────────────────────────────────────────── + + it('long-press on an online card WITHOUT ?product param does not enter selection mode', () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + }), + ); + + renderWithProviders(, { withDialog: true }); + + fireEvent(screen.getByLabelText('Camera: Cam'), 'longPress'); + + expect(screen.queryByText(SELECTED_PATTERN)).toBeNull(); + }); + + it('long-press in selection mode toggles the camera id in selectedIds', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: '7' }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [ + { id: 'cam-1', name: 'Cam A', description: '', status: { connection: 'online' } }, + { id: 'cam-2', name: 'Cam B', description: '', status: { connection: 'online' } }, + ], + }), + ); + + renderWithProviders(, { withDialog: true }); + + // Enter selection mode with cam-1 + fireEvent(screen.getByLabelText('Camera: Cam A'), 'longPress'); + expect(screen.getByText('1 selected')).toBeOnTheScreen(); + + // Long-press cam-2 to add it → 2 selected + fireEvent(screen.getByLabelText('Camera: Cam B'), 'longPress'); + expect(screen.getByText('2 selected')).toBeOnTheScreen(); + + // Long-press cam-1 again to deselect → 1 selected + fireEvent(screen.getByLabelText('Camera: Cam A'), 'longPress'); + expect(screen.getByText('1 selected')).toBeOnTheScreen(); + }); + + it('long-pressing an offline camera in capture mode shows snackbar and does not toggle', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: '7' }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [ + { id: 'cam-1', name: 'Online Cam', description: '', status: { connection: 'online' } }, + { id: 'cam-2', name: 'Offline Cam', description: '', status: { connection: 'offline' } }, + ], + }), + ); + + renderWithProviders(, { withDialog: true }); + + // Long-press the offline camera directly — shows snackbar without entering selection mode + fireEvent(screen.getByLabelText('Camera: Offline Cam'), 'longPress'); + + await waitFor(() => + expect(screen.getByText("Offline Cam is offline — can't capture.")).toBeOnTheScreen(), + ); + // Selection mode must NOT have been entered + expect(screen.queryByText(SELECTED_PATTERN)).toBeNull(); + }); + + it('"Select all" fills selectedIds with exactly the online cameras', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: '7' }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [ + { id: 'cam-1', name: 'Cam A', description: '', status: { connection: 'online' } }, + { id: 'cam-2', name: 'Cam B', description: '', status: { connection: 'online' } }, + { id: 'cam-3', name: 'Cam C', description: '', status: { connection: 'offline' } }, + ], + }), + ); + + renderWithProviders(, { withDialog: true }); + + // Enter selection mode + fireEvent(screen.getByLabelText('Camera: Cam A'), 'longPress'); + expect(screen.getByText('1 selected')).toBeOnTheScreen(); + + // Press "Select all (2)" — 2 online cameras + fireEvent.press(screen.getByLabelText('Select all online cameras')); + + expect(screen.getByText('2 selected')).toBeOnTheScreen(); + }); + + it('"Capture N" fires useCaptureAllMutation with selected ids + productId and clears selection on success', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ product: '7' }); + mockUseCamerasQuery.mockReturnValue( + camerasQuery({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + }), + ); + mockCaptureMutate.mockImplementation((...args: unknown[]) => { + const opts = args[1] as { + onSuccess: (r: { total: number; succeeded: number; failed: number }) => void; + }; + opts.onSuccess({ total: 1, succeeded: 1, failed: 0 }); + }); + + renderWithProviders(, { withDialog: true }); + + fireEvent(screen.getByLabelText('Camera: Cam'), 'longPress'); + expect(screen.getByText('1 selected')).toBeOnTheScreen(); + + fireEvent.press(screen.getByText('Capture 1')); + + await waitFor(() => { + expect(mockCaptureMutate).toHaveBeenCalledWith( + { cameraIds: ['cam-1'], productId: 7 }, + expect.any(Object), + ); + }); + + // Selection is cleared after success + expect(screen.queryByText(SELECTED_PATTERN)).toBeNull(); + }); + + // ── Pull-to-refresh ──────────────────────────────────────────────────────── + + it('pull-to-refresh calls refetch()', async () => { + mockUseCamerasQuery.mockReturnValue(camerasQuery()); + + const { UNSAFE_getByProps } = renderWithProviders(, { withDialog: true }); + + // The FlatList RefreshControl fires onRefresh when pulled + const refreshControl = UNSAFE_getByProps({ refreshing: false }); + fireEvent(refreshControl, 'refresh'); + + await waitFor(() => expect(mockRefetch).toHaveBeenCalled()); + }); + + // ── Column layout ────────────────────────────────────────────────────────── + + it('desktop layout uses 3 columns', () => { + mockUseBreakpoint.mockReturnValue({ isMd: true, isLg: false }); + mockUseCamerasQuery.mockReturnValue(camerasQuery()); + + const { UNSAFE_getByProps } = renderWithProviders(, { withDialog: true }); + + const list = UNSAFE_getByProps({ numColumns: 3 }); + expect(list).toBeTruthy(); + }); + + it('mobile layout uses 2 columns', () => { + mockUseBreakpoint.mockReturnValue({ isMd: false, isLg: false }); + mockUseCamerasQuery.mockReturnValue(camerasQuery()); + + const { UNSAFE_getByProps } = renderWithProviders(, { withDialog: true }); + + const list = UNSAFE_getByProps({ numColumns: 2 }); + expect(list).toBeTruthy(); + }); +}); diff --git a/app/src/app/(tabs)/(cameras)/cameras/add.tsx b/app/src/app/(tabs)/(cameras)/cameras/add.tsx new file mode 100644 index 000000000..bad46c65e --- /dev/null +++ b/app/src/app/(tabs)/(cameras)/cameras/add.tsx @@ -0,0 +1,154 @@ +import { type RefObject, useRef } from 'react'; +import { ScrollView, View } from 'react-native'; +import Animated, { ReduceMotion, ZoomIn } from 'react-native-reanimated'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { AppText } from '@/components/base/AppText'; +import { ControlledTextField } from '@/components/base/ControlledTextField'; +import { DocsLink } from '@/components/base/DocsLink'; +import { Icon } from '@/components/base/Icon'; +import { MutedText } from '@/components/base/MutedText'; +import { PageContainer } from '@/components/base/PageContainer'; +import { Separator } from '@/components/base/ui/separator'; +import { RPI_CAM_DOCS_PATH } from '@/config'; +import { sanitizePairingCode, useAddCameraForm } from '@/features/cameras/useAddCameraForm'; +import { useAppTheme } from '@/theme'; + +function PairingSuccessDialog({ + visible, + onDismiss, + triggerRef, +}: { + visible: boolean; + onDismiss: () => void; + triggerRef: RefObject; +}) { + const theme = useAppTheme(); + return ( + + + + + + + Camera paired + + + Your camera should come online within a few seconds. + + + + + Done + + + + ); +} + +export default function AddCameraScreen() { + const theme = useAppTheme(); + const { user, control, submit, isPending, pairingSuccess, dismissSuccess } = useAddCameraForm(); + const pairButtonRef = useRef(null); + + if (!user) return null; + + return ( + + + + + PAIRING CODE + + + Enter the 6-character code shown on your Raspberry Pi setup page, or read the boxed + “PAIRING READY” banner over SSH if the device is headless. + + + + + + + Camera name * + + + + + Description (optional) + + + + + + + Power on the Raspberry Pi. Read the pairing code from its setup page or its startup + logs. If no code appears, the Pi does not know where to reach this server yet. Ask + whoever set the camera up to point it at this server and restart it. + + + + Camera setup guide + + + + + + Pair camera + + + + + + + + ); +} diff --git a/app/src/app/(tabs)/(cameras)/cameras/index.tsx b/app/src/app/(tabs)/(cameras)/cameras/index.tsx new file mode 100644 index 000000000..707797deb --- /dev/null +++ b/app/src/app/(tabs)/(cameras)/cameras/index.tsx @@ -0,0 +1,84 @@ +import { useCallback, useRef } from 'react'; +import type { View } from 'react-native'; +import { CenteredSpinner } from '@/components/base/CenteredSpinner'; +import { ErrorState } from '@/components/base/ErrorState'; +import { PageContainer } from '@/components/base/PageContainer'; +import { GoLiveDialog } from '@/components/cameras/GoLiveDialog'; +import { SelectionBar } from '@/components/cameras/SelectionBar'; +import { CamerasFab } from '@/components/cameras/screen/Chrome'; +import { CamerasGrid } from '@/components/cameras/screen/Grid'; +import { useCamerasScreen } from '@/features/cameras/useCamerasScreen'; +import { getErrorMessage } from '@/utils/errors'; + +export default function CamerasScreen() { + const { screen, selection, streaming, actions } = useCamerasScreen(); + const { refetch } = screen; + const handleStartStream = async () => streaming.handleStartStream(); + const handleRetry = useCallback(() => refetch(), [refetch]); + const streamTriggerRef = useRef(null); + + // useCamerasScreen's useRequireAuth('/cameras') fires the redirect; AuthProvider + // already blocks rendering until the initial auth check resolves, so this can + // only be hit for the one-render window before that redirect completes. + if (!screen.user) return ; + if (screen.isLoading) return ; + if (screen.isError) { + return ( + + ); + } + + return ( + <> + + + + + + + + + + + ); +} diff --git a/app/src/app/(tabs)/(products)/_layout.tsx b/app/src/app/(tabs)/(products)/_layout.tsx new file mode 100644 index 000000000..fdf772407 --- /dev/null +++ b/app/src/app/(tabs)/(products)/_layout.tsx @@ -0,0 +1,39 @@ +import { Stack } from 'expo-router'; +import { BrandHeaderTitle } from '@/components/base/BrandHeaderTitle'; +import { HeaderRightPill } from '@/components/base/HeaderRightPill'; +import { useEffectiveColorScheme } from '@/context/themeMode'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { getAppTheme } from '@/theme'; +import { getProductsHeaderStyle } from '@/utils/router/styles'; + +/** + * Products tab stack: the /products tree plus the /components tree it links + * into (see (tabs)/_layout.tsx for why they share a navigator). Screens with a + * dynamic title (product/component detail) declare it inline via + * navigation.setOptions instead of here. + */ +export default function ProductsTabLayout() { + const colorScheme = useEffectiveColorScheme(); + const { isLg } = useBreakpoint(); + const theme = getAppTheme(colorScheme); + // TopNav already covers the products list on >=lg web, so the stack's own + // header would just duplicate it. Every other screen keeps its header. + return ( + + , + ...getProductsHeaderStyle(theme), + headerRight: () => , + headerLeft: () => null, + headerShown: !isLg, + }} + /> + + + + + ); +} diff --git a/app/src/app/(tabs)/(products)/components/[id]/__tests__/index.test.tsx b/app/src/app/(tabs)/(products)/components/[id]/__tests__/index.test.tsx new file mode 100644 index 000000000..44e08ebda --- /dev/null +++ b/app/src/app/(tabs)/(products)/components/[id]/__tests__/index.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react-native'; +import type { Text as RNText } from 'react-native'; + +jest.mock('@/components/product/detail/ProductDetailScreen', () => { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + return { + ProductDetailScreen: () => mockReact.createElement(Text, null, 'DetailScreen'), + }; +}); + +import ComponentPage from '@/app/(tabs)/(products)/components/[id]/index'; + +describe('ComponentPage route', () => { + it('renders the shared product detail screen', () => { + render(); + expect(screen.getByText('DetailScreen')).toBeOnTheScreen(); + }); +}); diff --git a/app/src/app/(tabs)/(products)/components/[id]/components/__tests__/new.test.tsx b/app/src/app/(tabs)/(products)/components/[id]/components/__tests__/new.test.tsx new file mode 100644 index 000000000..2b8892af4 --- /dev/null +++ b/app/src/app/(tabs)/(products)/components/[id]/components/__tests__/new.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react-native'; +import type { Text as RNText } from 'react-native'; + +jest.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ id: '42' }), +})); + +jest.mock('@/components/product/capture/CaptureScreen', () => { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + return { + CaptureScreen: (props: { entityRole?: string; parentID?: number; parentRole?: string }) => + mockReact.createElement( + Text, + null, + `role:${props.entityRole ?? ''} parent:${props.parentID ?? ''} parentRole:${props.parentRole ?? ''}`, + ), + }; +}); + +import NestedComponentNewPage from '@/app/(tabs)/(products)/components/[id]/components/new'; + +describe('NestedComponentNewPage route', () => { + it('renders CaptureScreen for a new component and seeds the component-parent context', () => { + render(); + expect(screen.getByText('role:component parent:42 parentRole:component')).toBeOnTheScreen(); + }); +}); diff --git a/app/src/app/(tabs)/(products)/components/[id]/components/new.tsx b/app/src/app/(tabs)/(products)/components/[id]/components/new.tsx new file mode 100644 index 000000000..5aeb28947 --- /dev/null +++ b/app/src/app/(tabs)/(products)/components/[id]/components/new.tsx @@ -0,0 +1,5 @@ +import { NewComponentPage } from '@/components/product/detail/NewComponentPage'; + +export default function NestedComponentNewPage() { + return ; +} diff --git a/app/src/app/(tabs)/(products)/components/[id]/index.tsx b/app/src/app/(tabs)/(products)/components/[id]/index.tsx new file mode 100644 index 000000000..158da7a54 --- /dev/null +++ b/app/src/app/(tabs)/(products)/components/[id]/index.tsx @@ -0,0 +1,5 @@ +import { EntityDetailPage } from '@/components/product/detail/EntityDetailPage'; + +export default function ComponentPage() { + return ; +} diff --git a/app/src/app/(tabs)/(products)/products/[id]/__tests__/index.test.tsx b/app/src/app/(tabs)/(products)/products/[id]/__tests__/index.test.tsx new file mode 100644 index 000000000..052ed538c --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/[id]/__tests__/index.test.tsx @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { screen, waitFor } from '@testing-library/react-native'; +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import type { ReactNode } from 'react'; +import type { ScrollView as RNScrollView, Text as RNText } from 'react-native'; +import ProductPage from '@/app/(tabs)/(products)/products/[id]/index'; +import { getBaseProduct, newProduct } from '@/services/api/products'; +import { renderWithProviders } from '@/test-utils/index'; + +const mockedGetProduct = jest.mocked(getBaseProduct); +const mockedNewProduct = jest.mocked(newProduct); +const mockUseAuth = jest.fn(); + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/services/api/products', () => ({ + getBaseProduct: jest.fn(), + newProduct: jest.fn(), +})); + +jest.mock('@/services/api/saving', () => ({ + deleteProduct: jest.fn(), + saveProduct: jest.fn(), +})); + +jest.mock('@/services/api/validation/productSchema', () => { + const actual = jest.requireActual( + '@/services/api/validation/productSchema', + ); + + return { + ...actual, + getBaseProductNameHelperText: jest.fn(() => 'help text'), + }; +}); + +jest.mock('react-native-keyboard-controller', () => { + const mockReact = jest.requireActual('react'); + const { ScrollView } = jest.requireActual('react-native') as { + ScrollView: typeof RNScrollView; + }; + + return { + KeyboardAwareScrollView: ({ + children, + ...props + }: { + children?: React.ReactNode; + [key: string]: unknown; + }) => mockReact.createElement(ScrollView, props, children), + }; +}); + +function mockCreateSectionStub(label: string) { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + + return function SectionStub({ + children, + ...props + }: { + children?: ReactNode; + [key: string]: unknown; + }) { + return mockReact.createElement(Text, props, children ?? label); + }; +} + +jest.mock('@/components/product/ProductImageGallery', () => + mockCreateSectionStub('ProductImageGallery'), +); +jest.mock('@/components/product/detail/ProductCircularityProperties', () => + mockCreateSectionStub('ProductCircularityProperties'), +); +jest.mock('@/components/product/detail/ProductComponents', () => + mockCreateSectionStub('ProductComponents'), +); +jest.mock('@/components/product/ProductDelete', () => mockCreateSectionStub('ProductDelete')); +jest.mock('@/components/product/detail/ProductPhysicalProperties', () => + mockCreateSectionStub('ProductPhysicalProperties'), +); +jest.mock('@/components/product/detail/ProductTags', () => mockCreateSectionStub('ProductTags')); +jest.mock('@/components/product/detail/ProductType', () => mockCreateSectionStub('ProductType')); +jest.mock('@/components/product/ProductVideo', () => mockCreateSectionStub('ProductVideo')); +jest.mock('@/components/product/detail/ProductMetaData', () => { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + + function ProductMetaDataMock({ product }: { product: { name?: string } }) { + return mockReact.createElement(Text, null, `Meta:${product.name ?? ''}`); + } + + return ProductMetaDataMock; +}); +jest.mock('@/components/product/ProductDescription', () => { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + + function ProductDescriptionMock({ product }: { product: { name?: string } }) { + return mockReact.createElement(Text, null, `Description:${product.name ?? ''}`); + } + + return ProductDescriptionMock; +}); + +const mockReplace = jest.fn(); +const mockSetParams = jest.fn(); +const mockSetOptions = jest.fn(); +const mockAddListener = jest.fn(() => jest.fn()); + +const existingProduct = { + id: 42, + role: 'product' as const, + name: 'Existing Product', + description: 'Loaded from API', + productTypeID: undefined, + componentIDs: [], + components: [], + physicalProperties: { weight: 1, width: 1, height: 1, depth: 1 }, + circularityProperties: { + recyclability: null, + disassemblability: null, + remanufacturability: null, + }, + images: [], + videos: [], + ownedBy: 'someone-else', +}; + +const newProductDraft = { + id: undefined, + role: 'product' as const, + name: 'Draft Product', + description: '', + productTypeID: undefined, + componentIDs: [], + components: [], + physicalProperties: { weight: 1, width: 1, height: 1, depth: 1 }, + circularityProperties: { + recyclability: null, + disassemblability: null, + remanufacturability: null, + }, + images: [], + videos: [], + ownedBy: 'me', +}; + +describe('ProductPage route protection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ user: null }); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + setParams: mockSetParams, + }); + (useNavigation as jest.Mock).mockReturnValue({ + setOptions: mockSetOptions, + canGoBack: jest.fn().mockReturnValue(false), + goBack: jest.fn(), + addListener: mockAddListener, + dispatch: jest.fn(), + }); + mockedNewProduct.mockReturnValue(newProductDraft); + mockedGetProduct.mockResolvedValue(existingProduct); + }); + + it('allows guests to view an existing product route without redirecting', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ id: '42' }); + mockUseAuth.mockReturnValue({ user: null }); + + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(getBaseProduct).toHaveBeenCalledWith(42); + expect(screen.getByText('Description:Existing Product')).toBeOnTheScreen(); + }); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/app/(tabs)/(products)/products/[id]/__tests__/product-page-state.integration.test.tsx b/app/src/app/(tabs)/(products)/products/[id]/__tests__/product-page-state.integration.test.tsx new file mode 100644 index 000000000..a38ebedef --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/[id]/__tests__/product-page-state.integration.test.tsx @@ -0,0 +1,706 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react-native'; +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import type { ReactElement, ReactNode } from 'react'; +import type { Text as RNText } from 'react-native'; +import ProductPage from '@/app/(tabs)/(products)/products/[id]'; +import { ProductDetailScreen } from '@/components/product/detail/ProductDetailScreen'; +import { useBaseProductQuery } from '@/features/products/queries'; +import { useAncestorTrail } from '@/features/products/useAncestorTrail'; +import { useProductForm } from '@/features/products/useProductForm'; +import { ProductNotFoundError } from '@/services/api/products'; +import { baseProduct, renderWithProviders } from '@/test-utils/index'; + +const SLOW_LOADING_PATTERN = /taking longer than usual/i; +const PARENT_PRODUCT_PATTERN = /A parent product/; +const LONG_PRODUCT_NAME_PATTERN = /A very long product name/; +const LONG_PRODUCT_NAME_PREFIX_PATTERN = /^A very long product name/; + +const mockUseProductForm = jest.mocked(useProductForm); +const mockUseBaseProductQuery = jest.mocked(useBaseProductQuery); +const mockUseAncestorTrail = jest.mocked(useAncestorTrail); +const mockUseAuth = jest.fn(); +const mockSetOptions = jest.fn(); +const mockReplace = jest.fn(); + +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(), + useNavigation: jest.fn(), + useRouter: jest.fn(), + // Used by ProductType's own effect; never invokes its callback here. + useFocusEffect: jest.fn(), + // Camera hooks (via CameraPickerDialog) read this for their `subscribed` flag. + useIsFocused: jest.fn(() => true), + // ProductFabControls reads useBottomNavVisible() for its tab-bar clearance; + // segments outside the tab group keep the offset at its base value. + useSegments: jest.fn(() => []), +})); + +const baseFormReturn = { + product: baseProduct, + editMode: false, + isProductComponent: false, + validationResult: { isValid: true, error: '' }, + isLoading: false, + isError: false, + error: '', + refetch: jest.fn(), + isSaving: false, + justSaved: false, + onProductNameChange: jest.fn(), + onChangeDescription: jest.fn(), + onChangePhysicalProperties: jest.fn(), + onChangeCircularityProperties: jest.fn(), + onBrandChange: jest.fn(), + onModelChange: jest.fn(), + onTypeChange: jest.fn(), + onImagesChange: jest.fn(), + onAmountInParentChange: jest.fn(), + onVideoChange: jest.fn(), + saveAndExit: jest.fn(), + onProductDelete: jest.fn(), +}; + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/features/products/useProductForm', () => ({ + useProductForm: jest.fn(), +})); + +jest.mock('@/features/products/queries', () => ({ + useBaseProductQuery: jest.fn(), + useComponentQuery: jest.fn(), +})); + +jest.mock('@/features/products/useAncestorTrail', () => ({ + useAncestorTrail: jest.fn(), +})); + +function mockCreateSectionStub(label: string) { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + + return function SectionStub({ + children, + ...props + }: { + children?: ReactNode; + [key: string]: unknown; + }) { + return mockReact.createElement(Text, props, children ?? label); + }; +} + +// Walks the rendered tree collecting text nodes in document order — used to +// assert section order without depending on a real layout engine. +function collectText(instance: ReturnType): string[] { + const out: string[] = []; + const walk = (node: ReturnType) => { + for (const child of node.children) { + if (typeof child === 'string') { + out.push(child); + } else { + walk(child); + } + } + }; + walk(instance); + return out; +} + +jest.mock('@/components/base/HeaderBackButton', () => { + const mockReact = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + + return { + HeaderBackButton: ({ onPress }: { onPress?: () => void }) => + mockReact.createElement( + Pressable, + { onPress, accessibilityLabel: 'header-back' }, + mockReact.createElement(Text, null, 'Back'), + ), + }; +}); + +jest.mock('react-native-keyboard-controller', () => { + const mockReact = jest.requireActual('react'); + const { ScrollView } = jest.requireActual('react-native'); + + return { + KeyboardAwareScrollView: ({ + children, + ...props + }: { + children?: ReactNode; + [key: string]: unknown; + }) => mockReact.createElement(ScrollView, { ...props, testID: 'product-scroll' }, children), + }; +}); + +jest.mock('@/components/product/detail/ProductCircularityProperties', () => + mockCreateSectionStub('ProductCircularityProperties'), +); +jest.mock('@/components/product/detail/ProductComponents', () => + mockCreateSectionStub('ProductComponents'), +); +jest.mock('@/components/product/ProductDelete', () => mockCreateSectionStub('ProductDelete')); +jest.mock('@/components/product/ProductDescription', () => { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + + function ProductDescriptionMock({ product }: { product: { name?: string } }) { + return mockReact.createElement(Text, null, `Description:${product.name ?? ''}`); + } + + return ProductDescriptionMock; +}); +jest.mock('@/components/product/ProductImageGallery', () => + mockCreateSectionStub('ProductImageGallery'), +); +jest.mock('@/components/product/detail/ProductMetaData', () => { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + + function ProductMetaDataMock({ product }: { product: { name?: string } }) { + return mockReact.createElement(Text, null, `Meta:${product.name ?? ''}`); + } + + return ProductMetaDataMock; +}); +jest.mock('@/components/product/detail/ProductPhysicalProperties', () => + mockCreateSectionStub('ProductPhysicalProperties'), +); +jest.mock('@/components/product/detail/ProductTags', () => mockCreateSectionStub('ProductTags')); +jest.mock('@/components/product/detail/ProductType', () => mockCreateSectionStub('ProductType')); +jest.mock('@/components/product/ProductVideo', () => mockCreateSectionStub('ProductVideo')); + +describe('ProductPage state handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ + user: { + id: '1', + username: 'owner', + email: 'owner@example.com', + isActive: true, + isVerified: true, + isSuperuser: false, + oauth_accounts: [], + }, + }); + (useLocalSearchParams as jest.Mock).mockReturnValue({ id: '42' }); + (useNavigation as jest.Mock).mockReturnValue({ + setOptions: mockSetOptions, + canGoBack: jest.fn().mockReturnValue(false), + goBack: jest.fn(), + addListener: jest.fn(() => jest.fn()), + dispatch: jest.fn(), + }); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + setParams: jest.fn(), + dismissTo: jest.fn(), + }); + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + } as never); + mockUseBaseProductQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + } as never); + mockUseAncestorTrail.mockReturnValue({ ancestors: [], isLoading: false }); + }); + + it('shows the saved FAB icon after a successful save', async () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + justSaved: true, + } as never); + + const { unmount } = renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(screen.getByTestId('icon-check')).toBeOnTheScreen(); + }); + + unmount(); + }); + + // Regression: States rendered `{String(error) || fallback}`, so a non-Error + // value showed "[object Object]" and the friendly fallback was dead code. + it('renders the friendly fallback when the error is not an Error', () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + isError: true, + error: { status: 500 }, + refetch: jest.fn(), + } as never); + + renderWithProviders(, { withDialog: true }); + + expect(screen.queryByText('[object Object]')).toBeNull(); + expect(screen.getByText("Couldn't load the product details.")).toBeOnTheScreen(); + }); + + it('renders the error state and retries the load', () => { + const refetch = jest.fn(); + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + isError: true, + error: new Error('boom'), + refetch, + } as never); + + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText("Couldn't load product")).toBeOnTheScreen(); + // States now formats via getErrorMessage: the message, not `String(error)` + // (which rendered "[object Object]" for a non-Error and made the friendly + // fallback unreachable). + expect(screen.getByText('boom')).toBeOnTheScreen(); + + fireEvent.press(screen.getByText('Try again')); + + expect(refetch).toHaveBeenCalled(); + }); + + it('renders the not-found state for missing products', () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + isError: true, + error: new ProductNotFoundError(42), + } as never); + + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText('Product not found')).toBeOnTheScreen(); + expect( + screen.getByText('This product may have been removed or the link is no longer valid.'), + ).toBeOnTheScreen(); + + fireEvent.press(screen.getByText('Back to products')); + + expect(mockReplace).toHaveBeenCalledWith('/products'); + }); + + it('renders the not-found state with component copy on component routes', () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + isError: true, + error: new ProductNotFoundError(42), + } as never); + + renderWithProviders(, { + withDialog: true, + }); + + expect(screen.getByText('Component not found')).toBeOnTheScreen(); + expect( + screen.getByText('This component may have been removed or the link is no longer valid.'), + ).toBeOnTheScreen(); + }); + + it('collapses the FAB label on scroll down and restores it on scroll up', async () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: { ...baseProduct, ownedBy: 'me' }, + } as never); + + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(screen.getByLabelText('Edit Product')).toBeOnTheScreen(); + }); + + // Fire scroll events to exercise the onScroll handler + fireEvent.scroll(screen.getByLabelText('Edit Product'), { + nativeEvent: { contentOffset: { y: 100 } }, + }); + + // Scroll back to top + fireEvent.scroll(screen.getByLabelText('Edit Product'), { + nativeEvent: { contentOffset: { y: 0 } }, + }); + + // Component doesn't crash and FAB still renders + expect(screen.getByLabelText('Edit Product')).toBeOnTheScreen(); + }); + + it('shows slow-loading card after timeout', async () => { + jest.useFakeTimers(); + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + isLoading: true, + } as never); + + try { + renderWithProviders(, { withDialog: true }); + + // Initially just the skeleton, no slow-loading message + expect(screen.queryByText(SLOW_LOADING_PATTERN)).toBeNull(); + + // Advance fake timers past the 5s threshold inside act() + await act(() => { + jest.advanceTimersByTime(5100); + }); + + expect(screen.getByText(SLOW_LOADING_PATTERN)).toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } + }); + + it('truncates long header labels, renders the component header, and uses the fallback back action', async () => { + const longProductName = + 'A very long product name that absolutely needs truncation for the navigation bar'; + const longParentName = 'A parent product name that also needs truncation'; + + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: { + ...baseProduct, + id: 99, + name: longProductName, + parentID: 17, + }, + isProductComponent: true, + } as never); + mockUseBaseProductQuery.mockReturnValue({ + data: { ...baseProduct, id: 17, name: longParentName }, + isLoading: false, + isError: false, + error: null, + } as never); + mockUseAncestorTrail.mockReturnValue({ + ancestors: [{ id: 17, name: longParentName, role: 'product' }], + isLoading: false, + }); + + (useNavigation as jest.Mock).mockReturnValue({ + setOptions: mockSetOptions, + canGoBack: jest.fn().mockReturnValue(false), + goBack: jest.fn(), + addListener: jest.fn(() => jest.fn()), + dispatch: jest.fn(), + }); + + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(mockSetOptions).toHaveBeenCalled(); + }); + + const setOptionsArg = mockSetOptions.mock.calls.at(-1)?.[0] as { + title?: string; + headerLeft?: () => ReactElement; + headerTitle?: () => ReactElement; + }; + + expect(setOptionsArg.title).toBeUndefined(); + expect(setOptionsArg.headerTitle).toBeInstanceOf(Function); + expect(setOptionsArg.headerLeft).toBeInstanceOf(Function); + + renderWithProviders(setOptionsArg.headerTitle?.() as ReactElement, { withDialog: true }); + + expect(screen.getByText(PARENT_PRODUCT_PATTERN)).toBeOnTheScreen(); + expect(screen.getByText(LONG_PRODUCT_NAME_PATTERN)).toBeOnTheScreen(); + + renderWithProviders(setOptionsArg.headerLeft?.() as ReactElement, { + withDialog: true, + }); + + fireEvent.press(screen.getByLabelText('header-back')); + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith({ + pathname: '/products/[id]', + params: { id: '17' }, + }); + }); + }); + + it('does not render the video card for components', () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: { + ...baseProduct, + id: 99, + role: 'component', + parentID: 17, + }, + isProductComponent: true, + } as never); + + renderWithProviders(, { withDialog: true }); + + expect(screen.queryByText('ProductVideo')).toBeNull(); + }); + + it('truncates the navigation title for regular products', async () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: { + ...baseProduct, + id: 100, + name: 'A very long product name that absolutely needs truncation for the navigation bar', + }, + } as never); + + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(mockSetOptions).toHaveBeenCalled(); + }); + + const setOptionsArg = mockSetOptions.mock.calls.at(-1)?.[0] as { + title?: string; + headerLeft?: () => ReactElement; + }; + + expect(setOptionsArg.title).toMatch(LONG_PRODUCT_NAME_PREFIX_PATTERN); + + renderWithProviders(setOptionsArg.headerLeft?.() as ReactElement, { withDialog: true }); + fireEvent.press(screen.getByLabelText('header-back')); + expect(mockReplace).toHaveBeenCalledWith('/products'); + }); + + it('warns before leaving when there are unsaved edits', async () => { + let beforeRemoveHandler: + | ((event: { preventDefault: () => void; data: { action: unknown } }) => void) + | undefined; + + (useNavigation as jest.Mock).mockReturnValue({ + setOptions: mockSetOptions, + canGoBack: jest.fn().mockReturnValue(false), + goBack: jest.fn(), + addListener: jest.fn((event: string, handler: typeof beforeRemoveHandler) => { + if (event === 'beforeRemove') beforeRemoveHandler = handler; + return jest.fn(); + }), + dispatch: jest.fn(), + }); + + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + editMode: true, + isDirty: true, + } as never); + + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(beforeRemoveHandler).toBeDefined(); + }); + + const preventDefault = jest.fn(); + await act(async () => { + beforeRemoveHandler?.({ + preventDefault, + data: { action: { type: 'GO_BACK' } }, + }); + }); + + expect(preventDefault).toHaveBeenCalled(); + expect(screen.getByText('Discard changes?')).toBeOnTheScreen(); + }); + + it('flips ?edit=1 on the same screen when the detail FAB is pressed in view mode', async () => { + const mockSetParams = jest.fn(); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + setParams: mockSetParams, + dismissTo: jest.fn(), + }); + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: { ...baseProduct, id: 42, ownedBy: 'me' }, + editMode: false, + } as never); + + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(screen.getByLabelText('Edit Product')).toBeOnTheScreen(); + }); + + fireEvent.press(screen.getByLabelText('Edit Product')); + + expect(mockSetParams).toHaveBeenCalledWith({ edit: '1' }); + expect(baseFormReturn.saveAndExit).not.toHaveBeenCalled(); + }); + + it('collapses the FAB when the product list is scrolled', async () => { + renderWithProviders(, { withDialog: true }); + + await waitFor(() => { + expect(screen.getByLabelText('Edit Product')).toBeOnTheScreen(); + }); + + fireEvent.scroll(screen.getByTestId('product-scroll'), { + nativeEvent: { contentOffset: { y: 120 } }, + }); + + expect(screen.getByLabelText('Edit Product')).toBeOnTheScreen(); + }); +}); + +describe('Section layout', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ + user: { + id: '1', + username: 'owner', + email: 'owner@example.com', + isActive: true, + isVerified: true, + isSuperuser: false, + oauth_accounts: [], + }, + }); + (useLocalSearchParams as jest.Mock).mockReturnValue({ id: '42' }); + (useNavigation as jest.Mock).mockReturnValue({ + setOptions: mockSetOptions, + canGoBack: jest.fn().mockReturnValue(false), + goBack: jest.fn(), + addListener: jest.fn(() => jest.fn()), + dispatch: jest.fn(), + }); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + setParams: jest.fn(), + dismissTo: jest.fn(), + }); + mockUseBaseProductQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + } as never); + mockUseAncestorTrail.mockReturnValue({ ancestors: [], isLoading: false }); + }); + + const fullProduct = { + ...baseProduct, + name: 'Full Spec Product', + description: 'A thorough description.', + videos: [{ id: 1, url: 'https://example.com/video', title: 'Demo', description: '' }], + circularityProperties: { + recyclability: 'Fully recyclable', + disassemblability: null, + remanufacturability: null, + }, + }; + + it('renders sections in the new spec-sheet order for a full product', () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: fullProduct, + } as never); + + renderWithProviders(, { withDialog: true }); + + const contentTexts = collectText(screen.getByTestId('product-scroll')); + const indexOf = (text: string) => contentTexts.indexOf(text); + + expect(indexOf('ProductImageGallery')).toBeGreaterThanOrEqual(0); + expect(indexOf(fullProduct.name)).toBeGreaterThan(indexOf('ProductImageGallery')); + expect(indexOf('Overview')).toBeGreaterThan(indexOf(fullProduct.name)); + expect(indexOf(`Description:${fullProduct.name}`)).toBeGreaterThan(indexOf('Overview')); + expect(indexOf('ProductTags')).toBeGreaterThan(indexOf(`Description:${fullProduct.name}`)); + expect(indexOf('ProductType')).toBeGreaterThan(indexOf('ProductTags')); + expect(indexOf('Components')).toBeGreaterThan(indexOf('ProductType')); + expect(indexOf('ProductComponents')).toBeGreaterThan(indexOf('Components')); + // Measurements and circularity notes share the Properties section; the + // metadata block is a footer after the last section, not a section itself. + expect(indexOf('Properties')).toBeGreaterThan(indexOf('ProductComponents')); + expect(indexOf('ProductPhysicalProperties')).toBeGreaterThan(indexOf('Properties')); + expect(indexOf('ProductCircularityProperties')).toBeGreaterThan( + indexOf('ProductPhysicalProperties'), + ); + expect(indexOf('Media')).toBeGreaterThan(indexOf('ProductCircularityProperties')); + expect(indexOf('ProductVideo')).toBeGreaterThan(indexOf('Media')); + expect(indexOf(`Meta:${fullProduct.name}`)).toBeGreaterThan(indexOf('ProductVideo')); + expect(indexOf('Details')).toBe(-1); + }); + + it('hides an empty properties section in view mode and shows one add-row in edit mode', () => { + const bareProduct = { + ...baseProduct, + physicalProperties: { + weight: undefined, + width: undefined, + height: undefined, + depth: undefined, + }, + }; + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: bareProduct, + editMode: false, + } as never); + + const { rerender } = renderWithProviders(, { withDialog: true }); + + expect(screen.queryByText('Properties')).toBeNull(); + expect(screen.queryByText('ProductCircularityProperties')).toBeNull(); + + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: bareProduct, + editMode: true, + } as never); + + rerender(); + + expect(screen.getByText('Add properties')).toBeOnTheScreen(); + expect(screen.queryByText('ProductPhysicalProperties')).toBeNull(); + expect(screen.queryByText('ProductCircularityProperties')).toBeNull(); + }); + + it('renders no more than four section-nav chips in edit mode', () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: fullProduct, + editMode: true, + } as never); + + renderWithProviders(, { withDialog: true }); + + const chips = within(screen.getByTestId('section-nav-chips')).getAllByRole('button'); + expect(chips.map((chip) => chip.props.accessibilityLabel)).toEqual([ + 'Overview, current section', + 'Components', + 'Properties', + 'Media', + ]); + }); + + it('renders phone section-nav chips with Overview and Components labels', () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: fullProduct, + } as never); + + renderWithProviders(, { withDialog: true }); + + const chips = screen.getByTestId('section-nav-chips'); + expect(within(chips).getByText('Overview')).toBeOnTheScreen(); + expect(within(chips).getByText('Components')).toBeOnTheScreen(); + }); +}); diff --git a/app/src/app/(tabs)/(products)/products/[id]/components/__tests__/new.test.tsx b/app/src/app/(tabs)/(products)/products/[id]/components/__tests__/new.test.tsx new file mode 100644 index 000000000..1c8d1e0c3 --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/[id]/components/__tests__/new.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react-native'; +import type { Text as RNText } from 'react-native'; + +jest.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ id: '42' }), +})); + +jest.mock('@/components/product/capture/CaptureScreen', () => { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + return { + CaptureScreen: (props: { entityRole?: string; parentID?: number; parentRole?: string }) => + mockReact.createElement( + Text, + null, + `role:${props.entityRole ?? ''} parent:${props.parentID ?? ''} parentRole:${props.parentRole ?? ''}`, + ), + }; +}); + +import ComponentNewPage from '@/app/(tabs)/(products)/products/[id]/components/new'; + +describe('ComponentNewPage route', () => { + it('renders CaptureScreen for a new component and seeds parent id from the URL', () => { + render(); + expect(screen.getByText('role:component parent:42 parentRole:product')).toBeOnTheScreen(); + }); +}); diff --git a/app/src/app/(tabs)/(products)/products/[id]/components/new.tsx b/app/src/app/(tabs)/(products)/products/[id]/components/new.tsx new file mode 100644 index 000000000..17614dcb1 --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/[id]/components/new.tsx @@ -0,0 +1,5 @@ +import { NewComponentPage } from '@/components/product/detail/NewComponentPage'; + +export default function ComponentNewPage() { + return ; +} diff --git a/app/src/app/(tabs)/(products)/products/[id]/index.tsx b/app/src/app/(tabs)/(products)/products/[id]/index.tsx new file mode 100644 index 000000000..47d83405c --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/[id]/index.tsx @@ -0,0 +1,5 @@ +import { EntityDetailPage } from '@/components/product/detail/EntityDetailPage'; + +export default function ProductPage() { + return ; +} diff --git a/app/src/app/(tabs)/(products)/products/__tests__/new.test.tsx b/app/src/app/(tabs)/(products)/products/__tests__/new.test.tsx new file mode 100644 index 000000000..6d72d58b9 --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/__tests__/new.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react-native'; +import type { Text as RNText } from 'react-native'; + +jest.mock('@/components/product/capture/CaptureScreen', () => { + const mockReact = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native') as { + Text: typeof RNText; + }; + return { + CaptureScreen: (props: { entityRole?: string; parentID?: number; parentRole?: string }) => + mockReact.createElement( + Text, + null, + `role:${props.entityRole ?? ''} parent:${props.parentID ?? ''} parentRole:${props.parentRole ?? ''}`, + ), + }; +}); + +import ProductNewPage from '@/app/(tabs)/(products)/products/new'; + +describe('ProductNewPage route', () => { + it('renders CaptureScreen for a new product with no parent context', () => { + render(); + expect(screen.getByText('role:product parent: parentRole:')).toBeOnTheScreen(); + }); +}); diff --git a/app/src/app/(tabs)/(products)/products/__tests__/products.integration.test.tsx b/app/src/app/(tabs)/(products)/products/__tests__/products.integration.test.tsx new file mode 100644 index 000000000..949ddd797 --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/__tests__/products.integration.test.tsx @@ -0,0 +1,709 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { HttpResponse, http } from 'msw'; +import { FlatList } from 'react-native'; +import Products from '@/app/(tabs)/(products)/products'; +import { API_URL } from '@/config'; +import { productsInfiniteQueryOptions } from '@/features/products/queries'; +import { mockUser, renderWithProviders, server } from '@/test-utils/index'; + +const NETWORK_FAILURE_PATTERN = /Network failure/; + +const mockUseAuth = jest.fn(); +const mockDismissWelcomeCard = jest.fn(); +const mockDialogApi = { + alert: jest.fn(), + input: jest.fn(), + toast: jest.fn(), +}; +const mockUseDialog = jest.fn(() => ({ + ...mockDialogApi, +})); + +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(), + useRouter: jest.fn(), + // ProductsFab reads useBottomNavVisible() (BOTTOM_NAV_CLEARANCE on web), + // which calls useSegments() — default it to segments outside the tab group + // so the fab's bottom offset stays at its base value unless a test opts in. + useSegments: jest.fn().mockReturnValue([]), + // useProductSearchShortcut scopes itself to this screen via useFocusEffect; + // a no-op default keeps the "/" shortcut out of scope for these tests, + // matching the unit-lane default (see config/setup.unit.ts). + useFocusEffect: jest.fn(), +})); + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/features/products/useProductsWelcomeCard', () => { + const React = require('react'); + + return { + useProductsWelcomeCard: ({ + isAuthenticated, + currentUser, + }: { + isAuthenticated: boolean; + currentUser?: { preferences?: { products_welcome_dismissed?: boolean } | null } | null; + }) => { + const initialVisible = isAuthenticated + ? currentUser?.preferences?.products_welcome_dismissed !== true + : true; + const [showInfoCard, setShowInfoCard] = React.useState(initialVisible); + + React.useEffect(() => { + setShowInfoCard(initialVisible); + }, [currentUser, initialVisible, isAuthenticated]); + + const dismissInfoCard = React.useCallback(() => { + mockDismissWelcomeCard(); + setShowInfoCard(false); + return Promise.resolve(); + }, []); + + return { + showInfoCard, + dismissInfoCard, + }; + }, + }; +}); + +jest.mock('@/components/base/dialogContext', () => { + const actual = jest.requireActual( + '@/components/base/dialogContext', + ); + return { + ...actual, + useDialog: () => mockUseDialog(), + }; +}); + +// useWindowDimensions is spied on in beforeEach to control numColumns per describe block + +jest.mock('react-native/Libraries/Lists/FlatList', () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const FlatListMock = React.forwardRef(function FlatListMock( + { + data, + renderItem, + ListFooterComponent, + ListEmptyComponent, + ...props + }: { + data?: unknown[]; + renderItem?: (info: { item: unknown; index: number }) => React.ReactNode; + ListFooterComponent?: React.ComponentType | React.ReactElement | null; + ListEmptyComponent?: React.ComponentType | React.ReactElement | null; + [key: string]: unknown; + }, + ref: React.ForwardedRef<{ scrollToOffset: () => void; scrollToIndex: () => void }>, + ) { + React.useImperativeHandle( + ref, + () => ({ + scrollToOffset: jest.fn(), + scrollToIndex: jest.fn(), + }), + [], + ); + + const items = + Array.isArray(data) && renderItem + ? data.map((item, index) => + React.createElement(React.Fragment, { key: index }, renderItem({ item, index })), + ) + : null; + const footer = + typeof ListFooterComponent === 'function' + ? React.createElement(ListFooterComponent) + : ListFooterComponent; + const empty = + typeof ListEmptyComponent === 'function' + ? React.createElement(ListEmptyComponent) + : ListEmptyComponent; + + return React.createElement(View, props, items && items.length > 0 ? items : empty, footer); + }); + return { + __esModule: true, + default: FlatListMock, + }; +}); + +jest.mock('expo-image', () => { + const { View } = jest.requireActual('react-native'); + // WelcomeCard renders the brand mark via ; both exports must be stubbed. + return { Image: View, ImageBackground: View }; +}); + +jest.mock('expo-linear-gradient', () => { + const { View } = jest.requireActual('react-native'); + return { LinearGradient: View }; +}); + +jest.mock('@/components/product/ProductCard', () => { + return function ProductCardMock({ product }: { product: { name: string } }) { + const React = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return React.createElement(Text, { testID: 'product-card' }, product.name); + }; +}); + +jest.mock('@/components/product/ProductCardSkeleton', () => { + return function ProductCardSkeletonMock() { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return React.createElement(View, { testID: 'product-card-skeleton' }); + }; +}); + +// Controlled query state. Brand/type search stay mocked (they're irrelevant to +// this file's scope); the products list itself now flows through the real +// useInfiniteQuery + productsInfiniteQueryOptions, hitting MSW like the app does. +const mockUseBrandsQuery = jest.fn(); +const mockUseProductTypesQuery = jest.fn(); +const mockSetParams = jest.fn(); +const mockPush = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@/features/products/queries', () => { + const actual = jest.requireActual( + '@/features/products/queries', + ); + + return { + ...actual, + // Wrapped in jest.fn so tests can inspect call args (e.g. the sort/search + // params sent through) while still exercising the real fetch pipeline. + productsInfiniteQueryOptions: jest.fn(actual.productsInfiniteQueryOptions), + useSearchBrandsQuery: (...args: unknown[]) => mockUseBrandsQuery(...args), + useSearchProductTypesQuery: (...args: unknown[]) => mockUseProductTypesQuery(...args), + DEFAULT_PRODUCT_SORT: ['-created_at'], + PRODUCT_SORT_OPTIONS: [ + { label: 'Relevance', value: [] }, + { label: 'Newest first', value: ['-created_at'] }, + { label: 'Oldest first', value: ['created_at'] }, + { label: 'Name A→Z', value: ['name'] }, + { label: 'Name Z→A', value: ['-name'] }, + { label: 'Brand A→Z', value: ['brand'] }, + { label: 'Brand Z→A', value: ['-brand'] }, + ], + }; +}); + +const mockProductsInfiniteQueryOptions = jest.mocked(productsInfiniteQueryOptions); + +const FILTERS_TOGGLE_PATTERN = /^Filters/; + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(async () => { + jest.clearAllMocks(); + // Wide viewport by default; numColumns only affects grid layout now, not + // which pagination UI renders (infinite scroll is the same on every width). + jest.spyOn(require('react-native'), 'useWindowDimensions').mockReturnValue({ + width: 1280, + height: 768, + scale: 1, + fontScale: 1, + }); + mockUseAuth.mockReturnValue({ user: null }); + (useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + navigate: mockNavigate, + replace: jest.fn(), + back: jest.fn(), + setParams: mockSetParams, + }); + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + mockUseBrandsQuery.mockReturnValue({ data: [], isLoading: false }); + mockUseProductTypesQuery.mockReturnValue({ data: [], isLoading: false }); + mockDialogApi.alert.mockReset(); + mockDialogApi.input.mockReset(); + mockDialogApi.toast.mockReset(); + mockDismissWelcomeCard.mockClear(); +}); + +/** + * MSW handler returning a fixed 3-page catalogue, 2 items/page. `total` (50) + * must exceed the hook's hardcoded page size (24) twice over so + * getNextPageParam actually reports a next page after page 1 and page 2 — + * the real fetched item counts per page don't matter to that check. + */ +function threePageProductsHandler() { + const itemsByPage: Record = { + 1: [ + { id: 1, name: 'Product A' }, + { id: 2, name: 'Product B' }, + ], + 2: [ + { id: 3, name: 'Product C' }, + { id: 4, name: 'Product D' }, + ], + 3: [{ id: 5, name: 'Product E' }], + }; + + return http.get(`${API_URL}/products`, ({ request }) => { + const page = Number(new URL(request.url).searchParams.get('page')) || 1; + return HttpResponse.json({ + items: itemsByPage[page] ?? [], + total: 50, + page, + size: 24, + pages: 3, + }); + }); +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function renderProducts() { + return renderWithProviders(); +} + +/** The sort/filter chips sit behind one toggle; the toggle's name carries the active count. */ +function openFilters() { + fireEvent.press(screen.getByLabelText(FILTERS_TOGGLE_PATTERN)); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('Products screen', () => { + it('renders the search bar and the filters toggle, with the chips collapsed', async () => { + renderProducts(); + expect(screen.getByPlaceholderText('Search products')).toBeOnTheScreen(); + expect(screen.getByLabelText('Filters').props.accessibilityState.expanded).toBe(false); + expect(screen.queryByText('Date')).toBeNull(); + openFilters(); + expect(screen.getByText('Date')).toBeOnTheScreen(); + }); + + it('opens the chips and counts them when the URL carries a filter', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ days: '7', brands: 'Bosch' }); + renderProducts(); + expect(screen.getByLabelText('Filters, 2 active').props.accessibilityState.expanded).toBe(true); + expect(screen.getByText('Last 7d')).toBeOnTheScreen(); + }); + + it('shows skeleton rows while loading', async () => { + renderProducts(); + expect(screen.getAllByTestId('product-card-skeleton').length).toBeGreaterThan(0); + }); + + it('shows empty state when no products match', async () => { + renderProducts(); + await waitFor(() => + expect( + screen.getByText('No products available yet. Sign in to add your own.'), + ).toBeOnTheScreen(), + ); + }); + + it('shows search-specific empty state when searching', async () => { + renderProducts(); + await waitFor(() => expect(screen.queryByTestId('product-card-skeleton')).toBeNull()); + + fireEvent.changeText(screen.getByPlaceholderText('Search products'), 'xyz'); + expect(screen.getByText('No products match your search.')).toBeOnTheScreen(); + }); + + it('clears the search query from the URL when the search box is emptied', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ q: 'saved query' }); + renderProducts(); + fireEvent.changeText(screen.getByPlaceholderText('Search products'), ''); + + expect(mockSetParams).toHaveBeenCalledWith({ q: undefined }); + }); + + it('resets page to 1 when sort changes (colocated in onPress)', async () => { + renderProducts(); + openFilters(); + + // Open sort menu and pick a different option + fireEvent.press(screen.getByLabelText('Sort: Newest first')); + fireEvent.press(screen.getByText('Oldest first')); + + expect(mockSetParams).toHaveBeenCalledWith({ sort: 'created_at' }); + }); + + it('renders welcome banner on first visit', async () => { + renderProducts(); + expect(screen.getByText('Welcome to Relab')).toBeOnTheScreen(); + expect( + screen.getByText('Browse products freely. Sign in when you are ready to add your own.'), + ).toBeOnTheScreen(); + }); + + it('dismisses welcome banner when Maybe later is pressed', async () => { + renderProducts(); + fireEvent.press(screen.getByText('Maybe later')); + expect(screen.queryByText('Welcome to Relab')).toBeNull(); + }); + + it('shows no welcome card to verified users', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + renderProducts(); + + expect(screen.queryByText('Got it')).toBeNull(); + expect(screen.queryByTestId('profile-pill-label')).toBeNull(); + }); + + it('prompts unverified signed-in users to verify their email', async () => { + mockUseAuth.mockReturnValue({ user: mockUser({ isVerified: false }) }); + + renderProducts(); + + expect(screen.getByText('Verify your email to start creating')).toBeOnTheScreen(); + expect(screen.getByText('Verify email to add product')).toBeOnTheScreen(); + expect(screen.getByTestId('profile-pill-label')).toBeOnTheScreen(); + expect(screen.getByText('Got it')).toBeOnTheScreen(); + expect(screen.getByText('Verify email')).toBeOnTheScreen(); + + fireEvent.press(screen.getByRole('button', { name: 'Verify email' })); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/account'); + }); + }); + + it('uses Got it for the dismiss action when signed in but unverified', async () => { + mockUseAuth.mockReturnValue({ user: mockUser({ isVerified: false }) }); + + renderProducts(); + + expect(screen.getByText('Got it')).toBeOnTheScreen(); + expect(screen.getByTestId('profile-pill-label')).toBeOnTheScreen(); + }); +}); + +describe('FAB and new-product flow', () => { + it('shows sign-in dialog when guest presses the FAB', async () => { + renderProducts(); + fireEvent.press(screen.getByLabelText('Sign in to add product')); + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Sign in required' }), + ); + }); + + it('navigates to /products/new when verified user presses FAB', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + renderProducts(); + fireEvent.press(screen.getByLabelText('New product')); + expect(mockDialogApi.input).not.toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledWith('/products/new'); + }); + + it('shows email-verification dialog when unverified user presses FAB', async () => { + mockUseAuth.mockReturnValue({ user: mockUser({ isVerified: false }) }); + renderProducts(); + fireEvent.press(screen.getByLabelText('Verify email to add product')); + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Verify your email first' }), + ); + }); +}); + +describe('Filter chips and modals', () => { + it('opens brand filter modal when Brand chip is pressed', async () => { + renderProducts(); + openFilters(); + fireEvent.press(screen.getByText('Brand')); + expect(screen.getByText('Filter by brand')).toBeOnTheScreen(); + }); + + it('opens product type filter modal when Type chip is pressed', async () => { + renderProducts(); + openFilters(); + fireEvent.press(screen.getByText('Product type')); + expect(screen.getByText('Filter by product type')).toBeOnTheScreen(); + }); + + it('shows Date chip and opens dropdown menu when pressed', async () => { + renderProducts(); + openFilters(); + fireEvent.press(screen.getByText('Date')); + expect(screen.getByText('Last 7d')).toBeOnTheScreen(); + expect(screen.getByText('Last 30d')).toBeOnTheScreen(); + expect(screen.getByText('Last 90d')).toBeOnTheScreen(); + }); + + it('activates a date preset when selected from the dropdown menu', async () => { + renderProducts(); + openFilters(); + fireEvent.press(screen.getByText('Date')); + fireEvent.press(screen.getByText('Last 7d')); + expect(mockSetParams).toHaveBeenCalledWith({ days: '7' }); + }); + + it('shows the active preset label on the Date chip', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ days: '30' }); + renderProducts(); + expect(screen.getByText('Last 30d')).toBeOnTheScreen(); + }); + + it('clears an active date preset via the chip close button', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ days: '7' }); + renderProducts(); + expect(screen.getByText('Last 7d')).toBeOnTheScreen(); + const closeBtn = screen.getByLabelText('Clear Last 7d filter'); + fireEvent.press(closeBtn); + expect(mockSetParams).toHaveBeenCalledWith({ days: undefined }); + }); +}); + +describe('Error state', () => { + it('shows error message and retry button on query error', async () => { + server.use( + http.get(`${API_URL}/products`, () => + HttpResponse.json({ detail: 'Network failure' }, { status: 500 }), + ), + ); + renderProducts(); + await waitFor(() => expect(screen.getByText(NETWORK_FAILURE_PATTERN)).toBeOnTheScreen()); + expect(screen.getByLabelText('Retry loading products')).toBeOnTheScreen(); + }); +}); + +describe('Empty-state messages', () => { + it('shows mine-specific empty state when authenticated and filterMode=mine', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + renderProducts(); + openFilters(); + + // Switch to mine filter via the Mine chip + fireEvent.press(screen.getByText('Mine')); + expect(mockSetParams).toHaveBeenCalledWith({ filterMode: 'mine' }); + }); + + it('shows a mine-specific empty state with a New product CTA', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + (useLocalSearchParams as jest.Mock).mockReturnValue({ filterMode: 'mine' }); + + renderProducts(); + + await waitFor(() => + expect(screen.getByText("You haven't created any products yet. Tap the")).toBeOnTheScreen(), + ); + expect(screen.getAllByText('New product').length).toBeGreaterThan(0); + }); + + it('shows creation prompt when authenticated user has no products', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + renderProducts(); + + await waitFor(() => expect(screen.getByText('No products yet. Tap the')).toBeOnTheScreen()); + expect(screen.getAllByText('New product').length).toBeGreaterThan(0); + }); +}); + +describe('Infinite scroll', () => { + it('shows a Load more button when more results exist', async () => { + server.use(threePageProductsHandler()); + renderProducts(); + + await waitFor(() => expect(screen.getByLabelText('Load more products')).toBeOnTheScreen()); + expect(screen.getByText('Product A')).toBeOnTheScreen(); + expect(screen.getByText('Product B')).toBeOnTheScreen(); + }); + + // The core regression this task fixes: pressing "Load more" must APPEND the + // next page's items below the ones already rendered, never replace them. + it('appends page-2 items below page-1 items when Load more is pressed', async () => { + server.use(threePageProductsHandler()); + renderProducts(); + + await waitFor(() => expect(screen.getByLabelText('Load more products')).toBeOnTheScreen()); + expect(screen.queryByText('Product C')).toBeNull(); + + fireEvent.press(screen.getByLabelText('Load more products')); + + await waitFor(() => expect(screen.getByText('Product C')).toBeOnTheScreen()); + // Page-1 items are still there — the new page was appended, not swapped in. + expect(screen.getByText('Product A')).toBeOnTheScreen(); + expect(screen.getByText('Product B')).toBeOnTheScreen(); + expect(screen.getByText('Product D')).toBeOnTheScreen(); + }); + + it('fires onEndReached (scroll-triggered append) instead of only responding to the button', async () => { + server.use(threePageProductsHandler()); + const { UNSAFE_getByType } = renderProducts(); + + await waitFor(() => expect(screen.getByText('Product B')).toBeOnTheScreen()); + + const list = UNSAFE_getByType(FlatList); + expect(list.props.onEndReachedThreshold).toBe(0.5); + await act(async () => { + list.props.onEndReached(); + }); + + await waitFor(() => expect(screen.getByText('Product C')).toBeOnTheScreen()); + expect(screen.getByText('Product A')).toBeOnTheScreen(); + }); + + it('does not touch URL params when loading more — pagination is local to the query', async () => { + server.use(threePageProductsHandler()); + renderProducts(); + + await waitFor(() => expect(screen.getByLabelText('Load more products')).toBeOnTheScreen()); + mockSetParams.mockClear(); + + fireEvent.press(screen.getByLabelText('Load more products')); + await waitFor(() => expect(screen.getByText('Product C')).toBeOnTheScreen()); + + expect(mockSetParams).not.toHaveBeenCalled(); + }); + + it('shows the muted product-count footer and hides Load more once every page loads', async () => { + server.use( + http.get(`${API_URL}/products`, () => + HttpResponse.json({ + items: [{ id: 1, name: 'Product A' }], + total: 1, + page: 1, + size: 24, + pages: 1, + }), + ), + ); + renderProducts(); + + await waitFor(() => expect(screen.getByText('1 of 1 products')).toBeOnTheScreen()); + expect(screen.queryByLabelText('Load more products')).toBeNull(); + }); +}); + +describe('Mine filter chip', () => { + it('is not shown for guest users', async () => { + mockUseAuth.mockReturnValue({ user: null }); + renderProducts(); + expect(screen.queryByText('Mine')).toBeNull(); + }); + + it('is shown for authenticated users', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + renderProducts(); + openFilters(); + expect(screen.getByText('Mine')).toBeOnTheScreen(); + }); + + it('sets filterMode=mine when pressed while in all-products mode', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + renderProducts(); + openFilters(); + fireEvent.press(screen.getByText('Mine')); + expect(mockSetParams).toHaveBeenCalledWith({ filterMode: 'mine' }); + }); + + it('clears filterMode when pressed while already in mine mode', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + (useLocalSearchParams as jest.Mock).mockReturnValue({ filterMode: 'mine' }); + renderProducts(); + fireEvent.press(screen.getByText('Mine')); + expect(mockSetParams).toHaveBeenCalledWith({ filterMode: 'all' }); + }); + + it('exposes the active filter to screen readers via accessibilityState.selected', async () => { + mockUseAuth.mockReturnValue({ user: mockUser() }); + (useLocalSearchParams as jest.Mock).mockReturnValue({ filterMode: 'mine' }); + renderProducts(); + expect(screen.getByLabelText('Show all products').props.accessibilityState).toMatchObject({ + selected: true, + }); + }); +}); + +describe('Date filter dropdown', () => { + it('renders a single Date chip instead of multiple preset chips', async () => { + renderProducts(); + // Individual preset labels are not visible until menu is opened + expect(screen.queryByText('Last 7d')).toBeNull(); + expect(screen.queryByText('Last 30d')).toBeNull(); + expect(screen.queryByText('Last 90d')).toBeNull(); + }); + + it('opens menu with all preset options when the chip is pressed', async () => { + renderProducts(); + openFilters(); + fireEvent.press(screen.getByText('Date')); + expect(screen.getByText('Last 7d')).toBeOnTheScreen(); + expect(screen.getByText('Last 30d')).toBeOnTheScreen(); + expect(screen.getByText('Last 90d')).toBeOnTheScreen(); + }); + + it('sets days param when a menu option is selected', async () => { + renderProducts(); + openFilters(); + fireEvent.press(screen.getByText('Date')); + fireEvent.press(screen.getByText('Last 30d')); + expect(mockSetParams).toHaveBeenCalledWith({ days: '30' }); + }); + + it('shows the active preset label on the chip when days param is set', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ days: '90' }); + renderProducts(); + expect(screen.getByText('Last 90d')).toBeOnTheScreen(); + }); +}); + +describe('Sort — Relevance default when searching', () => { + it('omits explicit sort when a search query is in the URL', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ q: 'aluminum' }); + renderProducts(); + + await waitFor(() => expect(mockProductsInfiniteQueryOptions).toHaveBeenCalled()); + const sortArgs = mockProductsInfiniteQueryOptions.mock.calls.map((c) => c[2] as string[]); + expect(sortArgs.some((s) => s.length === 0)).toBe(true); + }); + + it('defaults to newest-first sort when there is no search query', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + renderProducts(); + + await waitFor(() => expect(mockProductsInfiniteQueryOptions).toHaveBeenCalled()); + const sortArgs = mockProductsInfiniteQueryOptions.mock.calls.map((c) => c[2] as string[]); + expect(sortArgs.some((s) => s[0] === '-created_at')).toBe(true); + }); + + it('uses an explicit sort param from URL even when search is active', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ q: 'aluminum', sort: 'name' }); + renderProducts(); + + await waitFor(() => expect(mockProductsInfiniteQueryOptions).toHaveBeenCalled()); + const sortArgs = mockProductsInfiniteQueryOptions.mock.calls.map((c) => c[2] as string[]); + expect(sortArgs.some((s) => s[0] === 'name')).toBe(true); + }); + + it('shows Relevance option in the sort menu when a search is active', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ q: 'aluminum' }); + renderProducts(); + openFilters(); + fireEvent.press(screen.getByLabelText('Sort: Relevance')); + // The chip itself reads "Relevance"; the second match is the menu item. + expect(screen.getAllByText('Relevance')).toHaveLength(2); + }); + + it('hides Relevance option in the sort menu when there is no search', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + renderProducts(); + openFilters(); + fireEvent.press(screen.getByLabelText('Sort: Newest first')); + expect(screen.queryByText('Relevance')).toBeNull(); + }); + + it('clears explicit sort when Relevance is selected from the sort menu', async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ q: 'aluminum' }); + renderProducts(); + openFilters(); + fireEvent.press(screen.getByLabelText('Sort: Relevance')); + fireEvent.press(screen.getAllByText('Relevance')[1]); + expect(mockSetParams).toHaveBeenCalledWith({ sort: undefined }); + }, 15_000); +}); diff --git a/app/src/app/(tabs)/(products)/products/index.tsx b/app/src/app/(tabs)/(products)/products/index.tsx new file mode 100644 index 000000000..16f7a0615 --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/index.tsx @@ -0,0 +1,150 @@ +import Head from 'expo-router/head'; +import { useCallback, useRef } from 'react'; +import { type LayoutChangeEvent, type TextInput, useWindowDimensions, View } from 'react-native'; +import { PageContainer } from '@/components/base/PageContainer'; +import { + ProductsErrorBanner, + ProductsFab, +} from '@/components/product/products-screen/FeedbackControls'; +import { ProductsFilterBar } from '@/components/product/products-screen/FilterBar'; +import { + ProductsHeaderFade, + ProductsListContent, +} from '@/components/product/products-screen/ListContent'; +import { ProductsSearchToolbar } from '@/components/product/products-screen/Toolbar'; +import { ProductsWelcomeCard } from '@/components/product/products-screen/WelcomeCard'; +import { useEffectiveColorScheme } from '@/context/themeMode'; +import { productGridColumns } from '@/features/products/productGridColumns'; +import { PRODUCT_SORT_OPTIONS } from '@/features/products/queries'; +import { useProductSearchShortcut } from '@/features/products/useProductSearchShortcut'; +import { useProductsScreen } from '@/features/products/useProductsScreen'; +import { getAppTheme } from '@/theme'; + +const SORT_OPTIONS = PRODUCT_SORT_OPTIONS; + +export default function Products() { + const colorScheme = useEffectiveColorScheme(); + const bgOverlay = getAppTheme(colorScheme).tokens.overlay.page; + const { width } = useWindowDimensions(); + const numColumns = productGridColumns(width); + + const { screen, search, filters, list, actions } = useProductsScreen(); + const searchRef = useRef(null); + useProductSearchShortcut(searchRef); + const handleGoToLogin = async () => { + await actions.dismissWelcomeCard(); + actions.goToLogin(); + }; + const handleGoToProfile = async () => { + await actions.dismissWelcomeCard(); + actions.goToProfile(); + }; + const handleLayout = useCallback( + (event: LayoutChangeEvent) => + list.setHeaderBottom(event.nativeEvent.layout.y + event.nativeEvent.layout.height), + [list], + ); + const handleRetry = useCallback(() => list.refetch(), [list]); + const creationState = !screen.currentUser + ? 'guest' + : screen.currentUser.isVerified + ? 'verified' + : 'unverified'; + + return ( + <> + + Products · Relab + + + + + + + + {filters.expanded ? ( + + ) : null} + + + + + + + + + + + + + + ); +} diff --git a/app/src/app/(tabs)/(products)/products/new.tsx b/app/src/app/(tabs)/(products)/products/new.tsx new file mode 100644 index 000000000..0dfe344f7 --- /dev/null +++ b/app/src/app/(tabs)/(products)/products/new.tsx @@ -0,0 +1,5 @@ +import { CaptureScreen } from '@/components/product/capture/CaptureScreen'; + +export default function ProductNewPage() { + return ; +} diff --git a/app/src/app/(tabs)/_layout.tsx b/app/src/app/(tabs)/_layout.tsx new file mode 100644 index 000000000..53772be7d --- /dev/null +++ b/app/src/app/(tabs)/_layout.tsx @@ -0,0 +1,42 @@ +import type { BottomTabBarProps } from 'expo-router/js-tabs'; +import { Tabs } from 'expo-router/js-tabs'; +import { BottomNav } from '@/components/base/BottomNav'; + +// Rendered as an element, not passed bare: React Navigation invokes `tabBar` +// as a plain function, so a bare component's hooks would run outside a +// component (React error #321) and crash every tab screen. Defined at module +// scope (not inline) so it's a stable reference across renders. +function renderTabBar(props: BottomTabBarProps) { + return ; +} + +/** + * The three primary destinations, one React Navigation tab each. Every tab is a + * group segment holding its own Stack, so a tab keeps its navigation trail while + * you are on another one — and group segments stay invisible to the URL, so + * /products, /components/1, /cameras and /account are unchanged. + * + * The products tab owns both the /products and /components trees: a component is + * a product's child, and cards, breadcrumbs and post-create redirects move + * between the two constantly. Splitting them across navigators makes every such + * hop a cross-navigator REPLACE, which React Navigation resolves by swapping the + * whole tab navigator out — resetting every tab. They share one stack instead. + * + * `Tabs.Screen` order is what fixes the tab order (and therefore the initial + * tab); the options themselves live in each group's own Stack layout. + */ +export default function TabsLayout() { + return ( + + + + + + ); +} diff --git a/app/src/app/__tests__/_layout.test.tsx b/app/src/app/__tests__/_layout.test.tsx new file mode 100644 index 000000000..b9d8554c4 --- /dev/null +++ b/app/src/app/__tests__/_layout.test.tsx @@ -0,0 +1,139 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { act, render, screen, waitFor } from '@testing-library/react-native'; +import type React from 'react'; +import { Text, View } from 'react-native'; +import { AppStack, Providers } from '@/app/_layout'; +import { HeaderRightPill } from '@/components/base/HeaderRightPill'; +import { useAuth } from '@/context/auth'; +import { renderWithProviders } from '@/test-utils/index'; + +// Populated by the Stack.Screen mock below with each screen's `name` -> +// `options`, keyed fresh on every render/rerender. 'mock'-prefixed names are +// exempt from babel-jest's hoisting TDZ check, so the factory can close over it. +const mockScreenOptions: Record | undefined> = {}; + +jest.mock('expo-router', () => { + // `expo-router/react-navigation` is the lightweight compat module — unlike the + // real `expo-router` entry point, it doesn't eagerly evaluate `ExpoRoot` (which + // reads `window.location` and crashes outside a real router tree). + const { DefaultTheme, DarkTheme, ThemeProvider } = jest.requireActual< + typeof import('expo-router/react-navigation') + >('expo-router/react-navigation'); + const ReactActual = require('react'); + function StackScreenMock({ name, options }: { name: string; options?: Record }) { + mockScreenOptions[name] = options; + return null; + } + function StackMock({ children }: { children?: React.ReactNode }) { + return ReactActual.createElement(ReactActual.Fragment, null, children); + } + StackMock.Screen = StackScreenMock; + return { + DefaultTheme, + DarkTheme, + ThemeProvider, + useRouter: jest.fn(() => ({ + push: jest.fn(), + replace: jest.fn(), + })), + usePathname: jest.fn(() => '/products'), + Stack: StackMock, + }; +}); + +jest.mock('@/context/auth', () => ({ + useAuth: jest.fn(() => ({ user: null, refetch: jest.fn() })), +})); + +jest.mock('@/context/AuthProvider', () => ({ + AuthProvider: ({ children }: { children?: React.ReactNode }) => <>{children}, +})); + +jest.mock('react-native-keyboard-controller', () => { + const { View } = require('react-native'); + return { + KeyboardProvider: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + useKeyboardHandler: jest.fn(), + useReanimatedKeyboardAnimation: jest.fn(() => ({ + height: { value: 0 }, + progress: { value: 0 }, + })), + }; +}); + +// The stack's headerRight renderer (see AppStack's products/index screen) is +// literally `() => `, so exercise the pill itself. +describe('HeaderRightPill', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders "Sign in" for guests', async () => { + (useAuth as jest.Mock).mockReturnValue({ user: null }); + + renderWithProviders(, { withAuth: true }); + + await waitFor( + () => { + expect(screen.getByText('Sign in')).toBeOnTheScreen(); + }, + { timeout: 3000 }, + ); + }); + + it('renders username for authenticated users', async () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { id: 1, username: 'testuser', email: 'test@test.com' }, + }); + + renderWithProviders(, { withAuth: true }); + + await waitFor( + () => { + expect(screen.getByText('testuser')).toBeOnTheScreen(); + }, + { timeout: 3000 }, + ); + }); +}); + +describe('Providers', () => { + it('renders children without crashing', async () => { + renderWithProviders( + + + Hello + + , + { withAuth: true }, + ); + expect(screen.getByTestId('child')).toBeOnTheScreen(); + // PersistQueryClientProvider flips `isRestoring` once the persisted cache + // read resolves — a macrotask later. Settle it here or it lands unwrapped. + await act(async () => new Promise((resolve) => setImmediate(resolve))); + }); +}); + +describe('AppStack', () => { + beforeEach(() => { + for (const key of Object.keys(mockScreenOptions)) delete mockScreenOptions[key]; + }); + + // The root stack only holds what sits outside the tabs. Every primary + // destination — and its header — now belongs to a tab's own stack (see + // tab-layouts.test.tsx), and the (tabs) route must not add a second header + // above them. + it('owns no tab screens and lets the tabs render their own headers', () => { + render(); + + expect(mockScreenOptions['(tabs)']?.headerShown).toBe(false); + expect(mockScreenOptions['products/index']).toBeUndefined(); + expect(mockScreenOptions['cameras/index']).toBeUndefined(); + expect(mockScreenOptions.account).toBeUndefined(); + // Screens presented over a tab stay here. + expect(mockScreenOptions['category-selection']?.title).toBe('Select category'); + expect(mockScreenOptions['users/[username]']).toBeDefined(); + }); +}); diff --git a/app/src/app/__tests__/category-selection.test.tsx b/app/src/app/__tests__/category-selection.test.tsx new file mode 100644 index 000000000..686e19343 --- /dev/null +++ b/app/src/app/__tests__/category-selection.test.tsx @@ -0,0 +1,318 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useRouter } from 'expo-router'; +import { StyleSheet } from 'react-native'; +import CategorySelection from '@/app/category-selection'; +import { MIN_TAP_TARGET } from '@/constants'; +import { setPendingTypeSelection } from '@/features/products/pendingTypeSelection'; +import { useCategorySelection } from '@/features/products/useCategorySelection'; +import { useRecentCategories } from '@/features/products/useRecentCategories'; +import { loadCPV } from '@/services/cpv'; +import { renderWithProviders } from '@/test-utils/index'; +import type { User } from '@/types/User'; + +const mockUseAuth = jest.fn(); +const mockedLoadCPV = jest.mocked(loadCPV); +const mockedSetPending = jest.mocked(setPendingTypeSelection); +const SUBCATEGORY_COUNT_PATTERN = /1 subcategor/; +const TYPING_NOW_PATTERN = /typing-now/; +const BLURB_PATTERN = /Search by name or description, or browse into a category/; +const INFO_TOOLTIP_LABEL_PATTERN = /Info: Product types come from/; + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/services/cpv', () => ({ + loadCPV: jest.fn(), +})); + +jest.mock('@/features/products/pendingTypeSelection', () => ({ + setPendingTypeSelection: jest.fn(), + takePendingTypeSelection: jest.fn(), +})); + +// Every other test in this file exercises the real hook (loadCPV mocked below +// it) end to end, so wrap — don't replace — it: jest.fn() defaults to calling +// straight through to the real implementation, and only the one test below +// overrides it (mockReturnValueOnce, self-restoring) to force a searchQuery +// vs debouncedSearchQuery mismatch that the real 300ms debounce makes +// impractical to hit deterministically in a DOM test. +jest.mock('@/features/products/useCategorySelection', () => ({ + useCategorySelection: jest.fn( + jest.requireActual( + '@/features/products/useCategorySelection', + ).useCategorySelection, + ), +})); + +const mockBack = jest.fn(); +const mockReplace = jest.fn(); + +const PETROLEUM_RECENT = { + id: 2, + name: '09000000-3', + description: 'Petroleum products', + allChildren: [], + directChildren: [], + updatedAt: '', + createdAt: '', +}; + +describe('CategorySelection', () => { + beforeEach(() => { + jest.clearAllMocks(); + // The recents store is a module-level singleton (zustand), so it outlives + // any single test's render — reset it here rather than letting one test's + // recordRecent leak a duplicate "Petroleum products" node into the next. + useRecentCategories.setState({ recents: [] }); + mockedLoadCPV.mockResolvedValue({ + root: { + id: 0, + name: 'root', + description: 'All categories', + allChildren: [1, 2], + directChildren: [1, 2], + updatedAt: '', + createdAt: '', + }, + '1': { + id: 1, + name: '03000000-1', + description: 'Agricultural products', + allChildren: [3], + directChildren: [3], + updatedAt: '', + createdAt: '', + }, + '2': { + id: 2, + name: '09000000-3', + description: 'Petroleum products', + allChildren: [], + directChildren: [], + updatedAt: '', + createdAt: '', + }, + '3': { + id: 3, + name: '03100000-2', + description: 'Agricultural and horticultural products', + allChildren: [], + directChildren: [], + updatedAt: '', + createdAt: '', + }, + }); + (useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: mockBack, + setParams: jest.fn(), + dismissTo: jest.fn(), + }); + mockUseAuth.mockReturnValue({ user: { id: '1', username: 'testuser' } as Partial }); + }); + + it('redirects guests to login', async () => { + mockUseAuth.mockReturnValue({ user: null }); + renderWithProviders(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith({ + pathname: '/login', + params: { redirectTo: '/products' }, + }); + }); + + // Flush the pending loadCPV promise so it doesn't leak into the next test + await waitFor(() => { + expect(mockedLoadCPV).toHaveBeenCalled(); + }); + }); + + it('renders root category items initially', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Agricultural products')).toBeOnTheScreen(); + expect(screen.getByText('Petroleum products')).toBeOnTheScreen(); + }); + }); + + it('hands the picked type to the pending slot and pops back when a leaf is pressed', async () => { + renderWithProviders(); + await screen.findByText('Petroleum products'); + fireEvent.press(screen.getByText('Petroleum products')); + await waitFor(() => { + expect(mockedSetPending).toHaveBeenCalledWith(2); + expect(mockBack).toHaveBeenCalled(); + }); + }); + + it('navigates into subcategory when subcategories button is pressed', async () => { + renderWithProviders(); + // Agricultural products has 1 subcategory; shows "1 subcategories" link + await waitFor(() => { + expect(screen.getByText(SUBCATEGORY_COUNT_PATTERN)).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByText('1 subcategories')); + await waitFor(() => { + expect(screen.getByText('Agricultural and horticultural products')).toBeOnTheScreen(); + }); + }); + + it('shows history breadcrumb after navigating into subcategory', async () => { + renderWithProviders(); + await screen.findByText('1 subcategories'); + fireEvent.press(screen.getByText('1 subcategories')); + await waitFor(() => { + expect(screen.getByText('Agricultural products')).toBeOnTheScreen(); + }); + }); + + it('pressing the history breadcrumb navigates back up to the parent level', async () => { + renderWithProviders(); + await screen.findByText('1 subcategories'); + fireEvent.press(screen.getByText('1 subcategories')); + // Now inside Agricultural products; breadcrumb shows + await waitFor(() => { + expect(screen.getByText('Agricultural products')).toBeOnTheScreen(); + }); + // Pressing the breadcrumb triggers moveUp; root categories re-appear + fireEvent.press(screen.getByText('Agricultural products')); + await waitFor(() => { + expect(screen.getByText('Petroleum products')).toBeOnTheScreen(); + }); + }); + + it('shows a Recent section above the list at the root when there are recent picks', async () => { + useRecentCategories.setState({ recents: [PETROLEUM_RECENT] }); + renderWithProviders(); + await screen.findByText('Agricultural products'); + + expect(screen.getByText('Recent')).toBeOnTheScreen(); + // Once for the recents card, once for the same item in the root list. + expect(screen.getAllByText('Petroleum products')).toHaveLength(2); + }); + + it('omits the Recent section when there are no recent picks', async () => { + renderWithProviders(); + await screen.findByText('Agricultural products'); + + expect(screen.queryByText('Recent')).toBeNull(); + }); + + it('hides the Recent section once a search is active', async () => { + useRecentCategories.setState({ recents: [PETROLEUM_RECENT] }); + renderWithProviders(); + await screen.findByText('Recent'); + + fireEvent.changeText(screen.getByPlaceholderText('Search'), 'agri'); + + await waitFor(() => { + expect(screen.queryByText('Recent')).toBeNull(); + }); + }); + + it('hides the Recent section once browsed into a subcategory', async () => { + useRecentCategories.setState({ recents: [PETROLEUM_RECENT] }); + renderWithProviders(); + await screen.findByText('1 subcategories'); + + fireEvent.press(screen.getByText('1 subcategories')); + + await waitFor(() => { + expect(screen.queryByText('Recent')).toBeNull(); + }); + }); + + it('selecting a recent category hands it to the pending slot like a normal pick', async () => { + useRecentCategories.setState({ recents: [PETROLEUM_RECENT] }); + renderWithProviders(); + await screen.findByText('Recent'); + + fireEvent.press(screen.getAllByText('Petroleum products')[0]); + + await waitFor(() => { + expect(mockedSetPending).toHaveBeenCalledWith(2); + expect(mockBack).toHaveBeenCalled(); + }); + }); + + it('records the picked category as a recent when a leaf is selected', async () => { + renderWithProviders(); + await screen.findByText('Petroleum products'); + + fireEvent.press(screen.getByText('Petroleum products')); + + await waitFor(() => { + expect(useRecentCategories.getState().recents.map((c) => c.id)).toEqual([2]); + }); + }); + + it('shows the plain-language blurb and contextual CPV help tooltip', async () => { + renderWithProviders(); + await screen.findByPlaceholderText('Search'); + expect(screen.getByText(BLURB_PATTERN)).toBeOnTheScreen(); + expect(screen.getByLabelText(INFO_TOOLTIP_LABEL_PATTERN)).toBeOnTheScreen(); + }); + + it('meets the 44px tap-target floor on the subcategories link and history breadcrumb', async () => { + renderWithProviders(); + const linkButton = await screen.findByRole('button', { name: 'Browse 1 subcategories' }); + expect(StyleSheet.flatten(linkButton.props.style).minHeight).toBe(MIN_TAP_TARGET); + + fireEvent.press(linkButton); + const historyButton = await screen.findByRole('button', { + name: 'Go back to parent category', + }); + expect(StyleSheet.flatten(historyButton.props.style).minHeight).toBe(MIN_TAP_TARGET); + }); + + it('filters categories by search query', async () => { + renderWithProviders(); + await screen.findByPlaceholderText('Search'); + fireEvent.changeText(screen.getByPlaceholderText('Search'), 'petroleum'); + await waitFor(() => { + expect(screen.getByText('Petroleum products')).toBeOnTheScreen(); + expect(screen.queryByText('Agricultural products')).toBeNull(); + }); + }); + + it('shows an empty state when the search query matches nothing', async () => { + renderWithProviders(); + await screen.findByPlaceholderText('Search'); + fireEvent.changeText(screen.getByPlaceholderText('Search'), 'nonexistent-widget'); + await waitFor(() => { + expect( + screen.getByText('No categories match “nonexistent-widget”. Try a broader term.'), + ).toBeOnTheScreen(); + }); + }); + + // While the 300ms debounce is still catching up to a keystroke, `filtered` + // (computed from debouncedSearchQuery) can legitimately lag behind the + // input's immediate searchQuery. Force that mismatch directly rather than + // timing the real debounce, and assert the empty-state message quotes the + // query `filtered` was actually computed from. + it('quotes the debounced query, not the in-flight keystroke, in the empty state', () => { + (useCategorySelection as jest.Mock).mockReturnValueOnce({ + user: { id: '1', username: 'testuser' }, + cpvClass: { id: 0, name: 'root', description: 'root', directChildren: [], allChildren: [] }, + history: [], + filtered: [], + recents: [], + searchQuery: 'typing-now', + debouncedSearchQuery: 'typing', + setSearchQuery: jest.fn(), + selectBranch: jest.fn(), + moveUp: jest.fn(), + selectType: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('No categories match “typing”. Try a broader term.')).toBeOnTheScreen(); + expect(screen.queryByText(TYPING_NOW_PATTERN)).toBeNull(); + }); +}); diff --git a/frontend-app/src/__tests__/router/index.integration.test.tsx b/app/src/app/__tests__/index.integration.test.tsx similarity index 100% rename from frontend-app/src/__tests__/router/index.integration.test.tsx rename to app/src/app/__tests__/index.integration.test.tsx diff --git a/app/src/app/__tests__/layout-helpers.test.tsx b/app/src/app/__tests__/layout-helpers.test.tsx new file mode 100644 index 000000000..cbf9a2a1b --- /dev/null +++ b/app/src/app/__tests__/layout-helpers.test.tsx @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { renderHook, screen } from '@testing-library/react-native'; +import { HeaderRightPill } from '@/components/base/HeaderRightPill'; +import { renderWithProviders } from '@/test-utils/index'; +import { getAppTheme } from '@/theme'; +import { type BackgroundOverlay, useBackgroundOverlay } from '@/utils/router/background'; +import { getUsernameOnboardingRedirect } from '@/utils/router/onboarding'; +import { getProductsHeaderStyle } from '@/utils/router/styles'; + +const mockPush = jest.fn(); +const mockUseAuth = jest.fn(); +const mockUsePathname = jest.fn(() => '/products'); + +jest.mock('expo-router', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => mockUsePathname(), +})); + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ user: null }); + mockUsePathname.mockReturnValue('/products'); +}); + +describe('layout helpers rendering', () => { + it('renders HeaderRightPill for guests and signed-in users', () => { + mockUseAuth.mockReturnValueOnce({ user: null }); + const { rerender } = renderWithProviders(); + expect(screen.getByText('Sign in')).toBeOnTheScreen(); + + mockUseAuth.mockReturnValueOnce({ + user: { id: 'user-1', username: 'averyverylongusername', email: 'test@example.com' }, + }); + rerender(); + + expect(screen.getByText('averyverylongu…')).toBeOnTheScreen(); + }); + + it('renders a safe prompt for signed-in users without a username', () => { + mockUseAuth.mockReturnValueOnce({ + user: { id: 'user-1', username: null, email: 'test@example.com' }, + }); + renderWithProviders(); + + expect(screen.getByText('Complete profile')).toBeOnTheScreen(); + }); + + it('returns dark and light product header styles', () => { + expect(getProductsHeaderStyle(getAppTheme('light')).headerTitleStyle.color).toBeDefined(); + expect(getProductsHeaderStyle(getAppTheme('dark')).headerTitleStyle.color).toBeDefined(); + expect(getProductsHeaderStyle(getAppTheme('light')).headerStyle.backgroundColor).not.toBe( + getProductsHeaderStyle(getAppTheme('dark')).headerStyle.backgroundColor, + ); + }); + + it('routes incomplete users to onboarding and completed users away from onboarding', () => { + expect( + getUsernameOnboardingRedirect({ + user: { id: 'user-1', username: null, email: 'test@example.com' } as never, + pathname: '/products', + }), + ).toBe('/onboarding'); + expect( + getUsernameOnboardingRedirect({ + user: { id: 'user-1', username: 'alice', email: 'test@example.com' } as never, + pathname: '/onboarding', + }), + ).toBe('/products'); + expect( + getUsernameOnboardingRedirect({ + user: { id: 'user-1', username: null, email: 'test@example.com' } as never, + pathname: '/onboarding', + }), + ).toBeNull(); + }); + + // Reads the values from the tokens rather than restating them: hardcoded + // rgba literals here just break whenever the scrim is retuned. + it('returns the overlay for normal and auth routes', () => { + const light = getAppTheme('light').tokens.overlay; + const dark = getAppTheme('dark').tokens.overlay; + const { result, rerender } = renderHook( + ({ isDark }) => useBackgroundOverlay(isDark), + { initialProps: { isDark: false } }, + ); + + expect(result.current).toEqual({ color: light.page, edgeColor: null }); + + mockUsePathname.mockReturnValue('/login'); + rerender({ isDark: true }); + + // /login is a band route: gradient, not the flat hero scrim. + expect(result.current).toEqual({ color: dark.heroBand, edgeColor: dark.heroEdge }); + }); +}); diff --git a/app/src/app/__tests__/tab-layouts.test.tsx b/app/src/app/__tests__/tab-layouts.test.tsx new file mode 100644 index 000000000..4cf0c62c4 --- /dev/null +++ b/app/src/app/__tests__/tab-layouts.test.tsx @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { render } from '@testing-library/react-native'; +import type React from 'react'; +import TabsLayout from '@/app/(tabs)/_layout'; +import AccountTabLayout from '@/app/(tabs)/(account)/_layout'; +import CamerasTabLayout from '@/app/(tabs)/(cameras)/_layout'; +import ProductsTabLayout from '@/app/(tabs)/(products)/_layout'; +import { tabRouteName } from '@/components/base/useBottomNav'; + +// Populated by the Tabs.Screen mock below with every screen `name` it saw, in +// declaration order — the order fixes tab order (and therefore the initial tab). +const mockTabScreenNames: string[] = []; + +jest.mock('expo-router/js-tabs', () => { + const ReactActual = require('react'); + function TabsScreenMock({ name }: { name: string }) { + mockTabScreenNames.push(name); + return null; + } + function TabsMock({ children }: { children?: React.ReactNode }) { + return ReactActual.createElement(ReactActual.Fragment, null, children); + } + TabsMock.Screen = TabsScreenMock; + return { Tabs: TabsMock }; +}); + +// Populated by the Stack.Screen mock with each screen's `name` -> `options`, +// keyed fresh on every render. 'mock'-prefixed names are exempt from +// babel-jest's hoisting TDZ check, so the factory can close over it. +const mockScreenOptions: Record | undefined> = {}; +const mockNavigate = jest.fn(); +const mockReplace = jest.fn(); + +jest.mock('expo-router', () => { + const ReactActual = require('react'); + function StackScreenMock({ name, options }: { name: string; options?: Record }) { + mockScreenOptions[name] = options; + return null; + } + function StackMock({ children }: { children?: React.ReactNode }) { + return ReactActual.createElement(ReactActual.Fragment, null, children); + } + StackMock.Screen = StackScreenMock; + return { + Stack: StackMock, + useRouter: () => ({ navigate: mockNavigate, replace: mockReplace }), + }; +}); + +const mockUseBreakpoint = jest.fn(); +jest.mock('@/hooks/useBreakpoint', () => ({ + useBreakpoint: () => mockUseBreakpoint(), +})); + +jest.mock('@/context/themeMode', () => ({ + useEffectiveColorScheme: () => 'light', +})); + +beforeEach(() => { + for (const key of Object.keys(mockScreenOptions)) delete mockScreenOptions[key]; + mockTabScreenNames.length = 0; + jest.clearAllMocks(); + mockUseBreakpoint.mockReturnValue({ isLg: false }); +}); + +describe('tab stack layouts', () => { + // Each tab owns its own stack now, so hideForTopNav lives in three places + // instead of one. TopNav covers the three tab roots on >=lg web; every + // deeper screen keeps its header at any width. + it('hides only the TopNav-covered tab roots, and only at lg', () => { + render( + <> + + + + , + ); + + expect(mockScreenOptions['products/index']?.headerShown).toBe(true); + expect(mockScreenOptions['cameras/index']?.headerShown).toBe(true); + expect(mockScreenOptions['account/index']?.headerShown).toBe(true); + expect(mockScreenOptions['cameras/add']?.headerShown).toBeUndefined(); + expect(mockScreenOptions['cameras/[id]']?.headerShown).toBeUndefined(); + expect(mockScreenOptions['products/new']?.headerShown).toBeUndefined(); + + mockUseBreakpoint.mockReturnValue({ isLg: true }); + render( + <> + + + + , + ); + + expect(mockScreenOptions['products/index']?.headerShown).toBe(false); + expect(mockScreenOptions['cameras/index']?.headerShown).toBe(false); + expect(mockScreenOptions['account/index']?.headerShown).toBe(false); + expect(mockScreenOptions['cameras/add']?.headerShown).toBeUndefined(); + }); + + // The products tab owns the /components tree too, so a component's creation + // screen has to be declared here rather than on the root stack. + it('keeps both the products and components trees in the products tab', () => { + render(); + + expect(mockScreenOptions['products/[id]/components/new']?.title).toBe('New component'); + expect(mockScreenOptions['components/[id]/components/new']?.title).toBe('New component'); + }); + + // A replace from the account tab to the products tab resolves above the tab + // navigator and swaps the whole thing out, resetting every tab's trail. + it('leaves the account tab by navigating, never replacing', () => { + render(); + const headerLeft = mockScreenOptions['account/index']?.headerLeft as (props: object) => { + props: { onPress: () => void }; + }; + + headerLeft({}).props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith('/products'); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + // The cameras back arrow targets its own stack, so a replace stays inside + // the tab — no other trail is touched. + it('keeps the cameras back arrow a same-stack replace', () => { + render(); + const headerLeft = mockScreenOptions['cameras/add']?.headerLeft as (props: object) => { + props: { onPress: () => void }; + }; + + headerLeft({}).props.onPress(); + + expect(mockReplace).toHaveBeenCalledWith('/cameras'); + }); + + // Each Tabs.Screen name is a group segment, and BottomNav resolves the + // active tab by comparing `tabRouteName(key)` against the current route — + // a typo here would silently break tab-active-state matching. + it('names each tab group after tabRouteName(key)', () => { + render(); + + expect(mockTabScreenNames).toEqual([ + tabRouteName('products'), + tabRouteName('cameras'), + tabRouteName('account'), + ]); + }); +}); diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx new file mode 100644 index 000000000..67e1e2660 --- /dev/null +++ b/app/src/app/_layout.tsx @@ -0,0 +1,267 @@ +// biome-ignore lint/style/noRestrictedImports: global.css lives at the app root (outside src/), so it has no '@/' alias path. +import '../../global.css'; + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; +import { focusManager, QueryClient } from '@tanstack/react-query'; +import { + PersistQueryClientProvider, + removeOldestQuery, +} from '@tanstack/react-query-persist-client'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Stack, ThemeProvider, usePathname, useRouter } from 'expo-router'; +import { setBackgroundColorAsync } from 'expo-system-ui'; +import { memo, type ReactNode, useCallback, useEffect } from 'react'; +import { AppState, type AppStateStatus, Platform, StyleSheet, View } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { KeyboardProvider } from 'react-native-keyboard-controller'; +import { Uniwind } from 'uniwind'; +import { TermsAcceptanceDialog } from '@/components/auth/TermsAcceptanceDialog'; +import { DialogProvider } from '@/components/base/DialogProvider'; +import { HeaderBackButton } from '@/components/base/HeaderBackButton'; +import { OfflineBanner } from '@/components/base/OfflineBanner'; +import { StaticBackground } from '@/components/base/StaticBackground'; +import { TopNav } from '@/components/base/TopNav'; +import { ActiveStreamBanner } from '@/components/cameras/ActiveStreamBanner'; +import { AuthProvider } from '@/context/AuthProvider'; +import { useAuth } from '@/context/auth'; +import { StreamSessionProvider } from '@/context/StreamSessionProvider'; +import { useStreamSession } from '@/context/streamSession'; +import { ThemeModeProvider } from '@/context/ThemeModeProvider'; +import { useEffectiveColorScheme } from '@/context/themeMode'; +import { SAVE_PRODUCT_MUTATION_KEY, saveProductMutationFn } from '@/features/products/queries'; +import { shouldDehydrateQuery } from '@/services/persistedQueryCache'; +import { QUERY_CACHE_STORAGE_KEY } from '@/services/storage'; +import { createNavigationThemes, getAppTheme } from '@/theme'; +import { AppThemeProvider } from '@/theme/AppThemeProvider'; +import { type BackgroundOverlay, useBackgroundOverlay } from '@/utils/router/background'; +import { getUsernameOnboardingRedirect } from '@/utils/router/onboarding'; + +// TODO: wire onlineManager to NetInfo/expo-network for the native phase. Until +// then onlineManager.isOnline() only tracks reality on web (where the default +// manager listens to the browser's online/offline events); on native it stays +// true, so mutations never pause and the queued-offline UI (OfflineBanner, +// QUEUED_OFFLINE_LABEL, isPaused) is effectively web-only. +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, // 30 s; products are fresh for 30 s + retry: 1, + }, + }, +}); + +// A mutation restored from the persisted cache (after a reload) is dehydrated +// down to its key + variables — the mutationFn itself can't survive +// (de)serialization. Registering it here by mutationKey is how TanStack's +// persist-mutations pattern re-attaches a working function before +// resumePausedMutations() (see Providers' onSuccess below) runs it. +queryClient.setMutationDefaults(SAVE_PRODUCT_MUTATION_KEY, { mutationFn: saveProductMutationFn }); + +const persister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: QUERY_CACHE_STORAGE_KEY, + // Cap unbounded growth: drop the oldest cached query first if a persisted + // write ever fails (e.g. storage quota), and coalesce rapid cache writes. + retry: removeOldestQuery, + throttleTime: 5000, +}); + +function resumePausedMutations() { + return queryClient.resumePausedMutations(); +} + +export default function RootLayout() { + return ( + + + + ); +} + +function AppBackground({ overlay }: { overlay: BackgroundOverlay }) { + return ( + <> + + {overlay.edgeColor ? ( + // Hero routes: calm the band behind the centred content column but let + // the backdrop stay vivid at the edges, so it still reads as a photo. + + ) : ( + + )} + + ); +} + +// The root stack holds what sits *outside* the tabs: the entry redirect, the +// auth group, and the two screens that present over a tab (the category picker +// and a public profile). Each tab's own screens are declared by its group +// layout under (tabs)/. +// +// memo: AppShell re-renders on every route change and stream-telemetry tick, +// and this rebuilds every screen's options object and header renderer. Nothing +// here depends on props or state, so memo bails on all of it. +export const AppStack = memo(function AppStack() { + const router = useRouter(); + // Cross-navigator target: a replace from this root screen would swap the + // whole (tabs) route out for a fresh one, resetting every tab's trail. + // navigate() returns to the tabs already on the stack instead. + const goToProducts = useCallback(() => router.navigate('/products'), [router]); + return ( + + + + , + }} + /> + + + + + + + + + + ); +}); + +function AppShell() { + const colorScheme = useEffectiveColorScheme(); + const router = useRouter(); + const pathname = usePathname(); + const isDark = colorScheme === 'dark'; + const theme = getAppTheme(colorScheme); + const { user, isLoading: authLoading } = useAuth(); + const { activeStream } = useStreamSession(); + const overlay = useBackgroundOverlay(isDark); + + // On native there's no document/visibilitychange, so TanStack's focus + // manager reports always-focused and refetch intervals (camera telemetry, + // stream status) keep firing while the app is backgrounded. Drive focus from + // AppState so polling pauses in the background and resumes on return. + useEffect(() => { + if (Platform.OS === 'web') return; + const sub = AppState.addEventListener('change', (status: AppStateStatus) => { + focusManager.setFocused(status === 'active'); + }); + return () => sub.remove(); + }, []); + + useEffect(() => { + if (Platform.OS !== 'web' || !activeStream) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [activeStream]); + + useEffect(() => { + setBackgroundColorAsync(theme.colors.background).catch(() => { + // Best-effort only; the app can render fine without this on unsupported targets. + }); + }, [theme.colors.background]); + + useEffect(() => { + if (authLoading) return; + const redirectPath = getUsernameOnboardingRedirect({ user, pathname }); + if (redirectPath) { + router.replace(redirectPath); + } + }, [authLoading, pathname, router, user]); + + return ( + + + + + + + + ); +} + +export function Providers({ children }: { children: ReactNode }) { + return ( + + + + + {children} + + + + + ); +} + +// Derived from module constants only, so build the pair once rather than per render. +const { LightTheme, DarkTheme } = createNavigationThemes(); + +/** Inner providers that depend on the resolved theme mode. */ +function ThemedProviders({ children }: { children: ReactNode }) { + const colorScheme = useEffectiveColorScheme(); + + // Keep Uniwind's active theme in sync with the app's own theme mode, so the + // `dark:` variants and the palette variables in brand.generated.css resolve + // against the scheme the user actually chose. setTheme owns both platforms + // and both directions: it disables adaptive themes when a scheme is forced + // (so a later OS flip can no longer overwrite the choice) and routes through + // Appearance.setColorScheme on native so system dialogs match. + useEffect(() => { + Uniwind.setTheme(colorScheme); + }, [colorScheme]); + + return ( + + + + + + {children} + {/* Inside DialogProvider (its toast reports the outcome) and inside + AuthProvider (it reads the flag off the current user). Renders + nothing at all when no prompt is due, including signed out. */} + + + + + + + ); +} diff --git a/app/src/app/category-selection.tsx b/app/src/app/category-selection.tsx new file mode 100644 index 000000000..ef2b8e7e9 --- /dev/null +++ b/app/src/app/category-selection.tsx @@ -0,0 +1,215 @@ +import { useCallback } from 'react'; +import { + FlatList, + Pressable, + type PressableStateCallbackType, + StyleSheet, + View, +} from 'react-native'; +import { AppText } from '@/components/base/AppText'; +import { CenteredSpinner } from '@/components/base/CenteredSpinner'; +import { Icon } from '@/components/base/Icon'; +import { InfoTooltip } from '@/components/base/InfoTooltip'; +import { PageContainer } from '@/components/base/PageContainer'; +import { Searchbar } from '@/components/base/Searchbar'; +import { SignedOutState } from '@/components/base/SignedOutState'; +import CPVCard from '@/components/product/CPVCard'; +import { MIN_TAP_TARGET, radius } from '@/constants'; +import { useCategorySelection } from '@/features/products/useCategorySelection'; +import { useAppTheme } from '@/theme'; +import type { CPVCategory } from '@/types/CPVCategory'; + +export default function CategorySelection() { + const { + user, + cpvClass, + history, + filtered, + recents, + searchQuery, + debouncedSearchQuery, + setSearchQuery, + selectBranch, + moveUp, + selectType, + } = useCategorySelection(); + + const renderItem = useCallback( + ({ item }: { item: CPVCategory }) => ( + + ), + [selectType, selectBranch], + ); + const keyExtractor = useCallback((item: CPVCategory) => String(item.id), []); + + // Recents only make sense as a browsing shortcut: at the taxonomy root + // (history has nothing to go "up" from) and with no active search, which + // would otherwise compete with the filtered results for attention. + const showRecents = history.length <= 1 && !searchQuery && recents.length > 0; + const listHeader = showRecents ? ( + + Recent + {recents.map((item) => ( + + ))} + + ) : null; + + // useCategorySelection's useRequireAuth('/products') fires the redirect, but + // this screen is pushed on top of an already-mounted edit screen — a session + // that expires while the picker is open can leave it visible for longer than + // a single render, so a real explanation (not a loading flicker) belongs here. + if (!user) return ; + if (!cpvClass) { + return ; + } + + return ( + // phoneFullBleed: the search bar, blurb, and list own their own px-4/gap-3 + // flow spacing on the 4/8 grid, so only the desktop centering/cap is + // wanted here. + + + + + + Search by name or description, or browse into a category. Tap a category to select it as + the product type. + + + + {history.length > 1 && } + + + + No categories match “{debouncedSearchQuery}”. Try a broader term. + + + ) : null + } + /> + + ); +} + +function CategoryListItem({ + item, + onSelectType, + onSelectBranch, +}: { + item: CPVCategory; + onSelectType: (id: CPVCategory['id']) => void; + onSelectBranch: (item: CPVCategory) => void; +}) { + const handleSelect = useCallback(() => onSelectType(item.id), [onSelectType, item.id]); + const handleBranch = useCallback(() => onSelectBranch(item), [onSelectBranch, item]); + return ( + + } + /> + + ); +} + +function RecentCategoryCard({ + item, + onSelectType, +}: { + item: CPVCategory; + onSelectType: (id: CPVCategory['id']) => void; +}) { + const handleSelect = useCallback(() => onSelectType(item.id), [onSelectType, item.id]); + return ; +} + +function CPVHistory({ history, onPress }: { history: CPVCategory[]; onPress?: () => void }) { + const { colors, tokens } = useAppTheme(); + const historyStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + // No className on this Pressable: it would drop this function (see IconButton.tsx). + styles.history, + { backgroundColor: tokens.surface.accent }, + pressed && { opacity: 0.5 }, + ], + [tokens], + ); + return ( + + + + {history[history.length - 1].description} + + + ); +} + +function CPVLink({ CPV, onPress }: { CPV: CPVCategory; onPress?: () => void }) { + const { colors } = useAppTheme(); + const linkStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [styles.link, pressed && { opacity: 0.5 }], + [], + ); + + if (CPV.directChildren.length <= 0) { + return ; + } + + return ( + + + {`${CPV.directChildren.length} subcategories`} + + + + ); +} + +const styles = StyleSheet.create({ + history: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + padding: 12, + minHeight: MIN_TAP_TARGET, + borderRadius: radius.control, + }, + link: { + minHeight: MIN_TAP_TARGET, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 5, + paddingHorizontal: 12, + backgroundColor: 'transparent', + }, +}); diff --git a/frontend-app/src/app/index.tsx b/app/src/app/index.tsx similarity index 100% rename from frontend-app/src/app/index.tsx rename to app/src/app/index.tsx diff --git a/app/src/app/users/[username].tsx b/app/src/app/users/[username].tsx new file mode 100644 index 000000000..a1099001d --- /dev/null +++ b/app/src/app/users/[username].tsx @@ -0,0 +1,139 @@ +import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native'; +import { AppText } from '@/components/base/AppText'; +import { Card } from '@/components/base/Card'; +import { ErrorState } from '@/components/base/ErrorState'; +import { Icon, type IconName } from '@/components/base/Icon'; +import { PageContainer } from '@/components/base/PageContainer'; +import { usePublicProfileScreen } from '@/features/profile/usePublicProfileScreen'; +import { type AppTheme, memoizeByTheme, useAppTheme } from '@/theme'; + +// Four stat blocks differ only in icon/color/value/label — mapped from data +// instead of hand-copied per stat. Local to this screen: unrelated to the +// HeroStats StatCard in components/profile. +function ProfileStatCard({ + icon, + color, + value, + label, +}: { + icon: IconName; + color: string; + value: string | number; + label: string; +}) { + return ( + + + + + {value} + + + {label} + + + + ); +} + +export default function UserProfileScreen() { + const theme = useAppTheme(); + const styles = createStyles(theme); + const { profile, loading, hasError, errorMessage, onRetry } = usePublicProfileScreen(); + + return ( + + + {loading ? ( + + + + ) : null} + + {hasError ? ( + + ) : null} + + {!(loading || hasError) && profile ? ( + + + + + {profile.username.substring(0, 2).toUpperCase()} + + + + {profile.username} + + {profile.created_at ? ( + + Joined{' '} + {new Date(profile.created_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + ) : null} + + + + {( + [ + { + icon: 'package', + color: theme.colors.primary, + value: profile.product_count, + label: 'Products', + }, + { + icon: 'weight', + color: theme.colors.secondary, + value: profile.total_weight_kg, + label: 'Total kg', + }, + { + icon: 'images', + color: theme.tokens.status.success, + value: profile.image_count, + label: 'Photos', + }, + { + icon: 'tag', + color: theme.tokens.status.warning, + value: profile.top_category || 'None', + label: 'Top category', + }, + ] as const + ).map((stat) => ( + + ))} + + + ) : null} + + + ); +} + +const createStyles = memoizeByTheme((theme: AppTheme) => + StyleSheet.create({ + // bg-primary/10 (a light primary tint) backs this text, not a solid + // *Container fill — onPrimaryContainer here was a contrast bug. + // NOTE: avatar-initials glyph sized to fill the 120px circle, not part of + // the reading hierarchy — no ramp step applies. + avatarText: { + fontSize: 48, + color: theme.colors.primary, + }, + }), +); diff --git a/app/src/app/users/__tests__/username.test.tsx b/app/src/app/users/__tests__/username.test.tsx new file mode 100644 index 000000000..b867f6815 --- /dev/null +++ b/app/src/app/users/__tests__/username.test.tsx @@ -0,0 +1,126 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useGlobalSearchParams } from 'expo-router'; +import type { ReactNode } from 'react'; +import UserProfileScreen from '@/app/users/[username]'; +import { ApiError } from '@/services/api/errors'; +import type { PublicProfileView } from '@/services/api/profiles'; +import { getPublicProfile } from '@/services/api/profiles'; +import { renderWithProviders } from '@/test-utils/index'; + +jest.mock('@/services/api/profiles'); +jest.mock('expo-router', () => { + return { + useRouter: jest.fn().mockReturnValue({ push: jest.fn(), replace: jest.fn(), back: jest.fn() }), + useSegments: () => [], + useLocalSearchParams: jest.fn().mockReturnValue({}), + useNavigation: jest.fn().mockReturnValue({ + setOptions: jest.fn(), + canGoBack: jest.fn().mockReturnValue(false), + goBack: jest.fn(), + }), + Link: ({ children }: { children: ReactNode }) => children, + useGlobalSearchParams: jest.fn().mockReturnValue({ username: 'alice' }), + Stack: { Screen: () => null }, + }; +}); + +const mockGetPublicProfile = jest.mocked(getPublicProfile); + +const profileFixture: PublicProfileView = { + username: 'alice', + created_at: '2024-01-15T00:00:00Z', + product_count: 3, + total_weight_kg: 5.5, + image_count: 7, + top_category: 'Electronics', +}; + +describe('UserProfileScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows loading spinner while the profile is being fetched', async () => { + mockGetPublicProfile.mockReturnValue(new Promise(() => {})); // never resolves + renderWithProviders(, { withAuth: true }); + await waitFor(() => expect(screen.getByTestId('activity-indicator')).toBeOnTheScreen()); + expect(screen.queryByText('alice')).toBeNull(); + }); + + it('renders the profile card with all stats on success', async () => { + mockGetPublicProfile.mockResolvedValue(profileFixture); + renderWithProviders(, { withAuth: true }); + + await waitFor(() => expect(screen.getByText('alice')).toBeOnTheScreen()); + + // Avatar initials + expect(screen.getByText('AL')).toBeOnTheScreen(); + // Stats + expect(screen.getByText('3')).toBeOnTheScreen(); + expect(screen.getByText('5.5')).toBeOnTheScreen(); + expect(screen.getByText('7')).toBeOnTheScreen(); + expect(screen.getByText('Electronics')).toBeOnTheScreen(); + // Labels + expect(screen.getByText('Products')).toBeOnTheScreen(); + expect(screen.getByText('Total kg')).toBeOnTheScreen(); + expect(screen.getByText('Photos')).toBeOnTheScreen(); + expect(screen.getByText('Top category')).toBeOnTheScreen(); + }); + + it('shows generic error message when fetch fails', async () => { + mockGetPublicProfile.mockRejectedValue(new Error('Network error')); + renderWithProviders(, { withAuth: true }); + + await waitFor(() => expect(screen.getByText('Network error')).toBeOnTheScreen()); + expect(screen.queryByTestId('activity-indicator')).toBeNull(); + }); + + it('shows friendly privacy message for a 404 error', async () => { + mockGetPublicProfile.mockRejectedValue(new ApiError('Profile not found', 404)); + renderWithProviders(, { withAuth: true }); + + await waitFor(() => + expect(screen.getByText('This profile is private or does not exist.')).toBeOnTheScreen(), + ); + }); + + it('does not call getPublicProfile when username param is undefined', async () => { + (useGlobalSearchParams as jest.Mock).mockReturnValue({ username: undefined }); + + renderWithProviders(, { withAuth: true }); + + // loading=true is set initially, but fetchProfile returns early without calling API + // The loading state stays true since setLoading(false) is in finally of the skipped block + // Wait a tick so useEffect fires + await waitFor(() => expect(mockGetPublicProfile).not.toHaveBeenCalled()); + expect(screen.queryByText('Products')).toBeNull(); + }); + + it('does not call getPublicProfile when username is an array', async () => { + (useGlobalSearchParams as jest.Mock).mockReturnValue({ username: ['alice', 'bob'] }); + + renderWithProviders(, { withAuth: true }); + + await waitFor(() => expect(mockGetPublicProfile).not.toHaveBeenCalled()); + expect(screen.queryByText('Products')).toBeNull(); + }); + + it('re-fetches the profile when the error state’s Retry action is pressed', async () => { + // A prior test in this suite leaves useGlobalSearchParams mocked to an array + // username (jest.clearAllMocks() doesn't undo mockReturnValue) — restore the + // normal single-username case explicitly instead of relying on file order. + (useGlobalSearchParams as jest.Mock).mockReturnValue({ username: 'alice' }); + mockGetPublicProfile + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce(profileFixture); + renderWithProviders(, { withAuth: true }); + + await waitFor(() => expect(screen.getByText('Network error')).toBeOnTheScreen()); + expect(mockGetPublicProfile).toHaveBeenCalledTimes(1); + + fireEvent.press(screen.getByText('Retry')); + + await waitFor(() => expect(screen.getByText('alice')).toBeOnTheScreen()); + expect(mockGetPublicProfile).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend-app/src/assets/data/cpv.json b/app/src/assets/data/cpv.json similarity index 100% rename from frontend-app/src/assets/data/cpv.json rename to app/src/assets/data/cpv.json diff --git a/backend/app/static/images/bg-dark.jpg b/app/src/assets/images/bg-dark.jpg similarity index 100% rename from backend/app/static/images/bg-dark.jpg rename to app/src/assets/images/bg-dark.jpg diff --git a/app/src/assets/images/bg-light.jpg b/app/src/assets/images/bg-light.jpg new file mode 100644 index 000000000..3f769ddbc Binary files /dev/null and b/app/src/assets/images/bg-light.jpg differ diff --git a/app/src/assets/images/favicon.png b/app/src/assets/images/favicon.png new file mode 100644 index 000000000..fc1f2c0f9 Binary files /dev/null and b/app/src/assets/images/favicon.png differ diff --git a/app/src/assets/images/logo-dark.png b/app/src/assets/images/logo-dark.png new file mode 100644 index 000000000..80f9551d4 Binary files /dev/null and b/app/src/assets/images/logo-dark.png differ diff --git a/app/src/assets/images/logo.png b/app/src/assets/images/logo.png new file mode 100644 index 000000000..82994bb94 Binary files /dev/null and b/app/src/assets/images/logo.png differ diff --git a/app/src/assets/images/mark-dark.png b/app/src/assets/images/mark-dark.png new file mode 100644 index 000000000..1fe10e0dd Binary files /dev/null and b/app/src/assets/images/mark-dark.png differ diff --git a/app/src/assets/images/mark.png b/app/src/assets/images/mark.png new file mode 100644 index 000000000..074a0fdef Binary files /dev/null and b/app/src/assets/images/mark.png differ diff --git a/app/src/assets/images/wordmark-dark.png b/app/src/assets/images/wordmark-dark.png new file mode 100644 index 000000000..53bd7c978 Binary files /dev/null and b/app/src/assets/images/wordmark-dark.png differ diff --git a/app/src/assets/images/wordmark.png b/app/src/assets/images/wordmark.png new file mode 100644 index 000000000..b21548d6e Binary files /dev/null and b/app/src/assets/images/wordmark.png differ diff --git a/app/src/components/auth/AuthCardSections.tsx b/app/src/components/auth/AuthCardSections.tsx new file mode 100644 index 000000000..0065be481 --- /dev/null +++ b/app/src/components/auth/AuthCardSections.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react'; +import { View, type ViewStyle } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { Card } from '@/components/base/Card'; + +/** + * Shared scaffold for the auth screens that are a single Card holding a + * title and stacked content (forgot-password, reset-password, mfa, verify). + * Screens only supply their title and children; a screen with different + * spacing needs (verify's centered, taller layout) can pass `contentStyle`. + */ +export function AuthCard({ + title, + subtitle, + contentStyle, + children, +}: { + title: string; + // A short line directly under the title (e.g. mfa's mode description), + // grouped tighter than the rest of the card's content. + subtitle?: ReactNode; + contentStyle?: ViewStyle; + children: ReactNode; +}) { + return ( + + + + {title} + {subtitle} + + {children} + + + ); +} + +/** The danger-colored inline error text repeated across the auth forms. */ +export function AuthFormError({ message }: { message?: string | null }) { + if (!message) return null; + return {message}; +} + +/** The centered "Back to login" ghost action row repeated across the auth forms. */ +export function AuthBackToLoginAction({ onPress }: { onPress: () => void }) { + return ( + + + Back to login + + + ); +} diff --git a/app/src/components/auth/AuthScreen.tsx b/app/src/components/auth/AuthScreen.tsx new file mode 100644 index 000000000..f5631f017 --- /dev/null +++ b/app/src/components/auth/AuthScreen.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; + +/** + * The shared auth frame: a centered, width-capped island over the app backdrop, + * lifted clear of the software keyboard. Every auth screen uses this shape — + * screens only supply their own content and inner spacing. + * + * KeyboardAvoidingView comes from react-native-keyboard-controller (its + * KeyboardProvider is already mounted in the root layout) rather than RN's + * built-in, which needs per-platform `behavior` tuning to behave the same. + */ +export function AuthScreen({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, +}); diff --git a/app/src/components/auth/LoginSections.tsx b/app/src/components/auth/LoginSections.tsx new file mode 100644 index 000000000..0c0851ab3 --- /dev/null +++ b/app/src/components/auth/LoginSections.tsx @@ -0,0 +1,222 @@ +import { type RefObject, useCallback } from 'react'; +import type { Control, ControllerFieldState, ControllerRenderProps } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; +import { StyleSheet, View } from 'react-native'; +import { AuthScreen } from '@/components/auth/AuthScreen'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { BrandWordmark } from '@/components/base/BrandWordmark'; +import { FormFieldError } from '@/components/base/FormField'; +import { Icon } from '@/components/base/Icon'; +import { TextInput } from '@/components/base/TextInput'; +import type { LoginFormValues } from '@/services/api/validation/userSchema'; +import { useAppTheme } from '@/theme'; +import { describedBy } from '@/utils/a11y'; + +type LoginLayoutProps = { + children: React.ReactNode; + onBrowse: () => void; +}; + +export function LoginLayout({ children, onBrowse }: LoginLayoutProps) { + const theme = useAppTheme(); + return ( + + {/* Carries its own ground. As a bare ghost button it sat directly on the + backdrop photograph, where pixel sampling measured a worst case of + 3.55:1 in dark at 1440 — the contrast varies with whatever part of the + image lands under the glyphs, so it passes at some widths and fails at + others. `overlay.page` is the near-opaque page tone in both schemes. */} + + + Browse + + + + {children} + + + ); +} + +export function LoginCard({ children }: { children: React.ReactNode }) { + const theme = useAppTheme(); + return ( + + {children} + + ); +} + +export function LoginBrandHero() { + // No wash behind the mark: the hero scrim's centre band already calms the + // backdrop here, and the logo is a high-contrast shape that reads without a + // panel of its own. One less surface between the brand and the photo. + return ( + + + + ); +} + +type LoginFormSectionProps = { + control: Control; + emailRef: RefObject<{ focus(): void } | null>; + onSubmit: () => void; + onForgotPassword: () => void; +}; + +export function LoginFormSection({ + control, + emailRef, + onSubmit, + onForgotPassword, +}: LoginFormSectionProps) { + const theme = useAppTheme(); + const setEmailRef = useCallback( + (instance: { focus(): void } | null) => { + emailRef.current = instance; + }, + [emailRef], + ); + const renderEmail = useCallback( + ({ + field: { onChange, value }, + fieldState, + }: { + field: ControllerRenderProps; + fieldState: ControllerFieldState; + }) => { + const { error } = fieldState; + return ( + // A visible name that survives typing: the placeholder used to be the only + // label, and it disappears the moment the field has a value. + + Email or username + + + + ); + }, + [setEmailRef, theme.colors.outline, theme.tokens.status.danger], + ); + const renderPassword = useCallback( + ({ + field: { onChange, value }, + fieldState, + }: { + field: ControllerRenderProps; + fieldState: ControllerFieldState; + }) => { + const { error } = fieldState; + return ( + // A visible name that survives typing: the placeholder used to be the only + // label, and it disappears the moment the field has a value. + + Password + + + + ); + }, + [onSubmit, theme.colors.outline, theme.tokens.status.danger], + ); + + return ( + <> + + + + Sign in + + + Forgot password? + + + ); +} + +export function LoginDivider() { + const theme = useAppTheme(); + return ( + + + or + + + ); +} + +type LoginOAuthSectionProps = { + onGoogle: () => void; + onGithub: () => void; +}; + +export function LoginOAuthSection({ onGoogle, onGithub }: LoginOAuthSectionProps) { + const theme = useAppTheme(); + return ( + <> + + + Continue with Google + + + + Continue with GitHub + + + ); +} + +export function LoginSecondaryAction({ onCreateAccount }: { onCreateAccount: () => void }) { + return ( + + Create a new account + + ); +} + +const styles = StyleSheet.create({ + card: { + borderWidth: StyleSheet.hairlineWidth, + }, + brandLogo: { + width: '100%', + }, +}); diff --git a/app/src/components/auth/LogoutConfirm.tsx b/app/src/components/auth/LogoutConfirm.tsx new file mode 100644 index 000000000..25107c8c6 --- /dev/null +++ b/app/src/components/auth/LogoutConfirm.tsx @@ -0,0 +1,35 @@ +import type { RefObject } from 'react'; +import { View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { AppText } from '@/components/base/AppText'; +import { dialogActionsStyle, dialogTitleStyle } from '@/components/base/dialogStyles'; + +export default function LogoutConfirm({ + visible, + onDismiss, + onConfirm, + triggerRef, +}: { + visible: boolean; + onDismiss: () => void; + onConfirm: () => void; + triggerRef?: RefObject; +}) { + return ( + + + Sign out + + Are you sure you want to sign out? + + + Cancel + + + Sign out + + + + ); +} diff --git a/app/src/components/auth/NewAccountSections.tsx b/app/src/components/auth/NewAccountSections.tsx new file mode 100644 index 000000000..6ab5f60cc --- /dev/null +++ b/app/src/components/auth/NewAccountSections.tsx @@ -0,0 +1,455 @@ +import { type ComponentProps, type ReactNode, useCallback } from 'react'; +import type { Control, ControllerRenderProps, FieldErrors } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { BrandWordmark } from '@/components/base/BrandWordmark'; +import { FormFieldError } from '@/components/base/FormField'; +import { Icon } from '@/components/base/Icon'; +import { TextInput } from '@/components/base/TextInput'; +import { WEBSITE_URL } from '@/config'; +import { + type NewAccountFormValues, + PASSWORD_MIN_LENGTH, +} from '@/services/api/validation/userSchema'; +import { openExternalUrl } from '@/services/externalLinks'; +import { useAppTheme } from '@/theme'; +import { describedBy } from '@/utils/a11y'; + +// Every row is a fixed slot, so the three steps are identical and nothing moves +// as the error message comes and goes. The slots stay fixed-height even under +// OS font scaling; MAX_FONT_SCALE caps the label text so it stays legible +// instead of clipping against the slot at large accessibility sizes. (The +// helper/error text renders via the shared FormFieldError component, which +// carries the same cap itself.) +const LABEL_ROW_HEIGHT = 16; +const MAX_FONT_SCALE = 1.5; +const INPUT_ROW_HEIGHT = 48; +const HELPER_SLOT_HEIGHT = 18; +const ACTION_ROW_HEIGHT = 44; +const CARD_PADDING = 16; +const CARD_GAP = 6; +const CARD_HEIGHT = + CARD_PADDING * 2 + + LABEL_ROW_HEIGHT + + INPUT_ROW_HEIGHT + + HELPER_SLOT_HEIGHT + + ACTION_ROW_HEIGHT + + CARD_GAP * 3; +// Shared by the step card and the footer card so the two line up. +const CARD_MAX_WIDTH = 380; + +const styles = StyleSheet.create({ + step: { + maxWidth: CARD_MAX_WIDTH, + }, + // NOTE: welcomeText/brandText/questionText are a bespoke three-line + // headline stack (own line, own weight per line), plain RN Text predating + // AppText — no single ramp step covers three different custom sizes in + // one headline. + welcomeText: { + fontSize: 40, + }, + // 64, not 80: it has to fit the card's measure now, and a username is + // arbitrary text — the slot is a fixed height, so it truncates rather than + // wrapping out of it. + brandText: { + fontSize: 64, + }, + // Logo standing in for the "Relab" wordmark on the first step; sized to + // carry the same visual weight as the brandText it replaces. + brandLogo: { + width: 200, + }, + questionText: { + fontSize: 31, + }, + // maxWidth: the control block is a compact instrument under a wide headline — + // a username field has no business being 390px. Fixed height so all three + // steps are the same size and nothing moves when the error slot fills. + card: { + borderWidth: StyleSheet.hairlineWidth, + height: CARD_HEIGHT, + padding: CARD_PADDING, + gap: CARD_GAP, + }, + // A visible label that survives typing — the placeholder used to be the only + // name for the field, and it disappears the moment you type into it. + // NOTE: fontSize 12 is sized to LABEL_ROW_HEIGHT (16) as part of the + // hand-computed CARD_HEIGHT; swapping to a ramp step's own line height + // would desync the fixed-height card math above. + label: { + height: LABEL_ROW_HEIGHT, + fontSize: 12, + }, + inputRow: { + height: INPUT_ROW_HEIGHT, + }, + helperSlot: { + height: HELPER_SLOT_HEIGHT, + }, + // NOTE: see label above — locked to HELPER_SLOT_HEIGHT (18), not a ramp step. + helperText: { + fontSize: 12, + }, + // Back left, primary right — the wizard convention. The primary action gets + // its own row so "Create account" stops overflowing the card's padding. + actionRow: { + height: ACTION_ROW_HEIGHT, + }, + // NOTE: sized to sit visually with the 16px chevron beside it, not a ramp step. + backButtonText: { + fontSize: 13, + }, + scroll: { + paddingBottom: 120, + }, + footerCard: { + borderWidth: StyleSheet.hairlineWidth, + maxWidth: CARD_MAX_WIDTH, + }, + // NOTE: matches label/helperText's 12px so all of this wizard's small print + // reads as one consistent size, rather than mixing in the 13px caption step. + privacyText: { + fontSize: 12, + }, + privacyLink: { + fontSize: 12, + }, +}); + +type SharedStepProps = { + control: Control; + errors: FieldErrors; + headlineColor: string; + mutedColor: string; +}; + +// No checkbox: creating the account is the acceptance, recorded server-side as +// terms_accepted_version / terms_accepted_at. +export function PrivacyPolicy() { + const theme = useAppTheme(); + const termsUrl = WEBSITE_URL ? new URL('/terms', WEBSITE_URL).toString() : ''; + const privacyUrl = WEBSITE_URL ? new URL('/privacy', WEBSITE_URL).toString() : ''; + const textColor = theme.colors.onBackground; + const openTerms = useCallback(() => { + if (termsUrl) { + void openExternalUrl(termsUrl); + } + }, [termsUrl]); + const openPrivacy = useCallback(() => { + if (privacyUrl) { + void openExternalUrl(privacyUrl); + } + }, [privacyUrl]); + + return ( + + By creating an account, you agree to our{' '} + + Terms + {' '} + and{' '} + + Privacy Policy + + + ); +} + +type StepFieldName = 'username' | 'email' | 'password'; + +function NewAccountStep({ + control, + errors, + headlineColor, + mutedColor, + field, + lines, + label, + brandLogo, + inputProps, + next, + submit, + back, +}: SharedStepProps & { + field: StepFieldName; + lines: [string, string, string]; + label: string; + brandLogo?: boolean; + inputProps: ComponentProps; + next?: { testID: string; accessibilityLabel: string; onPress: () => void }; + submit?: { isSubmitting: boolean; onPress: () => void }; + back?: { label: string; accessibilityLabel: string; onPress: () => void }; +}) { + const theme = useAppTheme(); + const error = errors[field]; + const errorId = `${field}-error`; + const renderInput = useCallback( + ({ field: { onChange, value } }: { field: ControllerRenderProps }) => ( + + ), + [error, errorId, inputProps, label, theme], + ); + + return ( + // One column for the headline and the card, so the copy starts exactly at + // the card's left edge instead of floating out to the wider measure. + + + {lines[0]} + + {/* Fixed height: the mark and the 80px name measure differently, and without + this the card sat 22px lower on step one than on the rest. */} + + {brandLogo ? ( + + ) : ( + + {lines[1]} + + )} + + + {lines[2]} + + + + {label} + + + + + {/* Reserved, never conditional: the message fills this slot instead of growing + the card. The error still stays until the field is actually fixed — it is + not on a timer — it just no longer moves the layout when it appears. */} + + + + + {/* Holds the left half of the action row even on step one, which has no back + action — otherwise the primary button would slide across between steps. */} + + {back ? ( + + + + {back.label} + + + ) : null} + + {next ? ( + + Continue + + ) : null} + {submit ? ( + + Create account + + ) : null} + + + + ); +} + +export function NewAccountUsernameStep({ + onAdvance, + ...shared +}: SharedStepProps & { onAdvance: () => void }) { + return ( + + ); +} + +export function NewAccountEmailStep({ + username, + onAdvance, + onBack, + ...shared +}: SharedStepProps & { + username: string; + onAdvance: () => void; + onBack: () => void; +}) { + return ( + + ); +} + +export function NewAccountPasswordStep({ + username, + isSubmitting, + onSubmit, + onBack, + ...shared +}: SharedStepProps & { + username: string; + isSubmitting: boolean; + onSubmit: () => void; + onBack: () => void; +}) { + return ( + + ); +} + +type NewAccountLayoutProps = { + children: ReactNode; + onNavigateToLogin: () => void; +}; + +export function NewAccountLayout({ children, onNavigateToLogin }: NewAccountLayoutProps) { + const theme = useAppTheme(); + return ( + + + {/* An intrinsic cap rather than a breakpoint: the column fills narrow screens + and stops growing past a readable measure, so there's no width at which the + layout jumps. */} + {children} + + + {/* On a card, not bare over the photo: the hero scrim is light by design + and the backdrop's densest area sits right behind this footer. */} + + + + + I already have an account + + + + + ); +} diff --git a/app/src/components/auth/TermsAcceptanceDialog.tsx b/app/src/components/auth/TermsAcceptanceDialog.tsx new file mode 100644 index 000000000..54aeb6555 --- /dev/null +++ b/app/src/components/auth/TermsAcceptanceDialog.tsx @@ -0,0 +1,61 @@ +import { useCallback } from 'react'; +import { View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { AppText } from '@/components/base/AppText'; +import { dialogActionsStyle, dialogTitleStyle } from '@/components/base/dialogStyles'; +import { WEBSITE_URL } from '@/config'; +import { useTermsAcceptance } from '@/features/auth/useTermsAcceptance'; +import { openExternalUrl } from '@/services/externalLinks'; + +/** + * Asks accounts created before acceptance was tracked to grant the publication + * licence a dataset release needs. + * + * Declining is free and changes nothing: the account keeps full access, and its + * records simply stay out of published releases. That is deliberate — a grant + * extracted by withholding access is the one whose validity gets questioned. + */ +export function TermsAcceptanceDialog() { + const { shouldPrompt, isAccepting, accept, dismiss } = useTermsAcceptance(); + const termsUrl = WEBSITE_URL ? new URL('/terms', WEBSITE_URL).toString() : ''; + + const openTerms = useCallback(() => { + if (termsUrl) void openExternalUrl(termsUrl); + }, [termsUrl]); + + const onAccept = useCallback(() => { + void accept(); + }, [accept]); + + return ( + + + Contributor terms + + + + Relab publishes curated datasets of the records contributed to it. Your records can only + be included if you accept the contributor terms. + + + Nothing changes if you decline — you keep full access to Relab, and your records stay out + of published datasets. You can accept later from your account screen. + + + + {termsUrl ? ( + + Read terms + + ) : null} + + Not now + + + {isAccepting ? 'Saving…' : 'Accept'} + + + + ); +} diff --git a/app/src/components/auth/__tests__/LogoutConfirm.test.tsx b/app/src/components/auth/__tests__/LogoutConfirm.test.tsx new file mode 100644 index 000000000..93ca7f615 --- /dev/null +++ b/app/src/components/auth/__tests__/LogoutConfirm.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import LogoutConfirm from '@/components/auth/LogoutConfirm'; +import { renderWithProviders, setupUser } from '@/test-utils/index'; + +describe('LogoutConfirm', () => { + const user = setupUser(); + + it('renders the logout dialog when visible', () => { + renderWithProviders(, { + withDialog: true, + }); + expect(screen.getAllByText('Sign out').length).toBeGreaterThan(0); + expect(screen.getByText('Are you sure you want to sign out?')).toBeOnTheScreen(); + }); + + it('does not render dialog content when not visible', () => { + renderWithProviders( + , + { + withDialog: true, + }, + ); + expect(screen.queryByText('Are you sure you want to sign out?')).toBeNull(); + }); + + it('calls onDismiss when Cancel is pressed', async () => { + const onDismiss = jest.fn(); + renderWithProviders(, { + withDialog: true, + }); + await user.press(screen.getByText('Cancel')); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('calls onConfirm when Logout button is pressed', async () => { + const onConfirm = jest.fn(); + renderWithProviders(, { + withDialog: true, + }); + const items = screen.getAllByText('Sign out'); + await user.press(items[items.length - 1]); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/auth/__tests__/NewAccountSections.test.tsx b/app/src/components/auth/__tests__/NewAccountSections.test.tsx new file mode 100644 index 000000000..d47275080 --- /dev/null +++ b/app/src/components/auth/__tests__/NewAccountSections.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import { PrivacyPolicy } from '@/components/auth/NewAccountSections'; +import { openExternalUrl } from '@/services/externalLinks'; +import { renderWithProviders, setupUser } from '@/test-utils/index'; + +// EXPO_PUBLIC_WEBSITE_URL is unset under Jest, which would leave both links inert. +const WEBSITE_URL = 'https://relab.example'; + +jest.mock('@/config', () => ({ + ...(jest.requireActual('@/config') as object), + WEBSITE_URL: 'https://relab.example', +})); + +jest.mock('@/services/externalLinks', () => ({ + openExternalUrl: jest.fn(), +})); + +const mockOpenExternalUrl = openExternalUrl as jest.MockedFunction; + +describe('PrivacyPolicy', () => { + const user = setupUser(); + + it('names both agreements a new account accepts', () => { + renderWithProviders(); + + expect(screen.getByText('Terms')).toBeOnTheScreen(); + expect(screen.getByText('Privacy Policy')).toBeOnTheScreen(); + }); + + it('opens the terms page on the website', async () => { + renderWithProviders(); + + await user.press(screen.getByText('Terms')); + + expect(mockOpenExternalUrl).toHaveBeenCalledWith(new URL('/terms', WEBSITE_URL).toString()); + }); + + it('opens the privacy policy on the website', async () => { + renderWithProviders(); + + await user.press(screen.getByText('Privacy Policy')); + + expect(mockOpenExternalUrl).toHaveBeenCalledWith(new URL('/privacy', WEBSITE_URL).toString()); + }); +}); diff --git a/app/src/components/auth/__tests__/TermsAcceptanceDialog.test.tsx b/app/src/components/auth/__tests__/TermsAcceptanceDialog.test.tsx new file mode 100644 index 000000000..3a314acc5 --- /dev/null +++ b/app/src/components/auth/__tests__/TermsAcceptanceDialog.test.tsx @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { TermsAcceptanceDialog } from '@/components/auth/TermsAcceptanceDialog'; +// biome-ignore lint/performance/noNamespaceImport: the namespace object is the thing under test control — setWebsiteUrl redefines a property on it. +import * as config from '@/config'; +import { useTermsPromptDismissed } from '@/features/auth/useTermsAcceptance'; +import { mockUser } from '@/test-utils/api-mocks'; +import { renderWithProviders } from '@/test-utils/index'; + +const mockUseAuth = jest.fn(); +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/services/api/terms', () => ({ + acceptContributorTerms: jest.fn(), +})); + +jest.mock('@/services/externalLinks', () => ({ + openExternalUrl: jest.fn(), +})); + +// WEBSITE_URL comes from the Expo env at module load and is unset under Jest, so the +// component would hide "Read terms" for every test. Overridden per test on the module +// object rather than through jest.mock(): a mock factory is hoisted above this file's +// own declarations, so a factory reading a local would only ever see it uninitialised. +function setWebsiteUrl(value: string | undefined) { + Object.defineProperty(config, 'WEBSITE_URL', { value, configurable: true }); +} + +const { acceptContributorTerms } = jest.requireMock('@/services/api/terms') as { + acceptContributorTerms: jest.Mock<() => Promise>; +}; +const { openExternalUrl } = jest.requireMock('@/services/externalLinks') as { + openExternalUrl: jest.Mock; +}; + +const DECLINE_IS_FREE = /Nothing changes if you decline/; + +const refetch = jest.fn(async () => undefined); + +function signedInWith(termsAcceptanceRequired: boolean) { + mockUseAuth.mockReturnValue({ + user: mockUser({ termsAcceptanceRequired }), + isLoading: false, + refetch, + }); +} + +describe('TermsAcceptanceDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + acceptContributorTerms.mockResolvedValue(undefined); + globalThis.sessionStorage?.clear(); + useTermsPromptDismissed.setState({ dismissed: false }); + setWebsiteUrl('https://relab.example'); + }); + + it('stays closed when the account has already accepted', () => { + signedInWith(false); + + renderWithProviders(, { withDialog: true }); + + expect(screen.queryByText('Contributor terms')).toBeNull(); + }); + + it('stays closed when signed out', () => { + mockUseAuth.mockReturnValue({ user: undefined, isLoading: false, refetch }); + + renderWithProviders(, { withDialog: true }); + + expect(screen.queryByText('Contributor terms')).toBeNull(); + }); + + it('prompts an account that still owes acceptance', () => { + signedInWith(true); + + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText('Contributor terms')).toBeTruthy(); + expect(screen.getByText('Accept')).toBeTruthy(); + }); + + it('says plainly that declining costs nothing', () => { + signedInWith(true); + + renderWithProviders(, { withDialog: true }); + + // The consent is only meaningful if the refusal is real and seen to be free. + expect(screen.getByText(DECLINE_IS_FREE)).toBeTruthy(); + }); + + it('accepts, then closes without asking again', async () => { + signedInWith(true); + + renderWithProviders(, { withDialog: true }); + fireEvent.press(screen.getByText('Accept')); + + await waitFor(() => expect(acceptContributorTerms).toHaveBeenCalledTimes(1)); + // Refetched rather than patched locally: the server owns the version it stamped. + await waitFor(() => expect(refetch).toHaveBeenCalledWith(true)); + + signedInWith(false); + screen.rerender(); + expect(screen.queryByText('Accept')).toBeNull(); + }); + + it('dismisses for the session without recording anything', () => { + signedInWith(true); + + renderWithProviders(, { withDialog: true }); + fireEvent.press(screen.getByText('Not now')); + + expect(acceptContributorTerms).not.toHaveBeenCalled(); + expect(useTermsPromptDismissed.getState().dismissed).toBe(true); + expect(screen.queryByText('Accept')).toBeNull(); + }); + + it('opens the public terms page rather than restating them in the app', () => { + signedInWith(true); + + renderWithProviders(, { withDialog: true }); + fireEvent.press(screen.getByText('Read terms')); + + expect(openExternalUrl).toHaveBeenCalledTimes(1); + expect(String(openExternalUrl.mock.calls[0][0])).toContain('/terms'); + }); + + it('hides the terms link when no public site URL is configured', () => { + setWebsiteUrl(undefined); + signedInWith(true); + + renderWithProviders(, { withDialog: true }); + + // A button that opens nothing is worse than no button. Accepting still works: + // an unconfigured site URL must not block the grant itself. + expect(screen.queryByText('Read terms')).toBeNull(); + expect(screen.getByText('Accept')).toBeTruthy(); + }); + + it('remembers the dismissal across a reload', () => { + // Regression: the dismissal used to be in-memory only, so every page load + // re-opened the modal. That is nagging rather than asking, and it blocked + // every authenticated e2e spec that navigates with a full page load. + signedInWith(true); + + renderWithProviders(, { withDialog: true }); + fireEvent.press(screen.getByText('Not now')); + + expect(globalThis.sessionStorage.getItem('terms_prompt_dismissed')).toBe('true'); + }); + + it('keeps the prompt due after a dismissal, so the next login asks again', () => { + signedInWith(true); + useTermsPromptDismissed.setState({ dismissed: true }); + + renderWithProviders(, { withDialog: true }); + + // Dismissal hides the dialog but must not look like acceptance anywhere else: + // the account row keys off `required`, which is untouched. + expect(screen.queryByText('Accept')).toBeNull(); + expect(mockUser({ termsAcceptanceRequired: true }).termsAcceptanceRequired).toBe(true); + }); +}); diff --git a/app/src/components/base/AmountStepper.tsx b/app/src/components/base/AmountStepper.tsx new file mode 100644 index 000000000..6849b3799 --- /dev/null +++ b/app/src/components/base/AmountStepper.tsx @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; +import { View } from 'react-native'; +import { AppButton } from './AppButton'; +import { AppText } from './AppText'; + +type AmountStepperProps = { + value: number; + onChange: (next: number) => void; + min?: number; + label?: string; +}; + +/** Integer stepper: 8 identical screws are one record with amount 8, not 8 records. */ +export function AmountStepper({ value, onChange, min = 1, label = 'Amount' }: AmountStepperProps) { + const handleDecrement = useCallback(() => onChange(value - 1), [onChange, value]); + const handleIncrement = useCallback(() => onChange(value + 1), [onChange, value]); + + return ( + + {label} + + − + + {String(value)} + + + + + + ); +} diff --git a/app/src/components/base/AppButton.tsx b/app/src/components/base/AppButton.tsx new file mode 100644 index 000000000..8b0ceca2e --- /dev/null +++ b/app/src/components/base/AppButton.tsx @@ -0,0 +1,71 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { + type AppButtonVariant, + VARIANT_FOREGROUND_COLOR, +} from '@/components/base/appButtonVariants'; +import { Button } from '@/components/base/ui/button'; +import { Text } from '@/components/base/ui/text'; +import { useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; + +type RnrVariant = ComponentProps['variant']; + +const VARIANT_MAP: Record = { + primary: 'default', + tonal: 'tonal', + outline: 'outline', + ghost: 'ghost', + destructive: 'destructive', +}; + +// Omit 'variant' from the vendored button's props: AppButton remaps its own +// app-level variant names to the RNR ones via VARIANT_MAP. Everything else +// (accessibilityHint, accessibilityState, aria-*, onLongPress, ...) passes +// through via `...rest` so callers aren't limited to the props this file +// happened to name explicitly. +type AppButtonProps = Omit, 'variant'> & { + variant?: AppButtonVariant; + loading?: boolean; + children: ReactNode; +}; + +/** App button over the vendored RNR button; maps app variants and a loading state. */ +export function AppButton({ + variant = 'primary', + loading = false, + disabled = false, + children, + className, + ...rest +}: AppButtonProps) { + const { colors } = useAppTheme(); + // Bare RN text nodes must live inside ; wrap primitive children so a numeric or + // string label renders safely. Anything else (an element, null, or a boolean, which + // React already renders as nothing) passes through untouched. + const renderedChildren = + typeof children === 'string' || typeof children === 'number' ? ( + {children} + ) : ( + children + ); + return ( + + ); +} diff --git a/app/src/components/base/AppDialog.tsx b/app/src/components/base/AppDialog.tsx new file mode 100644 index 000000000..6247415cc --- /dev/null +++ b/app/src/components/base/AppDialog.tsx @@ -0,0 +1,80 @@ +import type { ReactNode, RefObject } from 'react'; +import { Modal, Pressable, StyleSheet, type View } from 'react-native'; +import Animated, { Easing, FadeInUp, ReduceMotion } from 'react-native-reanimated'; +import { useReturnFocus } from '@/hooks/useReturnFocus'; +import { useAppTheme } from '@/theme'; +import { OverlaySurface } from './OverlaySurface'; + +type AppDialogProps = { + visible: boolean; + onDismiss: () => void; + /** When false, tapping the backdrop or pressing Escape/back does not dismiss. Defaults to true. */ + dismissable?: boolean; + /** The element that opened this dialog, so native screen readers can return focus to it on close. */ + triggerRef?: RefObject; + children: ReactNode; +}; + +const NOOP = () => {}; + +// Swallow presses so tapping inside the dialog doesn't dismiss it. Module-level so it's +// a stable reference across renders (this rule turns on for tap targets in this repo). +function stopPropagation(e: { stopPropagation: () => void }) { + e.stopPropagation(); +} + +/** + * Shared chrome for the app's Paper-free dialogs: a centered surface over a + * scrim, built on React Native's core Modal — which brings its own focus trap + * and Escape→onRequestClose on web (see DialogProvider.tsx for the rationale + * behind not using the vendored rn-primitives ui/dialog here). + * + * NOTE: hand-rolled on purpose — uses RN-core Modal for native focus trap and Escape→onRequestClose. + */ +export function AppDialog({ + visible, + onDismiss, + dismissable = true, + triggerRef, + children, +}: AppDialogProps) { + const theme = useAppTheme(); + const handleDismiss = dismissable ? onDismiss : undefined; + // Every dialog inherits return-focus on close; triggerRef is optional and + // only needed for native screen-reader focus restore (web works without it). + useReturnFocus(visible, triggerRef); + + return ( + + + + + + {children} + + + + + + ); +} + +const styles = StyleSheet.create({ + dialogWrapper: { + maxWidth: 480, + maxHeight: '85%', + }, +}); diff --git a/app/src/components/base/AppText.tsx b/app/src/components/base/AppText.tsx new file mode 100644 index 000000000..8aef70629 --- /dev/null +++ b/app/src/components/base/AppText.tsx @@ -0,0 +1,48 @@ +import type { ComponentProps } from 'react'; +import { Text } from 'react-native'; +import { useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; + +type Variant = 'display' | 'title' | 'heading' | 'body' | 'label' | 'caption' | 'data' | 'eyebrow'; + +// Record content (body copy, data readouts) is documentation — make it +// selectable so it can be copied. Headings and labels aren't content, so they +// stay non-selectable. RN's `selectable` prop maps to CSS `user-select` on +// RN-web (react-native-web's Text applies `styles.selectable`/`notSelectable` +// based on this prop) and is a harmless no-op on native. +const SELECTABLE_VARIANTS = new Set(['body', 'data']); + +type AppTextProps = ComponentProps & { variant?: Variant }; + +/** + * Themed text mapped to the app type scale (tokens.type). Default: body. + * The default color is a className (not inline style) so caller `text-*` + * classes can override it — inline styles always beat classNames under Uniwind, + * so a style-based default would silently eat them. + * + * `maxFontSizeMultiplier` defaults to the app-wide Dynamic Type cap (2x); a + * no-op on web, it keeps native OS text scaling from blowing out fixed + * layouts. Callers can still override it per instance. + */ +export function AppText({ + variant = 'body', + style, + className, + maxFontSizeMultiplier = 2, + ...rest +}: AppTextProps) { + const { tokens } = useAppTheme(); + const scale = + variant === 'eyebrow' + ? { ...tokens.type.label, textTransform: 'uppercase' as const } + : tokens.type[variant]; + return ( + + ); +} diff --git a/app/src/components/base/BottomNav.tsx b/app/src/components/base/BottomNav.tsx new file mode 100644 index 000000000..c4f388586 --- /dev/null +++ b/app/src/components/base/BottomNav.tsx @@ -0,0 +1,101 @@ +import type { BottomTabBarProps } from 'expo-router/js-tabs'; +import { useCallback } from 'react'; +import { Platform, Pressable, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MIN_TAP_TARGET, WEB_FOCUS_RING } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; +import { AppText } from './AppText'; +import { Icon } from './Icon'; +import { type Tab, tabRouteName, useBottomNavTabs, useBottomNavVisible } from './useBottomNav'; + +function BottomNavTab({ + tab, + active, + onPress, +}: { + tab: Tab; + active: boolean; + onPress: (key: string) => void; +}) { + const theme = useAppTheme(); + const handlePress = useCallback(() => onPress(tab.key), [onPress, tab.key]); + return ( + + + + {tab.label} + + + ); +} + +/** + * The `tabBar` of the (tabs) navigator: Products / Cameras / Account. TopNav + * owns >=lg web; this bar owns everything else (native always, web below lg), + * on every screen inside a tab — including detail screens, so a tab is always + * one tap away. Flat & sharp: hairline top border, no elevation. + * + * Navigating by route name (not href) is what makes a tab switch return to that + * tab's preserved trail instead of resetting it to the tab's root screen. + */ +export function BottomNav({ state, navigation }: BottomTabBarProps) { + const tabs = useBottomNavTabs(); + const visible = useBottomNavVisible(); + const insets = useSafeAreaInsets(); + const { routes } = state; + const activeRoute = routes[state.index]?.name; + const goTo = useCallback( + (key: string) => { + const name = tabRouteName(key); + const route = routes.find((candidate) => candidate.name === name); + if (!route) return; + // Same contract as the stock tab bar: tabPress is what the focused tab's + // own listeners hang off (the nested stack's pop-to-top), and it can + // preventDefault. Without it, tapping the tab you're already on is a dead + // control instead of scrolling/popping back to that tab's root. + const event = navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + if (route.name !== activeRoute && !event.defaultPrevented) navigation.navigate(name); + }, + [navigation, routes, activeRoute], + ); + + if (!visible) return null; + + return ( + + {tabs.map((tab) => ( + + ))} + + ); +} diff --git a/app/src/components/base/BrandHeaderTitle.tsx b/app/src/components/base/BrandHeaderTitle.tsx new file mode 100644 index 000000000..a3ee2d6de --- /dev/null +++ b/app/src/components/base/BrandHeaderTitle.tsx @@ -0,0 +1,25 @@ +import { Image } from 'expo-image'; +import { StyleSheet } from 'react-native'; + +// The R9lab wordmark for the nav header, theme-swapped so it stays legible on +// both light and dark headers. +export function BrandHeaderTitle({ isDark }: { isDark: boolean }) { + const source = isDark + ? require('@/assets/images/wordmark-dark.png') + : require('@/assets/images/wordmark.png'); + return ( + + ); +} + +const styles = StyleSheet.create({ + wordmark: { + height: 30, + width: 72, + }, +}); diff --git a/app/src/components/base/BrandWordmark.tsx b/app/src/components/base/BrandWordmark.tsx new file mode 100644 index 000000000..d6c168bfd --- /dev/null +++ b/app/src/components/base/BrandWordmark.tsx @@ -0,0 +1,26 @@ +import { Asset } from 'expo-asset'; +import { Image } from 'expo-image'; +import type { ImageStyle, StyleProp } from 'react-native'; +import { useAppTheme } from '@/theme'; + +const LOGO_LIGHT = require('@/assets/images/logo.png'); +const LOGO_DARK = require('@/assets/images/logo-dark.png'); + +/** + * The theme-matched brand logo (ringed R9lab wordmark), sized by the bundled + * asset's intrinsic aspect ratio — callers only set a width. The label carries + * the pronounced name "Relab" (see assets/DESIGN.md, Voice). + */ +export function BrandWordmark({ style }: { style?: StyleProp }) { + const theme = useAppTheme(); + const source = theme.dark ? LOGO_DARK : LOGO_LIGHT; + const { width, height } = Asset.fromModule(source); + return ( + + ); +} diff --git a/app/src/components/base/Card.tsx b/app/src/components/base/Card.tsx new file mode 100644 index 000000000..af42b2b0a --- /dev/null +++ b/app/src/components/base/Card.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react'; +import { View, type ViewProps } from 'react-native'; +import { cn } from '@/utils/cn'; + +interface Props extends Omit { + children: ReactNode; + className?: string; +} + +/** Plain card surface — border + rounded corners on the theme's card background. */ +export function Card({ children, className, ...props }: Props) { + return ( + + {children} + + ); +} diff --git a/app/src/components/base/CenteredSpinner.tsx b/app/src/components/base/CenteredSpinner.tsx new file mode 100644 index 000000000..51d0a219b --- /dev/null +++ b/app/src/components/base/CenteredSpinner.tsx @@ -0,0 +1,17 @@ +import { ActivityIndicator, View } from 'react-native'; +import { useAppTheme } from '@/theme'; + +/** Full-height centered loading spinner for screen-level pending states. */ +export function CenteredSpinner() { + const { colors } = useAppTheme(); + return ( + + + + ); +} diff --git a/app/src/components/base/Chip.tsx b/app/src/components/base/Chip.tsx new file mode 100644 index 000000000..1d771d1b2 --- /dev/null +++ b/app/src/components/base/Chip.tsx @@ -0,0 +1,107 @@ +// NOTE: hand-rolled on purpose — Pressable two-segment pill with control radius and danger-tint state. +import type React from 'react'; +import { useCallback } from 'react'; +import { + Pressable, + type PressableProps, + type PressableStateCallbackType, + StyleSheet, + View, +} from 'react-native'; +import { MIN_TAP_TARGET, radius } from '@/constants'; +import { getStatusTone, useAppTheme } from '@/theme'; +import { AppText } from './AppText'; +import { Icon } from './Icon'; + +interface Props extends PressableProps { + children?: string; + title?: string; + icon?: React.ReactNode; + error?: boolean; +} + +export const Chip = ({ + style, + children, + title, + icon, + error, + disabled, + accessibilityLabel, + accessibilityRole = 'button', + accessibilityState, + ...props +}: Props) => { + const theme = useAppTheme(); + const danger = theme.tokens.status.danger; + + const resolveStyle = useCallback( + (state: PressableStateCallbackType) => { + const resolvedStyle = typeof style === 'function' ? style(state) : style; + return [ + // No className on this Pressable: it would drop this function (see IconButton.tsx). + styles.base, + { backgroundColor: theme.tokens.surface.accent }, + state.pressed && { opacity: 0.5 }, + resolvedStyle, + ]; + }, + [style, theme], + ); + + // Composed so a screen reader gets one coherent name ("Brand: Unknown, + // required") instead of reading the title and value segments separately; + // an explicit accessibilityLabel from the caller always wins. + const composedLabel = + accessibilityLabel ?? + (title ? `${title}: ${children ?? ''}${error ? ', required' : ''}` : undefined); + + return ( + + {title ? ( + + {title} + + ) : null} + + {/* Error state must not be color-only (WCAG 1.4.1): a compact alert icon + carries the signal alongside the danger tint/border/text. */} + {error ? : null} + + {children} + + {icon} + + + ); +}; + +const styles = StyleSheet.create({ + base: { + flexDirection: 'row', + borderRadius: radius.control, + minHeight: MIN_TAP_TARGET, + }, +}); diff --git a/app/src/components/base/ControlledTextField.tsx b/app/src/components/base/ControlledTextField.tsx new file mode 100644 index 000000000..73917e38a --- /dev/null +++ b/app/src/components/base/ControlledTextField.tsx @@ -0,0 +1,51 @@ +import { useId } from 'react'; +import { type Control, Controller, type FieldPath, type FieldValues } from 'react-hook-form'; +import { type TextInputProps, View } from 'react-native'; +import { describedBy } from '@/utils/a11y'; +import { AppText } from './AppText'; +import { FormFieldError } from './FormField'; +import { TextInput } from './TextInput'; + +type Props = Omit & { + control: Control; + name: FieldPath; + label?: string; + /** Sanitize/transform keystrokes before they reach the form state (e.g. pairing-code uppercase). */ + transform?: (text: string) => string; +}; + +/** + * RHF Controller + label + TextInput + FormFieldError in one unit, with the + * error programmatically linked to the input (WCAG 1.3.1/3.3.1) — the wiring + * every call site previously hand-rolled, inconsistently. + */ +export function ControlledTextField({ + control, + name, + label, + transform, + ...inputProps +}: Props) { + const errorId = useId(); + return ( + ( + + {label ? {label} : null} + onChange(transform ? transform(text) : text)} + bordered + {...describedBy(errorId, Boolean(error?.message))} + {...inputProps} + /> + + + )} + /> + ); +} diff --git a/app/src/components/base/DetailSectionHeader.tsx b/app/src/components/base/DetailSectionHeader.tsx new file mode 100644 index 000000000..3b0fcc7b4 --- /dev/null +++ b/app/src/components/base/DetailSectionHeader.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import { View } from 'react-native'; +import { AppText } from './AppText'; +import { InfoTooltip } from './InfoTooltip'; + +interface Props { + title: string; + tooltipTitle?: string; + rightElement?: Exclude>; +} + +/** + * A sub-header inside a Section card. Section itself is the card title + * (`heading`, 19/24), so this sits one step below it: body size, semibold. + */ +export default function DetailSectionHeader({ title, tooltipTitle, rightElement }: Props) { + return ( + + + {/* The tooltip renders a View, so it sits beside the AppText, not inside it. */} + + {title} + + {tooltipTitle ? : null} + + {rightElement ? {rightElement} : null} + + ); +} diff --git a/app/src/components/base/DialogProvider.tsx b/app/src/components/base/DialogProvider.tsx new file mode 100644 index 000000000..a75c32b38 --- /dev/null +++ b/app/src/components/base/DialogProvider.tsx @@ -0,0 +1,297 @@ +import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { AccessibilityInfo, Platform, StyleSheet, View } from 'react-native'; +import Animated, { FadeInDown, FadeOut, ReduceMotion } from 'react-native-reanimated'; +import { useAppTheme, useInverseSurface } from '@/theme'; +import { cn } from '@/utils/cn'; +import { AppButton } from './AppButton'; +import { AppDialog } from './AppDialog'; +import { AppText } from './AppText'; +import { + type DialogButton, + DialogContext, + type DialogContextType, + type DialogOptions, + pickSubmitButton, + type ToastAction, +} from './dialogContext'; +import { dialogActionsStyle, dialogTitleStyle } from './dialogStyles'; +import { OverlaySurface } from './OverlaySurface'; +import { TextInput } from './TextInput'; + +// Within WCAG's 3-5s auto-dismiss guidance for transient toasts. +const TOAST_DURATION_MS = 4000; +// A toast carrying an action needs long enough to be noticed, read and reached; +// WCAG 2.2.1 allows the longer dismiss precisely because there is a control. +const ACTION_TOAST_DURATION_MS = 8000; + +export function DialogProvider({ children }: { children: ReactNode }) { + const [options, setOptions] = useState(null); + // Separate from `options` (which is kept around, not nulled, on close) so AppDialog's + // `visible` prop actually transitions true→false instead of the whole tree unmounting — + // useReturnFocus's return-focus-on-close logic (web and native, incl. triggerRef) only + // fires on that transition, never on unmount. + const [visible, setVisible] = useState(false); + // A fresh object per trigger: repeating the same message still produces a new + // identity, so the Toast effect re-runs and the dismiss timer resets. + const [toastState, setToastState] = useState<{ + message: string; + action?: ToastAction; + } | null>(null); + const [dialogVersion, setDialogVersion] = useState(0); + + const alert = useCallback((opts: DialogOptions) => { + setOptions({ ...opts, input: false }); + setVisible(true); + setDialogVersion((version) => version + 1); + }, []); + + const input = useCallback((opts: DialogOptions) => { + setOptions({ ...opts, input: true }); + setVisible(true); + setDialogVersion((version) => version + 1); + }, []); + + const toast = useCallback((message: string, action?: ToastAction) => { + setToastState({ message, action }); + }, []); + + const clear = useCallback(() => { + setVisible(false); + }, []); + + const dismissToast = useCallback(() => { + setToastState(null); + }, []); + + const contextValue = useMemo(() => ({ alert, input, toast }), [alert, input, toast]); + + return ( + + {children} + + {/* key remounts the body per dialog so the input resets to its defaultValue. */} + {options ? ( + + ) : null} + + + ); +} + +function DialogBody({ + options, + visible, + onDismiss, +}: { + options: DialogOptions; + visible: boolean; + onDismiss: () => void; +}) { + const theme = useAppTheme(); + const [inputValue, setInputValue] = useState(options.defaultValue || ''); + + const isButtonDisabled = useCallback( + (button: DialogButton) => { + if (typeof button.disabled === 'function') { + return button.disabled(inputValue); + } + return button.disabled ?? false; + }, + [inputValue], + ); + + // Every action — on-screen press, keyboard return — routes through here, so the + // disabled gate lives here rather than at each entry point. + const handleClose = useCallback( + (btn?: DialogButton) => { + if (btn && isButtonDisabled(btn)) { + return; + } + if (btn?.onPress) { + btn.onPress(options.input ? inputValue : undefined); + } + setInputValue(''); + onDismiss(); + }, + [inputValue, isButtonDisabled, onDismiss, options.input], + ); + + const buttons = useMemo(() => options.buttons ?? [{ text: 'OK' }], [options.buttons]); + + // Enter submits the last non-destructive, non-cancel action; a dialog whose only + // action is destructive gets no keyboard default at all. + const submitButton = useMemo(() => pickSubmitButton(buttons), [buttons]); + const handleSubmitEditing = useCallback(() => { + if (submitButton) handleClose(submitButton); + }, [handleClose, submitButton]); + + return ( + + {options.title ? ( + + {options.title} + + ) : null} + {options.message ? {options.message} : null} + + {options.input ? ( + + ) : null} + + {options.input && options.helperText ? ( + + {options.helperText} + + ) : null} + + + {buttons.map((btn) => ( + + ))} + + + ); +} + +function DialogActionButton({ + button, + isSubmit, + onSelect, + disabled, +}: { + button: DialogButton; + isSubmit: boolean; + onSelect: (btn: DialogButton) => void; + disabled: boolean; +}) { + const handlePress = useCallback(() => { + onSelect(button); + }, [onSelect, button]); + + // Destructive keeps its filled destructive variant; the button Enter would + // submit gets the same emphasis (AppButton's primary) so its visual weight + // matches the keyboard default. Every other action stays ghost. + const variant = button.style === 'destructive' ? 'destructive' : isSubmit ? 'primary' : 'ghost'; + + return ( + + {button.text} + + ); +} + +/** + * Transient feedback message. Rendered as a plain overlay View — not a Modal — + * so it announces via aria-live without trapping focus or being dismissable by + * Escape (a toast is not a dialog). + */ +function Toast({ + state, + onDismiss, +}: { + state: { message: string; action?: ToastAction } | null; + onDismiss: () => void; +}) { + const inverse = useInverseSurface(); + const message = state?.message ?? null; + const action = state?.action; + + // Dismiss first: the action may raise a toast of its own, and these two state + // updates batch in call order, so dismissing afterwards would swallow it. + const handleAction = useCallback(() => { + onDismiss(); + action?.onPress(); + }, [action, onDismiss]); + + // Depends on the state OBJECT, not the message string: each toast() call + // mints a new object, so a repeated identical message still restarts the + // timer (and re-announces on iOS; Android's live region ignores equal text). + useEffect(() => { + if (!state) return; + // accessibilityLiveRegion is Android-only; VoiceOver needs an explicit announcement. + if (Platform.OS === 'ios') AccessibilityInfo.announceForAccessibility(state.message); + const timer = setTimeout( + onDismiss, + state.action ? ACTION_TOAST_DURATION_MS : TOAST_DURATION_MS, + ); + return () => clearTimeout(timer); + }, [state, onDismiss]); + + if (!message) return null; + + return ( + + + + + {message} + + {action ? ( + // Ghost keeps the inverse ground showing through; the label takes its + // ink from useInverseSurface (the Inverse-Pair Rule) rather than the + // button variant's own foreground, which assumes a same-polarity surface. + + + {action.label} + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + toastContainer: { + // zIndex 100 has no exact Tailwind step (scale tops out at 50). + zIndex: 100, + }, + toast: { + maxWidth: '90%', + }, +}); diff --git a/app/src/components/base/DisclosureRow.tsx b/app/src/components/base/DisclosureRow.tsx new file mode 100644 index 000000000..ed2d178ff --- /dev/null +++ b/app/src/components/base/DisclosureRow.tsx @@ -0,0 +1,40 @@ +import { VARIANT_FOREGROUND_COLOR } from '@/components/base/appButtonVariants'; +import { useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; +import { AppButton } from './AppButton'; +import { Icon } from './Icon'; +import { Text } from './ui/text'; + +type DisclosureRowProps = { + /** Carries the count, e.g. "Show 3 more components" — never a bare "Show". */ + label: string; + expanded: boolean; + onPress: () => void; + className?: string; +}; + +/** + * The detail screens' one disclosure idiom: a left-aligned ghost row whose + * label says what opens and how much of it, with a chevron that points right + * when collapsed and down when expanded. Section's empty-state "Add …" row uses + * it too — that row also opens hidden content, so it is the same affordance. + */ +export function DisclosureRow({ label, expanded, onPress, className }: DisclosureRowProps) { + const { colors } = useAppTheme(); + return ( + + {label} + + + ); +} diff --git a/app/src/components/base/DocsLink.tsx b/app/src/components/base/DocsLink.tsx new file mode 100644 index 000000000..e272c4eec --- /dev/null +++ b/app/src/components/base/DocsLink.tsx @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; +import { Pressable } from 'react-native'; +import { AppText } from '@/components/base/AppText'; +import { DOCS_URL } from '@/config'; +import { openExternalUrl } from '@/services/externalLinks'; + +/** + * A caption-sized link into the published documentation, rendered only when a + * docs URL is configured. Contextual help lives next to the thing it explains, + * not in a settings screen: 26 guide pages and, until recently, two links to + * any of them. + */ +export function DocsLink({ + path, + children, + accessibilityLabel, + className, +}: { + /** Path within the docs site, e.g. `/user-guides/data-collection`. */ + path: string; + children: string; + accessibilityLabel: string; + className?: string; +}) { + const open = useCallback(() => { + if (DOCS_URL) { + void openExternalUrl(new URL(path, DOCS_URL).toString()); + } + }, [path]); + + if (!DOCS_URL) { + return null; + } + return ( + + + {children} + + + ); +} diff --git a/app/src/components/base/ErrorState.tsx b/app/src/components/base/ErrorState.tsx new file mode 100644 index 000000000..48214a4fb --- /dev/null +++ b/app/src/components/base/ErrorState.tsx @@ -0,0 +1,40 @@ +import { View } from 'react-native'; +import { useAppTheme } from '@/theme'; +import { AppButton } from './AppButton'; +import { AppText } from './AppText'; +import { Icon, type IconName } from './Icon'; + +type Props = { + message: string; + onRetry: () => void; + icon?: IconName; + title?: string; + actionLabel?: string; + iconColor?: string; +}; + +/** Full-height centered error state: icon, optional title, message, one action. */ +export function ErrorState({ + message, + onRetry, + icon = 'circle-alert', + title, + actionLabel = 'Retry', + iconColor, +}: Props) { + const theme = useAppTheme(); + return ( + + + {title ? ( + + {title} + + ) : null} + {message} + + {actionLabel} + + + ); +} diff --git a/app/src/components/base/Fab.tsx b/app/src/components/base/Fab.tsx new file mode 100644 index 000000000..f09daa75a --- /dev/null +++ b/app/src/components/base/Fab.tsx @@ -0,0 +1,140 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { useCallback, useEffect } from 'react'; +import { Pressable, type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { AppText } from '@/components/base/AppText'; +import { MIN_TAP_TARGET, radius } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { Icon, type IconName } from './Icon'; + +// `...rest` (accessibilityHint, onLongPress, aria-*, ...) passes through; +// onPress/disabled/accessibilityState stay controlled here so the +// disabled/blocked behavior can't be clobbered by a caller override. +type FabProps = Omit< + ComponentProps, + 'onPress' | 'style' | 'children' | 'accessibilityLabel' | 'disabled' | 'accessibilityState' +> & { + /** An Icon glyph name, or a render function for a custom icon (e.g. a saving spinner). */ + icon: IconName | (() => ReactNode); + label: string; + extended: boolean; + onPress: () => void; + visible?: boolean; + disabled?: boolean; + accessibilityLabel: string; + style?: StyleProp; + testID?: string; +}; + +const ANIMATION_DURATION = 200; +// NOTE: fixed cap instead of the label's measured natural width — the fab's +// labels are short (save/edit/error-count copy), so a hardcoded max avoids an +// onLayout-measurement dance for a visual nicety (a few px of trailing gap on +// shorter labels) nobody will notice. +const LABEL_MAX_WIDTH = 240; + +/** + * Floating action button with extend/collapse label. Extending animates the + * label's width and opacity via Reanimated (honoring OS reduce-motion); + * collapsing un-mounts it immediately — the icon stays put either way. + */ +export function Fab({ + icon, + label, + extended, + onPress, + visible = true, + disabled = false, + accessibilityLabel, + style, + testID, + ...rest +}: FabProps) { + const theme = useAppTheme(); + const progress = useSharedValue(extended ? 1 : 0); + + useEffect(() => { + progress.value = extended + ? withTiming(1, { + duration: ANIMATION_DURATION, + easing: Easing.out(Easing.quad), + reduceMotion: ReduceMotion.System, + }) + : 0; + }, [extended, progress]); + + const labelStyle = useAnimatedStyle(() => ({ + width: progress.value * LABEL_MAX_WIDTH, + opacity: progress.value, + })); + + const pressableStyle = useCallback( + ({ pressed }: { pressed: boolean }) => [ + // First so callers can still override deliberately. No className here: + // it would drop this whole function (see IconButton.tsx). + styles.base, + theme.tokens.elevation.overlay, + { backgroundColor: theme.colors.primary }, + disabled && styles.disabled, + pressed && !disabled && styles.pressed, + style, + ], + [theme.tokens.elevation.overlay, theme.colors.primary, disabled, style], + ); + + if (!visible) return null; + + return ( + + {typeof icon === 'function' ? ( + icon() + ) : ( + + )} + {extended ? ( + // Animated.View isn't a NativeWind className target (see ZoomableImage.tsx), so + // overflow stays inline. + + + {label} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + base: { + minWidth: MIN_TAP_TARGET, + minHeight: MIN_TAP_TARGET, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + borderRadius: radius.overlay, + }, + disabled: { + opacity: 0.5, + }, + pressed: { + opacity: 0.85, + }, + labelClip: { + overflow: 'hidden', + }, +}); diff --git a/app/src/components/base/FilterSelectionModal.tsx b/app/src/components/base/FilterSelectionModal.tsx new file mode 100644 index 000000000..83f5ef211 --- /dev/null +++ b/app/src/components/base/FilterSelectionModal.tsx @@ -0,0 +1,319 @@ +import { type ReactNode, useCallback } from 'react'; +import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useAppTheme } from '@/theme'; +import { AppButton } from './AppButton'; +import { AppText } from './AppText'; +import { Chip } from './Chip'; +import { OverlaySurface } from './OverlaySurface'; +import { TextInput } from './TextInput'; + +// Swallow presses so tapping inside the dialog doesn't dismiss it. Module-level so it's +// a stable reference across renders. +function stopPropagation(e: { stopPropagation: () => void }) { + e.stopPropagation(); +} + +function SelectableChip({ + item, + label, + selected, + onToggle, +}: { + item: string; + label?: string; + selected: boolean; + onToggle: (value: string) => void; +}) { + const { colors } = useAppTheme(); + const handlePress = useCallback(() => onToggle(item), [onToggle, item]); + return ( + + {label ?? item} + + ); +} + +type ShellProps = { + visible: boolean; + onDismiss: () => void; + title: string; + /** Items to display; controlled by the parent (parent owns the search query + fetch). */ + items: string[]; + /** + * Optional value -> display text. Selection, search and the value handed back + * on toggle all stay the raw item, because that is what the caller filters + * with; this only changes what the chip reads. Product types need it: their + * stored name can be a CPV code whose label lives elsewhere. + */ + labels?: Record; + isLoading?: boolean; + selectedValues: string[]; + onToggle: (value: string) => void; + /** Controlled search query; parent owns it so it can debounce/fetch. */ + searchQuery: string; + onSearchChange: (q: string) => void; + searchPlaceholder?: string; + /** Extra leading chip that lets the user add a value not present in `items`. */ + addNewChip?: { label: string; onPress: () => void }; + footer: ReactNode; +}; + +/** + * Presentational core shared by the multi-select and single-select filter + * modals: search field, chip list, loading/empty states. Selection semantics + * (toggle behavior, add-new, footer actions) are owned by each variant. + */ +function FilterModalShell({ + visible, + onDismiss, + title, + items, + labels, + isLoading, + selectedValues, + onToggle, + searchQuery, + onSearchChange, + searchPlaceholder = 'Search…', + addNewChip, + footer, +}: ShellProps) { + const theme = useAppTheme(); + + // Always show selected values at the top, even if not in the current search results. + const selectedNotInResults = selectedValues.filter((v) => !items.includes(v)); + const visibleItems = [...selectedNotInResults, ...items]; + + return ( + + + + + + {title} + + + {isLoading ? ( + + + + ) : visibleItems.length === 0 && !addNewChip ? ( + No results + ) : ( + + + {addNewChip ? ( + // NOTE: Chip's icon prop renders inside the same AppText node + // as the label, which breaks exact getByText(searchQuery) matches + // in callers/tests. + + {addNewChip.label} + + ) : null} + {visibleItems.map((item) => ( + + ))} + + + )} + {footer} + + + + + ); +} + +type Props = { + visible: boolean; + onDismiss: () => void; + title: string; + items: string[]; + labels?: Record; + isLoading?: boolean; + selectedValues: string[]; + onSelectionChange: (values: string[]) => void; + searchQuery: string; + onSearchChange: (q: string) => void; + searchPlaceholder?: string; +}; + +/** Multi-select filter modal: tapping a chip toggles it in `selectedValues`. */ +export default function FilterSelectionModal({ + visible, + onDismiss, + title, + items, + labels, + isLoading, + selectedValues, + onSelectionChange, + searchQuery, + onSearchChange, + searchPlaceholder, +}: Props) { + const toggle = useCallback( + (value: string) => { + if (selectedValues.includes(value)) { + onSelectionChange(selectedValues.filter((v) => v !== value)); + } else { + onSelectionChange([...selectedValues, value]); + } + }, + [onSelectionChange, selectedValues], + ); + const handleClearAll = useCallback(() => onSelectionChange([]), [onSelectionChange]); + + return ( + + {selectedValues.length > 0 ? ( + + Clear all + + ) : null} + + Done + + + } + /> + ); +} + +type SingleSelectProps = { + visible: boolean; + onDismiss: () => void; + title: string; + items: string[]; + labels?: Record; + isLoading?: boolean; + /** Currently selected value, or '' when none is set. */ + value: string; + onValueChange: (value: string) => void; + searchQuery: string; + onSearchChange: (q: string) => void; + searchPlaceholder?: string; +}; + +/** + * Single-select filter modal: tapping a chip immediately confirms the value + * and closes. Also lets the user add a value typed in the search box that + * isn't among `items`. + */ +export function SingleSelectFilterModal({ + visible, + onDismiss, + title, + items, + labels, + isLoading, + value, + onValueChange, + searchQuery, + onSearchChange, + searchPlaceholder, +}: SingleSelectProps) { + const selectedValues = value ? [value] : []; + const selectValue = useCallback( + (next: string) => { + onValueChange(next); + onDismiss(); + }, + [onValueChange, onDismiss], + ); + const handleClear = useCallback(() => { + onValueChange(''); + onDismiss(); + }, [onValueChange, onDismiss]); + + const trimmedQuery = searchQuery.trim(); + const canAddNew = + trimmedQuery.length > 0 && + !items.some((v) => v.toLowerCase() === trimmedQuery.toLowerCase()) && + trimmedQuery.toLowerCase() !== value.toLowerCase(); + const addNew = useCallback(() => selectValue(trimmedQuery), [selectValue, trimmedQuery]); + + return ( + + {value ? ( + + Clear + + ) : null} + + Cancel + + + } + /> + ); +} + +const styles = StyleSheet.create({ + dialogWrapper: { + maxWidth: 480, + maxHeight: '85%', + }, + scroll: { + // No exact Tailwind step for 320. + maxHeight: 320, + }, +}); diff --git a/app/src/components/base/FormField.tsx b/app/src/components/base/FormField.tsx new file mode 100644 index 000000000..85c992de3 --- /dev/null +++ b/app/src/components/base/FormField.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { AccessibilityInfo, Platform, StyleSheet, type TextStyle, View } from 'react-native'; +import Animated, { FadeIn, FadeOut, ReduceMotion } from 'react-native-reanimated'; +import { useAppTheme } from '@/theme'; + +// One caption line at fontSize 12 — matches NewAccountSections' HELPER_SLOT_HEIGHT. +// minHeight, not height: large OS text-scale settings may still grow the line. +const HELPER_SLOT_MIN_HEIGHT = 18; + +/** + * Renders a field-level error message with a stable `nativeID`, so the input + * can be linked to it via `describedBy` (`@/utils/a11y`) instead of rendering + * as an unlinked sibling (WCAG 1.3.1 / 3.3.1 / 4.1.2 — programmatic + * error association, not just a color change on the input). + */ +export function FormFieldError({ + errorId, + message, + style, + reserveSpace = false, +}: { + errorId: string; + message: string | undefined; + style?: TextStyle; + /** + * Always render a one-line helper slot so the error fills reserved space + * instead of shoving the rest of the form down. The fade below bridges the + * text's appearance; only the reserved slot prevents the layout shift. + * Opt-in: mixed view/edit layouts (product headers, video fields) keep the + * conditional render rather than carrying a permanent empty line. + */ + reserveSpace?: boolean; +}) { + const theme = useAppTheme(); + + // accessibilityLiveRegion below covers Android (and the web export's + // aria-live); VoiceOver ignores it, so iOS gets the explicit announcement — + // same split as DialogProvider's Toast. Keyed on the message, so a + // correction that swaps one error for another is announced too. + useEffect(() => { + if (message && Platform.OS === 'ios') AccessibilityInfo.announceForAccessibility(message); + }, [message]); + + const errorText = message ? ( + + {message} + + ) : null; + + if (!reserveSpace) return errorText; + return {errorText}; +} + +const styles = StyleSheet.create({ + slot: { + minHeight: HELPER_SLOT_MIN_HEIGHT, + justifyContent: 'center', + }, +}); diff --git a/app/src/components/base/HeaderBackButton.tsx b/app/src/components/base/HeaderBackButton.tsx new file mode 100644 index 000000000..6f7512ad5 --- /dev/null +++ b/app/src/components/base/HeaderBackButton.tsx @@ -0,0 +1,35 @@ +import type { NativeStackHeaderBackProps } from 'expo-router'; +import { Pressable } from 'react-native'; +import { MIN_TAP_TARGET } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { Icon } from './Icon'; + +// SDK 57 dropped the importable `@react-navigation/elements` HeaderBackButton, and +// expo-router ships no replacement component — a custom `headerLeft` fully replaces the +// native back button. These screens override the back target with `router.replace`, so +// they always want a visible back affordance regardless of `canGoBack`. +type HeaderBackButtonProps = NativeStackHeaderBackProps & { onPress: () => void }; + +export function HeaderBackButton({ onPress, tintColor }: HeaderBackButtonProps) { + const theme = useAppTheme(); + const color = typeof tintColor === 'string' ? tintColor : theme.colors.onBackground; + return ( + + + + ); +} diff --git a/app/src/components/base/HeaderRightPill.tsx b/app/src/components/base/HeaderRightPill.tsx new file mode 100644 index 000000000..58e493f83 --- /dev/null +++ b/app/src/components/base/HeaderRightPill.tsx @@ -0,0 +1,83 @@ +import { useRouter } from 'expo-router'; +import { useCallback } from 'react'; +import { Pressable } from 'react-native'; +import { MIN_TAP_TARGET } from '@/constants'; +import { useAuth } from '@/context/auth'; +import { useAppTheme } from '@/theme'; +import { needsUsernameOnboarding } from '@/utils/router/onboarding'; +import { AppText } from './AppText'; +import { Icon } from './Icon'; + +function truncateUsername(username: string) { + return username.length > 16 ? `${username.slice(0, 14)}…` : username; +} + +// Pill classes: layout/spacing/radius moved to className (exact Tailwind +// steps with inlineRem: 16 — rounded-[6px] matches radius.control). +// backgroundColor/color stay inline: theme-dependent values with no CSS var. +const PILL_CLASS_NAME = 'mr-4 flex-row items-center gap-1.5 rounded-[6px] px-3 py-1.5'; +const PILL_TEXT_CLASS_NAME = 'text-[14px] font-semibold'; + +export function HeaderRightPill() { + const { user } = useAuth(); + const router = useRouter(); + const theme = useAppTheme(); + const needsOnboarding = user ? needsUsernameOnboarding(user) : false; + // Interactive header control — primary family, never the neutral glass + // (DESIGN.md: primary blue carries all interaction). tokens.surface.accent + // is the canonical tinted fill (primary at 12% opacity); *Container roles + // are retired. + // minHeight, not just hitSlop: hitSlop expands the target on native but is + // invisible to the DOM on web, where this measured 83x30. + const pillStyle = { backgroundColor: theme.tokens.surface.accent, minHeight: MIN_TAP_TARGET }; + const primaryTextStyle = { color: theme.colors.primary }; + + const goToAccount = useCallback(() => { + // navigate(): /account is a tab, and this pill also renders on the public + // profile screen outside the tabs, where a push would stack a second (tabs). + router.navigate(needsOnboarding ? '/onboarding' : '/account'); + }, [router, needsOnboarding]); + const goToLogin = useCallback(() => router.push('/login'), [router]); + + if (user) { + const username = needsOnboarding ? 'Complete profile' : truncateUsername(user.username ?? ''); + return ( + + {/* tokens.surface.accent is a light primary tint, not a solid + *Container fill — onPrimaryContainer here was a contrast bug. */} + + + {username} + + + ); + } + + return ( + + + Sign in + + + ); +} diff --git a/app/src/components/base/Icon.tsx b/app/src/components/base/Icon.tsx new file mode 100644 index 000000000..acfdc094f --- /dev/null +++ b/app/src/components/base/Icon.tsx @@ -0,0 +1,168 @@ +import { + ArrowDownUp, + ArrowLeft, + Calendar, + Camera, + CameraOff, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + CircleAlert, + CircleCheckBig, + CircleUserRound, + Clock, + Copy, + EthernetPort, + ExternalLink, + Eye, + EyeOff, + Globe, + Image, + ImagePlus, + Images, + Info, + Link, + Lock, + type LucideIcon, + MailCheck, + Minus, + Moon, + Package, + PackageX, + Pencil, + Plus, + Radio, + RadioTower, + RefreshCw, + Save, + Search, + Settings, + Shapes, + SlidersHorizontal, + Sun, + SunMoon, + Tag, + Trash2, + User, + Users, + UserX, + VideoOff, + Webcam, + Weight, + X, +} from 'lucide-react-native'; +import { Path, Svg } from 'react-native-svg'; + +type BrandGlyphProps = { size?: number; color?: string; strokeWidth?: number }; + +/** Turns a single-path brand SVG's `d` data into a Lucide-shaped component. */ +function createBrandGlyph(d: string) { + // strokeWidth is accepted-and-ignored: brand marks are filled, not + // stroked, but the wrapper's call signature stays uniform across glyphs. + return function BrandGlyph({ size = 24, color = 'currentColor' }: BrandGlyphProps) { + return ( + + + + ); + }; +} + +// Source: assets/icons/brand/github.svg +const GITHUB_PATH = + 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'; + +// Source: assets/icons/brand/google.svg +const GOOGLE_PATH = + 'M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z'; + +// Source: assets/icons/brand/youtube.svg +const YOUTUBE_PATH = + 'M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2 31.2 31.2 0 0 0 0 12a31.2 31.2 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31.2 31.2 0 0 0 24 12a31.2 31.2 0 0 0-.5-5.8ZM9.6 15.6V8.4L15.9 12l-6.3 3.6Z'; + +const github = createBrandGlyph(GITHUB_PATH); +const google = createBrandGlyph(GOOGLE_PATH); +const youtube = createBrandGlyph(YOUTUBE_PATH); + +// Curated Lucide glyph set. Brand marks (google, github, youtube) render +// vendored paths from assets/icons/brand/ via createBrandGlyph() — filled, +// not stroked, unlike the Lucide glyphs (see assets/DESIGN.md). +const iconMap = { + 'arrow-down-up': ArrowDownUp, + 'arrow-left': ArrowLeft, + calendar: Calendar, + camera: Camera, + 'camera-off': CameraOff, + check: Check, + 'chevron-down': ChevronDown, + 'chevron-left': ChevronLeft, + 'chevron-right': ChevronRight, + 'circle-alert': CircleAlert, + 'circle-check-big': CircleCheckBig, + 'circle-user-round': CircleUserRound, + clock: Clock, + copy: Copy, + 'ethernet-port': EthernetPort, + 'external-link': ExternalLink, + eye: Eye, + 'eye-off': EyeOff, + github, + globe: Globe, + google, + image: Image, + 'image-plus': ImagePlus, + images: Images, + info: Info, + link: Link, + lock: Lock, + 'mail-check': MailCheck, + minus: Minus, + moon: Moon, + package: Package, + 'package-x': PackageX, + pencil: Pencil, + plus: Plus, + radio: Radio, + 'radio-tower': RadioTower, + 'refresh-cw': RefreshCw, + save: Save, + search: Search, + settings: Settings, + shapes: Shapes, + 'sliders-horizontal': SlidersHorizontal, + sun: Sun, + 'sun-moon': SunMoon, + tag: Tag, + 'trash-2': Trash2, + user: User, + users: Users, + 'user-x': UserX, + 'video-off': VideoOff, + webcam: Webcam, + weight: Weight, + x: X, + youtube, +} as const satisfies Record>; + +export type IconName = keyof typeof iconMap; + +const sizeMap = { sm: 16, md: 20, lg: 24 } as const; + +type IconProps = { + name: IconName; + size?: keyof typeof sizeMap | number; + color?: string; + strokeWidth?: number; +}; + +/** Thin wrapper resolving a curated MCI-style name + size token to a Lucide glyph. */ +export function Icon({ name, size = 'md', color, strokeWidth = 2 }: IconProps) { + const Glyph = iconMap[name]; + // NOTE: name is a compile-time-enforced union, but guard the runtime + // lookup anyway — non-TS callers (e.g. content driven by a string) can + // still hand this an unmapped value. + if (!Glyph) return null; + const resolvedSize = typeof size === 'number' ? size : sizeMap[size]; + return ; +} diff --git a/app/src/components/base/IconButton.tsx b/app/src/components/base/IconButton.tsx new file mode 100644 index 000000000..3b95ac040 --- /dev/null +++ b/app/src/components/base/IconButton.tsx @@ -0,0 +1,94 @@ +import type { ComponentProps } from 'react'; +import { useCallback } from 'react'; +import { + ActivityIndicator, + Pressable, + type StyleProp, + StyleSheet, + type ViewStyle, +} from 'react-native'; +import { MIN_TAP_TARGET, radius } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { Icon, type IconName } from './Icon'; + +// Everything not already named below (accessibilityHint, onLongPress, aria-*, +// ...) passes through via `...rest`; `onPress`/`disabled`/`accessibilityState` +// stay controlled by this component so the loading behavior can't be +// clobbered by a caller-supplied override. +type IconButtonProps = Omit< + ComponentProps, + 'onPress' | 'style' | 'children' | 'accessibilityLabel' | 'disabled' | 'accessibilityState' +> & { + icon: IconName; + onPress: () => void; + accessibilityLabel: string; + size?: number; + loading?: boolean; + mode?: 'default' | 'contained-tonal'; + style?: StyleProp; + testID?: string; +}; + +/** Icon-only pressable with >=44px touch target regardless of glyph visual size. */ +export function IconButton({ + icon, + onPress, + accessibilityLabel, + size = 24, + loading = false, + mode = 'default', + style, + testID, + ...rest +}: IconButtonProps) { + const theme = useAppTheme(); + const pressableStyle = useCallback( + ({ pressed }: { pressed: boolean }) => [ + // First so callers can still override deliberately. + styles.base, + mode === 'contained-tonal' && { backgroundColor: theme.tokens.surface.accent }, + pressed && !loading && styles.pressed, + style, + ], + [mode, theme.tokens.surface.accent, loading, style], + ); + + return ( + + {loading ? ( + + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + base: { + minWidth: MIN_TAP_TARGET, + minHeight: MIN_TAP_TARGET, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.control, + }, + pressed: { + opacity: 0.6, + }, +}); diff --git a/app/src/components/base/ImagePlaceholder.tsx b/app/src/components/base/ImagePlaceholder.tsx new file mode 100644 index 000000000..af4d8066f --- /dev/null +++ b/app/src/components/base/ImagePlaceholder.tsx @@ -0,0 +1,60 @@ +import { View } from 'react-native'; +import { useAppTheme } from '@/theme'; +import { AppText } from './AppText'; +import { Icon } from './Icon'; + +interface Props { + width: number; + height: number; + label?: string; + borderRadius?: number; + testID?: string; +} + +export default function ImagePlaceholder({ + width, + height, + label, + borderRadius = 8, + testID, +}: Props) { + const theme = useAppTheme(); + const iconSize = Math.min(width, height) * 0.3; + + return ( + + {/* Decorative — hidden from the accessibility tree, matching StaticBackground's aria-hidden treatment. */} + + + + {label ? ( + + {label} + + ) : null} + + ); +} diff --git a/app/src/components/base/InfoTooltip.tsx b/app/src/components/base/InfoTooltip.tsx new file mode 100644 index 000000000..0bd39167c --- /dev/null +++ b/app/src/components/base/InfoTooltip.tsx @@ -0,0 +1,134 @@ +// NOTE: hand-rolled on purpose — carries 1.5s auto-dismiss and mobile-web full-screen modal variant. +import { type JSX, useCallback, useEffect, useState } from 'react'; +import { Modal, Platform, Pressable, StyleSheet, View } from 'react-native'; +import { MIN_TAP_TARGET } from '@/constants'; +import { useAppTheme, useInverseSurface } from '@/theme'; +import { AppText } from './AppText'; +import { Icon } from './Icon'; +import { OverlaySurface } from './OverlaySurface'; + +const MOBILE_USER_AGENT_PATTERN = /iPhone|iPad|iPod|Android/i; + +const getIsMobileWeb = () => + Platform.OS === 'web' && + typeof navigator !== 'undefined' && + MOBILE_USER_AGENT_PATTERN.test(navigator.userAgent); + +export const InfoTooltip = ({ title }: { title: string }): JSX.Element => { + const theme = useAppTheme(); + const inverse = useInverseSurface(); + const [visible, setVisible] = useState(false); + const show = useCallback(() => setVisible(true), []); + const hide = useCallback(() => setVisible(false), []); + // Both variants float over content, so they take the single overlay tier. + const tooltipShadowStyle = theme.tokens.elevation.overlay; + + // Settings + const exitDelay = 1500; // milliseconds + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setVisible(false), exitDelay); + return () => clearTimeout(timer); + } + }, [visible]); + + if (getIsMobileWeb()) { + return ( + + + {/* Icon doesn't forward testID (Lucide maps it to a data-testid attribute + RNTL can't query), so the test target wraps the glyph instead. */} + + + + + + + + + + {title} + + + + + + ); + } + + // Native app + desktop web: a small bubble anchored under the icon, shown on + // press (native) or hover (web) — no portal needed since it's positioned + // relative to its own wrapper rather than covering the full screen. + return ( + + + + + + + {visible ? ( + + + {title} + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + tapFloor: { + minWidth: MIN_TAP_TARGET, + minHeight: MIN_TAP_TARGET, + alignItems: 'center', + justifyContent: 'center', + }, + tooltip: { + maxWidth: '80%', + minWidth: 200, + }, + floating: { + top: '100%', + maxWidth: 240, + }, +}); diff --git a/app/src/components/base/Menu.tsx b/app/src/components/base/Menu.tsx new file mode 100644 index 000000000..631a04ea9 --- /dev/null +++ b/app/src/components/base/Menu.tsx @@ -0,0 +1,138 @@ +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Modal, Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; +import Animated, { Easing, FadeInDown, ReduceMotion } from 'react-native-reanimated'; +import { AppText } from '@/components/base/AppText'; +import { Icon, type IconName } from '@/components/base/Icon'; +import { MIN_TAP_TARGET } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { getMenuPosition, MENU_MIN_WIDTH, type MenuPosition } from './menuPosition'; + +// Swallow presses so tapping an item doesn't fall through to the backdrop. Module-level +// so it's a stable reference across renders. +function stopPropagation(e: { stopPropagation: () => void }) { + e.stopPropagation(); +} + +type MenuProps = { + visible: boolean; + onDismiss: () => void; + anchor: ReactNode; + children: ReactNode; +}; + +/** + * Anchored dropdown menu rendered in RN-core Modal (no PortalHost) with + * full-screen dismiss backdrop; position measured from anchor once menu opens. + * + * NOTE: hand-rolled on purpose — uses RN-core Modal + measureInWindow anchoring for portal-free positioning. + * + * NOTE: position is captured on open only, not tracked live — a menu left + * open while its anchor scrolls out from under it (e.g. a chip in a + * horizontally-scrolling filter bar) won't follow it. Matches how most + * non-portal dropdown implementations behave; revisit if it's ever left open + * during a scroll in practice. + */ +export function Menu({ visible, onDismiss, anchor, children }: MenuProps) { + const theme = useAppTheme(); + const anchorRef = useRef(null); + const { width: windowWidth } = useWindowDimensions(); + const [position, setPosition] = useState({ top: 0, left: 0 }); + + useEffect(() => { + if (!visible) return; + anchorRef.current?.measureInWindow((x, y, width, height) => { + setPosition( + getMenuPosition({ + anchorX: x, + anchorY: y, + anchorWidth: width, + anchorHeight: height, + windowWidth, + }), + ); + }); + }, [visible, windowWidth]); + + return ( + <> + + {anchor} + + + + + + {children} + + + + + + ); +} + +function MenuItem({ + title, + trailingIcon, + onPress, +}: { + title: string; + trailingIcon?: IconName; + onPress: () => void; +}) { + const theme = useAppTheme(); + const pressableStyle = useCallback( + ({ pressed }: { pressed: boolean }) => [ + // No className on this Pressable: it would drop this function (see IconButton.tsx). + styles.item, + pressed && { backgroundColor: theme.colors.surfaceVariant }, + ], + [theme.colors.surfaceVariant], + ); + return ( + + + {title} + + {trailingIcon ? : null} + + ); +} + +Menu.Item = MenuItem; + +const styles = StyleSheet.create({ + content: { + position: 'absolute', + minWidth: MENU_MIN_WIDTH, + maxWidth: '92%', + // Floating surface: overlay radius (rounded-xl class) + the shared + // overlay elevation tier (applied inline, since it is theme-dependent). + }, + item: { + minHeight: MIN_TAP_TARGET, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + paddingHorizontal: 16, + }, +}); diff --git a/app/src/components/base/MutedText.tsx b/app/src/components/base/MutedText.tsx new file mode 100644 index 000000000..b20d9ba30 --- /dev/null +++ b/app/src/components/base/MutedText.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; +import { cn } from '@/utils/cn'; +import { AppText } from './AppText'; + +type MutedTextProps = ComponentProps; + +/** + * Muted secondary copy. Defaults to the `body` step; callers can override + * `variant` (e.g. ProductCard's dense metadata lines use `caption`). + */ +export function MutedText({ className, ...props }: MutedTextProps) { + return ; +} diff --git a/app/src/components/base/OfflineBanner.tsx b/app/src/components/base/OfflineBanner.tsx new file mode 100644 index 000000000..027eb20f6 --- /dev/null +++ b/app/src/components/base/OfflineBanner.tsx @@ -0,0 +1,43 @@ +import { onlineManager } from '@tanstack/react-query'; +import { useEffect, useSyncExternalStore } from 'react'; +import { AccessibilityInfo, Platform, View } from 'react-native'; +import Animated, { FadeInDown, FadeOutUp, ReduceMotion } from 'react-native-reanimated'; +import { AppText } from './AppText'; + +const OFFLINE_MESSAGE = "Offline — your captures will send when you're back online"; + +/** Persistent strip stating the offline contract: nothing is lost, sends resume on reconnect. */ +export function OfflineBanner() { + const isOnline = useSyncExternalStore( + (cb) => onlineManager.subscribe(cb), + () => onlineManager.isOnline(), + () => true, + ); + + // accessibilityLiveRegion below is Android-only (plus aria-live on the web + // export); VoiceOver needs the transition announced explicitly, same split + // as DialogProvider's Toast. + useEffect(() => { + if (!isOnline && Platform.OS === 'ios') { + AccessibilityInfo.announceForAccessibility(OFFLINE_MESSAGE); + } + }, [isOnline]); + + if (isOnline) return null; + return ( + + + + {OFFLINE_MESSAGE} + + + + ); +} diff --git a/app/src/components/base/OtpInput.tsx b/app/src/components/base/OtpInput.tsx new file mode 100644 index 000000000..3d62b12d4 --- /dev/null +++ b/app/src/components/base/OtpInput.tsx @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { StyleSheet, TextInput, View } from 'react-native'; +import { type AppTheme, memoizeByTheme, useAppTheme } from '@/theme'; +import { AppText } from './AppText'; + +type OtpInputProps = { + value: string; + onChangeText: (value: string) => void; + /** Fired once the field fills, so callers can submit without a button press. */ + onComplete?: (value: string) => void; + length?: number; + disabled?: boolean; + autoFocus?: boolean; + hasError?: boolean; + /** Visible field label, rendered above the cells inside the same width cap. */ + label?: string; + accessibilityLabel?: string; +}; + +/** + * Classic segmented one-time-code field: `length` visible cells backed by a + * single transparent TextInput. One input keeps OS autofill, paste, and the + * numeric keyboard working everywhere — no per-cell focus juggling. + */ +export function OtpInput({ + value, + onChangeText, + onComplete, + length = 6, + disabled = false, + autoFocus = false, + hasError = false, + label, + // Keep the accessible name equal to the visible label (WCAG 2.5.3). + accessibilityLabel = label ?? 'One-time code', +}: OtpInputProps) { + const styles = createStyles(useAppTheme()); + const inputRef = useRef(null); + + // autoFocus alone is unreliable inside a Portal/Dialog and on web, so focus + // via the ref once mounted (a tick after the dialog's open animation). + useEffect(() => { + if (!autoFocus || disabled) return; + const timer = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(timer); + }, [autoFocus, disabled]); + + const handleChange = useCallback( + (raw: string) => { + const next = raw.replace(/\D/g, '').slice(0, length); + onChangeText(next); + if (next.length === length) onComplete?.(next); + }, + [length, onChangeText, onComplete], + ); + + return ( + + {label ? {label} : null} + + {Array.from({ length }, (_, index) => index).map((index) => { + const filled = index < value.length; + const focused = index === value.length && !disabled; + return ( + + {/* NOTE: OTP digit cells are enlarged past the `data` step's 14px + for at-a-glance legibility of a single character — no ramp + step targets an oversized single-digit cell. */} + + {value[index] ?? ''} + + + ); + })} + + + + ); +} + +const createStyles = memoizeByTheme((theme: AppTheme) => + StyleSheet.create({ + cell: { + height: 56, + // 1.5 has no exact Tailwind border-width step, and the border/fill + // colors are JS-only tokens — the whole cell border+fill stays inline. + borderWidth: 1.5, + borderColor: theme.tokens.border.subtle, + backgroundColor: theme.tokens.surface.sunken, + }, + cellFilled: { + borderColor: theme.tokens.border.strong, + }, + cellFocused: { + // theme.colors.primary as a border color has no table entry (only + // bg-primary/text-primary are mapped), so this stays JS-side too. + borderColor: theme.colors.primary, + backgroundColor: theme.tokens.surface.accent, + }, + cellError: { + borderColor: theme.tokens.status.danger, + }, + hiddenInput: { + // color: 'transparent' isn't a table-mapped class; kept alongside the + // position/inset it used to share so one declaration covers it. + color: 'transparent', + }, + }), +); diff --git a/app/src/components/base/OverlaySurface.tsx b/app/src/components/base/OverlaySurface.tsx new file mode 100644 index 000000000..82d3d1bd7 --- /dev/null +++ b/app/src/components/base/OverlaySurface.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; +import { View, type ViewStyle } from 'react-native'; +import { useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; + +type OverlaySurfaceProps = { + children?: ReactNode; + style?: ViewStyle | ViewStyle[]; + className?: string; + /** + * 'surface' is an opaque panel — what a dialog or modal needs, since content + * sits on it and has to be readable. The rest are translucent films meant to + * be drawn *over* content ('scrim' also being the colour of the backdrop + * behind a modal), so a panel painted with one shows the page through itself. + */ + tone?: 'surface' | 'scrim' | 'media' | 'glass'; +}; + +export function OverlaySurface({ + children, + style, + className, + tone = 'scrim', +}: OverlaySurfaceProps) { + const theme = useAppTheme(); + // 'surface' is CSS-var-backed (bg-background); the other tones are JS-only + // overlay tokens, so their color has to stay inline. + return ( + + {children} + + ); +} diff --git a/app/src/components/base/PageContainer.tsx b/app/src/components/base/PageContainer.tsx new file mode 100644 index 000000000..b56ee1832 --- /dev/null +++ b/app/src/components/base/PageContainer.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react'; +import { type LayoutChangeEvent, View } from 'react-native'; + +/** + * Adaptive page scaffold: centers content at a max width with gutters that + * widen at the md/lg breakpoints (global.css). `fullBleed` opts a hero or + * gallery out of the constraint while keeping one wrapper element. + * + * `phoneFullBleed` drops the base `px-4` gutter below md, keeping the md/lg + * gutters and desktop centering. Use it for screens whose content already owns + * its phone horizontal padding (list/grid screens) so the scaffold's gutter + * doesn't stack on top of it and shift the phone layout. + */ +export function PageContainer({ + children, + fullBleed = false, + phoneFullBleed = false, + onLayout, +}: { + children: ReactNode; + fullBleed?: boolean; + phoneFullBleed?: boolean; + onLayout?: (event: LayoutChangeEvent) => void; +}) { + if (fullBleed) { + return ( + + {children} + + ); + } + return ( + // flex-1: several screens hand this a flex:1 child (a FlatList that must + // fill the remaining viewport height) — without it, that child loses its + // flex-basis chain the moment this wrapper sits between it and the + // screen's flex:1 root. Inert when nested in a ScrollView instead (no + // definite parent height to grow into), so it's safe for both cases. + // Two literal className strings (not a cn() join) so NativeWind's compiler + // statically sees every utility. + + {children} + + ); +} diff --git a/app/src/components/base/Searchbar.tsx b/app/src/components/base/Searchbar.tsx new file mode 100644 index 000000000..99162f96e --- /dev/null +++ b/app/src/components/base/Searchbar.tsx @@ -0,0 +1,87 @@ +import type { ComponentProps } from 'react'; +import { useCallback } from 'react'; +import { + ActivityIndicator, + Pressable, + type StyleProp, + StyleSheet, + View, + type ViewStyle, +} from 'react-native'; +import { Input } from '@/components/base/ui/input'; +import { useAppTheme } from '@/theme'; +import { Icon } from './Icon'; + +type SearchbarProps = Omit< + ComponentProps, + 'value' | 'onChangeText' | 'placeholder' | 'style' +> & { + value: string; + onChangeText: (text: string) => void; + placeholder?: string; + loading?: boolean; + style?: StyleProp; +}; + +/** Search field with a leading magnifier and a trailing clear/loading affordance. */ +export function Searchbar({ + value, + onChangeText, + placeholder, + loading = false, + style, + ref, + // Forwarded to the Input, the element that takes focus, so callers can supply + // accessibilityHint and friends instead of having them silently dropped. + ...rest +}: SearchbarProps) { + const theme = useAppTheme(); + const handleClear = useCallback(() => onChangeText(''), [onChangeText]); + + return ( + + + + + + {loading ? ( + + ) : value ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + leadingIcon: { + // zIndex 1 has no exact Tailwind step (the scale jumps 0 -> 10), so it + // stays inline alongside the absolute/left classes. + zIndex: 1, + }, +}); diff --git a/app/src/components/base/Section.tsx b/app/src/components/base/Section.tsx new file mode 100644 index 000000000..7e64cf1c9 --- /dev/null +++ b/app/src/components/base/Section.tsx @@ -0,0 +1,109 @@ +import { type ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { type LayoutChangeEvent, View } from 'react-native'; +import { cn } from '@/utils/cn'; +import { AppText } from './AppText'; +import { DisclosureRow } from './DisclosureRow'; +import { InfoTooltip } from './InfoTooltip'; +import { SectionNavContext } from './SectionNavContext'; + +export type { SectionKey } from './SectionNavContext'; + +import type { SectionKey } from './SectionNavContext'; + +type SectionProps = { + title: string; + sectionKey: SectionKey; + isEmpty?: boolean; + editMode?: boolean; + addLabel?: string; + /** Muted text after the title, e.g. a component count like "(3)". */ + titleSuffix?: string; + /** Info-tooltip text shown beside the title. */ + tooltip?: string; + /** + * Extra classes merged onto the section's root View. Section must stay a + * direct child of its screen's shared section-list wrapper — see + * useAnchoredSectionNav's base-offset math — so per-section styling (e.g. + * the account screen's danger-zone divider) goes here instead of an extra + * wrapping View, which would zero out that section's registered anchor. + */ + className?: string; + children: ReactNode; +}; + +/** + * Titled detail-screen section. Empty sections vanish in view mode and shrink + * to a single "Add …" row in edit mode, so sparse records stay short without + * hiding anything on rich ones (spec Part 1, "Empty sections"). + */ +export function Section({ + title, + sectionKey, + isEmpty = false, + editMode = false, + addLabel, + titleSuffix, + tooltip, + className, + children, +}: SectionProps) { + const nav = useContext(SectionNavContext); + const [expandedWhileEmpty, setExpandedWhileEmpty] = useState(false); + const isVisible = !(isEmpty && !editMode); + + // A section that collapses away (empty + view mode) must also drop out of + // the scroll-spy/chip registry, or a chip tap or scroll-spy pass can still + // land on the stale position of a section that isn't actually rendered. + // nav goes through a ref so the cleanup runs only on actual hide/unmount: + // the context value changes identity on every scroll-spy tick (activeKey), + // and depending on it directly would unregister a section that stays + // visible — with no onLayout re-fire to ever re-register it. + const navRef = useRef(nav); + useEffect(() => { + navRef.current = nav; + }, [nav]); + useEffect(() => { + if (!isVisible) return; + return () => navRef.current?.unregisterSection?.(sectionKey); + }, [isVisible, sectionKey]); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => nav?.registerSection(sectionKey, event.nativeEvent.layout.y), + [nav, sectionKey], + ); + const handleExpand = useCallback(() => setExpandedWhileEmpty(true), []); + + if (!isVisible) return null; + + const showAddRow = isEmpty && editMode && !expandedWhileEmpty; + + return ( + + {showAddRow ? ( + + ) : ( + <> + + {/* Section is a card, and the ramp assigns card titles `heading`; + `title` is for the screen. */} + {title} + {titleSuffix ? ( + + {titleSuffix} + + ) : null} + {tooltip ? : null} + + {children} + + )} + + ); +} diff --git a/app/src/components/base/SectionNavContext.tsx b/app/src/components/base/SectionNavContext.tsx new file mode 100644 index 000000000..a65af07a0 --- /dev/null +++ b/app/src/components/base/SectionNavContext.tsx @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +/** + * A section's stable id within one screen's nav (e.g. 'overview', 'security'). + * Deliberately a plain string: the registry is screen-agnostic, so product and + * account screens keep their own key vocabularies without widening a shared union. + */ +export type SectionKey = string; + +export type SectionNavApi = { + registerSection: (key: SectionKey, y: number) => void; + /** Drops a section from the registry — collapsed/unmounted sections can't activate scroll-spy. */ + unregisterSection?: (key: SectionKey) => void; + scrollTo: (key: SectionKey) => void; + activeKey: SectionKey; +}; + +export const SectionNavContext = createContext(null); diff --git a/app/src/components/base/SectionNavLayout.tsx b/app/src/components/base/SectionNavLayout.tsx new file mode 100644 index 000000000..068146090 --- /dev/null +++ b/app/src/components/base/SectionNavLayout.tsx @@ -0,0 +1,120 @@ +import type { ReactNode } from 'react'; +import { useCallback } from 'react'; +import { Platform, Pressable, ScrollView, View } from 'react-native'; +import { WEB_FOCUS_RING } from '@/constants'; +import { cn } from '@/utils/cn'; +import { AppText } from './AppText'; +import type { SectionKey } from './SectionNavContext'; + +function SectionNavItem({ + section, + active, + onPress, +}: { + section: { key: SectionKey; label: string }; + active: boolean; + onPress: (key: SectionKey) => void; +}) { + const handlePress = useCallback(() => onPress(section.key), [onPress, section.key]); + return ( + + + {section.label} + + + ); +} + +/** Section jump-nav: horizontal chips on phone, vertical outline on wide web. */ +function SectionNav({ + sections, + activeKey, + onPress, + orientation, +}: { + sections: { key: SectionKey; label: string }[]; + activeKey: SectionKey; + onPress: (key: SectionKey) => void; + orientation: 'chips' | 'outline'; +}) { + const items = sections.map((section) => ( + + )); + + if (orientation === 'outline') { + return {items}; + } + return ( + + {items} + + ); +} + +/** + * Shared document-nav shell for anchored-scroll screens (product detail, + * account): phone gets a chips row pinned above the scroll, ≥lg web gets a + * fixed outline column beside it. Extracted from ProductDetailScreen so the + * account screen (phase 3) reuses the exact same layout instead of a second + * copy — keep any change here in sync across both screens' tests. + */ +export function SectionNavLayout({ + isLg, + navSections, + activeKey, + onPressSection, + children, +}: { + isLg: boolean; + navSections: { key: SectionKey; label: string }[]; + activeKey: SectionKey; + onPressSection: (key: SectionKey) => void; + children: ReactNode; +}) { + if (isLg) { + return ( + + + + + {children} + + ); + } + return ( + + + + + {children} + + ); +} diff --git a/app/src/components/base/SignedOutState.tsx b/app/src/components/base/SignedOutState.tsx new file mode 100644 index 000000000..2f25d7a3b --- /dev/null +++ b/app/src/components/base/SignedOutState.tsx @@ -0,0 +1,27 @@ +import { useRouter } from 'expo-router'; +import { useCallback } from 'react'; +import { View } from 'react-native'; +import { useAppTheme } from '@/theme'; +import { AppButton } from './AppButton'; +import { AppText } from './AppText'; +import { Icon } from './Icon'; + +/** Replaces `if (!user) return null` blank screens with an explanation and a way in. */ +export function SignedOutState({ + message = 'Sign in to use this part of Relab.', +}: { + message?: string; +}) { + const router = useRouter(); + const { colors } = useAppTheme(); + const goToLogin = useCallback(() => router.replace('/login'), [router]); + return ( + + + {message} + + Sign in + + + ); +} diff --git a/app/src/components/base/Skeleton.tsx b/app/src/components/base/Skeleton.tsx new file mode 100644 index 000000000..60dc2269b --- /dev/null +++ b/app/src/components/base/Skeleton.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import Animated, { + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; + +interface SkeletonProps { + style?: StyleProp; + duration?: number; + testID?: string; +} + +/** + * Animated skeleton placeholder with a pulsing opacity effect. Honors the OS + * reduce-motion setting via Reanimated's `ReduceMotion.System` — same gate + * Fab's extend/collapse animation uses — instead of pulsing indefinitely + * regardless of the user's accessibility preference. + */ +export function Skeleton({ style, duration = 750, testID }: SkeletonProps) { + const opacity = useSharedValue(0.4); + + useEffect(() => { + opacity.value = withRepeat( + withSequence(withTiming(1, { duration }), withTiming(0.4, { duration })), + -1, + false, + undefined, + ReduceMotion.System, + ); + }, [opacity, duration]); + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + return ; +} diff --git a/app/src/components/base/StaticBackground.tsx b/app/src/components/base/StaticBackground.tsx new file mode 100644 index 000000000..796a5fa44 --- /dev/null +++ b/app/src/components/base/StaticBackground.tsx @@ -0,0 +1,21 @@ +import { ImageBackground } from 'expo-image'; +import { StyleSheet, View } from 'react-native'; +import { useEffectiveColorScheme } from '@/context/themeMode'; + +export function StaticBackground() { + const colorScheme = useEffectiveColorScheme(); + + const image = + colorScheme === 'light' + ? require('@/assets/images/bg-light.jpg') + : require('@/assets/images/bg-dark.jpg'); + + // Purely decorative: hide from the accessibility tree so screen readers and + // axe skip the underlying (expo-image drops an empty alt="", so mark + // the subtree aria-hidden instead). + return ( + + + + ); +} diff --git a/app/src/components/base/StatusPill.tsx b/app/src/components/base/StatusPill.tsx new file mode 100644 index 000000000..87794dd05 --- /dev/null +++ b/app/src/components/base/StatusPill.tsx @@ -0,0 +1,58 @@ +import { StyleSheet, View } from 'react-native'; +import { AppText } from '@/components/base/AppText'; +import { getStatusTone, useAppTheme } from '@/theme'; +import type { AppTokens } from '@/theme/types'; + +// onStatus is the fill's foreground text color, not a selectable tone. +export type StatusTone = Exclude; + +type StatusPillProps = { + label: string; + tone: StatusTone; + /** 'solid' = filled emphatic badge (e.g. LIVE); 'soft' = tinted informational chip. */ + variant?: 'solid' | 'soft'; + testID?: string; +}; + +/** + * Small status pill — the shared replacement for the near-identical LIVE badges + * (solid) and the thermal-telemetry chip (soft). `tone` maps to a status token; + * `variant` picks a solid fill or a tinted+bordered fill. + */ +export function StatusPill({ label, tone, variant = 'solid', testID }: StatusPillProps) { + const theme = useAppTheme(); + const color = theme.tokens.status[tone]; + const solid = variant === 'solid'; + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + pill: { + // Bumped from 22 to fit the caption step's 13px cap. minHeight, not + // height, so a caption line that grows (e.g. accessibility font scaling) + // doesn't get clipped. + minHeight: 24, + }, +}); diff --git a/app/src/components/base/TextInput.tsx b/app/src/components/base/TextInput.tsx new file mode 100644 index 000000000..53d35a454 --- /dev/null +++ b/app/src/components/base/TextInput.tsx @@ -0,0 +1,59 @@ +// NOTE: hand-rolled on purpose — carries error/validation state; no primitive in this app provides that + borderless default. +import type React from 'react'; +import { TextInput as NativeTextInput, type TextInputProps } from 'react-native'; +import { radius } from '@/constants'; +import { useAppTheme } from '@/theme'; + +interface Props extends TextInputProps { + errorOnEmpty?: boolean; + customValidation?: (value: string) => boolean; + // Opt-in to the primitive's default bordered look (1px outline, 12/10 + // padding) instead of every call site hand-copying that literal. Defaults + // to false so existing borderless call sites (inline editable text etc.) + // are unaffected. + bordered?: boolean; + ref?: React.Ref; +} + +export function TextInput({ + style, + children, + errorOnEmpty = false, + customValidation, + bordered = false, + ref, + ...props +}: Props) { + const theme = useAppTheme(); + const emptyError = errorOnEmpty && (!props.value || props.value === ''); + const validationError = customValidation && props.value && !customValidation(props.value); + const error = emptyError ? true : Boolean(validationError); + + return ( + + {children} + + ); +} diff --git a/app/src/components/base/TopNav.tsx b/app/src/components/base/TopNav.tsx new file mode 100644 index 000000000..713f1219d --- /dev/null +++ b/app/src/components/base/TopNav.tsx @@ -0,0 +1,108 @@ +import { usePathname, useRouter } from 'expo-router'; +import { useCallback } from 'react'; +import { Platform, Pressable, View } from 'react-native'; +import { AUTH_HERO_PATHS, WEB_FOCUS_RING } from '@/constants'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { type Destination, useVisibleDestinations } from '@/navigation/destinations'; +import { useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; +import { AppText } from './AppText'; +import { BrandHeaderTitle } from './BrandHeaderTitle'; +import { HeaderRightPill } from './HeaderRightPill'; + +// Full-bleed, chrome-free routes (AppStack's own headerShown: false list in +// _layout.tsx) — splash and the auth flow already opt out of the stack +// header, so the persistent top bar shouldn't layer on top of them either. +// Concretely: it was duplicating "Sign in" with the login form's own submit +// button. +// +// /mfa and /category-selection keep their own stack header (AppStack doesn't +// hide it for them), so TopNav suppresses itself there too — otherwise lg +// shows both bars, and on /mfa the Products/Cameras links let a keyboard user +// tab away mid login-challenge. +const NO_CHROME_PATHS = new Set(['/', '/category-selection', ...AUTH_HERO_PATHS]); + +function TopNavDestinationItem({ + destination, + active, + onPress, +}: { + destination: Destination; + active: boolean; + onPress: (href: Destination['href']) => void; +}) { + const handlePress = useCallback(() => onPress(destination.href), [onPress, destination.href]); + return ( + + + {destination.label} + + + ); +} + +/** + * Slim persistent top bar shown on desktop web (>=lg) only. Phone and native + * keep today's stack headers untouched — see PRIMARY_DESTINATIONS for the + * sidebar-vs-topnav rationale. + */ +export function TopNav() { + const { isLg } = useBreakpoint(); + const router = useRouter(); + const pathname = usePathname(); + const theme = useAppTheme(); + const destinations = useVisibleDestinations(); + // navigate(), not push(): from a screen outside the tabs (a public profile, + // where this bar still shows) a push would stack a *second* (tabs) navigator + // on the root stack instead of returning to the live one. From inside the + // tabs a push is downgraded to the same tab jump anyway. + const goToProducts = useCallback(() => router.navigate('/products'), [router]); + const goToDestination = useCallback( + (href: Destination['href']) => router.navigate(href), + [router], + ); + + if (!(Platform.OS === 'web' && isLg) || NO_CHROME_PATHS.has(pathname)) return null; + + return ( + + + + + + {destinations.map((destination) => ( + + ))} + + + + + + ); +} diff --git a/app/src/components/base/__tests__/AmountStepper.test.tsx b/app/src/components/base/__tests__/AmountStepper.test.tsx new file mode 100644 index 000000000..4804202e8 --- /dev/null +++ b/app/src/components/base/__tests__/AmountStepper.test.tsx @@ -0,0 +1,24 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { AmountStepper } from '@/components/base/AmountStepper'; + +test('increments and decrements', () => { + const onChange = jest.fn(); + render(); + fireEvent.press(screen.getByLabelText('Increase amount')); + expect(onChange).toHaveBeenCalledWith(4); + fireEvent.press(screen.getByLabelText('Decrease amount')); + expect(onChange).toHaveBeenCalledWith(2); +}); + +test('decrement disabled at min', () => { + const onChange = jest.fn(); + render(); + fireEvent.press(screen.getByLabelText('Decrease amount')); + expect(onChange).not.toHaveBeenCalled(); +}); + +test('renders label and value', () => { + render(); + expect(screen.getByText('Amount in parent')).toBeOnTheScreen(); + expect(screen.getByText('8')).toBeOnTheScreen(); +}); diff --git a/app/src/components/base/__tests__/AppButton.test.tsx b/app/src/components/base/__tests__/AppButton.test.tsx new file mode 100644 index 000000000..1385c68c7 --- /dev/null +++ b/app/src/components/base/__tests__/AppButton.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { AppButton } from '@/components/base/AppButton'; + +// react-native's own Platform.select (Platform.ios.js) hardcodes 'ios'/'native' +// key checks and ignores Platform.OS, and the vendored ui/button.tsx computes +// its Platform.select({ web: ... }) classes once at module-import time (inside +// cva()) — so a runtime mockPlatform('web') call in a test body is too late to +// affect it. Mock the whole module up front so the web branch is baked in when +// AppButton (and ui/button) are first imported below. +jest.mock('react-native', () => { + // Mutate in place rather than spreading the module namespace — spreading + // forces eager evaluation of unrelated lazy native-module getters (e.g. + // DevMenu) that throw outside the real native runtime. + const actual = jest.requireActual('react-native'); + actual.Platform.OS = 'web'; + actual.Platform.select = (spec: Record) => spec.web; + return actual; +}); + +test('fires onPress', () => { + const onPress = jest.fn(); + render(Save); + fireEvent.press(screen.getByText('Save')); + expect(onPress).toHaveBeenCalledTimes(1); +}); + +test('loading disables the button and blocks presses', () => { + const onPress = jest.fn(); + render( + + Save + , + ); + fireEvent.press(screen.getByText('Save')); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('primary variant label uses the primary-foreground text color', () => { + render(Save); + expect(screen.getByText('Save').props.className).toEqual( + expect.stringContaining('text-primary-foreground'), + ); +}); + +test('destructive variant label uses the white text color', () => { + render(Delete); + expect(screen.getByText('Delete').props.className).toEqual(expect.stringContaining('text-white')); +}); + +test('tonal variant uses the soft-primary fill and primary text color', () => { + render(Sign in); + expect(screen.getByRole('button').props.className).toEqual( + expect.stringContaining('bg-primary/12'), + ); + expect(screen.getByText('Sign in').props.className).toEqual( + expect.stringContaining('text-primary'), + ); +}); + +const SHADOW_CLASS_PATTERN = /\bshadow-(sm|md|lg|xl)\b/; +const ACCENT_CLASS_PATTERN = /\baccent\b/; + +// DESIGN.md "Form language — Flat & Sharp": inline surfaces carry no elevation, +// and controls sit at the 6px control radius (Tailwind's rounded-md === 6px, +// generated from assets/tokens.json into --radius-md in brand.generated.css). +test.each(['primary', 'tonal', 'outline', 'ghost', 'destructive'] as const)( + '%s variant is flat and uses the control radius', + (variant) => { + render(Label); + const className = screen.getByRole('button').props.className; + expect(className).not.toMatch(SHADOW_CLASS_PATTERN); + expect(className).toEqual(expect.stringContaining('rounded-md')); + }, +); + +// DESIGN.md: "the manila accent is a text colour only — never a button fill or a +// hover/pressed state; interaction states use a subtler shade of the primary." +// `accent` is the brand manila (brand.generated.css: --accent: #8F6212), so no +// button variant or state may reference it — pressed/hover are primary-derived. +test.each(['primary', 'tonal', 'outline', 'ghost', 'destructive'] as const)( + '%s variant never uses the accent for a fill or state', + (variant) => { + render(Label); + expect(screen.getByRole('button').props.className).not.toMatch(ACCENT_CLASS_PATTERN); + expect(screen.getByText('Label').props.className).not.toMatch(ACCENT_CLASS_PATTERN); + }, +); + +test.each(['outline', 'ghost'] as const)( + '%s variant uses a primary tint state-layer, not a neutral', + (variant) => { + render(Label); + expect(screen.getByRole('button').props.className).toEqual( + expect.stringContaining('active:bg-primary/'), + ); + }, +); + +test('meets the 44px a11y tap-target floor regardless of caller className', () => { + render(Add component); + expect(screen.getByRole('button').props.className).toEqual(expect.stringContaining('min-h-11')); +}); + +test('forwards arbitrary accessibility props (accessibilityHint) to the underlying button', () => { + render(Save); + expect(screen.getByRole('button').props.accessibilityHint).toBe('Saves the current product'); +}); + +test('has web hover, cursor, and focus-visible affordances', () => { + render(Save); + const className = screen.getByRole('button').props.className; + expect(className).toEqual(expect.stringContaining('cursor-pointer')); + expect(className).toEqual(expect.stringContaining('hover:')); + expect(className).toEqual(expect.stringContaining('focus-visible:')); +}); diff --git a/app/src/components/base/__tests__/AppDialog.test.tsx b/app/src/components/base/__tests__/AppDialog.test.tsx new file mode 100644 index 000000000..ca04d2da3 --- /dev/null +++ b/app/src/components/base/__tests__/AppDialog.test.tsx @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import { createRef } from 'react'; +import { AccessibilityInfo, findNodeHandle, Text, View } from 'react-native'; +import { AppDialog } from '@/components/base/AppDialog'; +import { mockPlatform, renderWithProviders, restorePlatform } from '@/test-utils/index'; + +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + findNodeHandle: jest.fn(() => 7), +})); + +const mockedFindNodeHandle = jest.mocked(findNodeHandle); + +// The web branch of useReturnFocus reads document.activeElement; the RN test +// environment has no DOM, so stub the one property it touches. +function stubDocument(activeElement: unknown) { + Object.defineProperty(globalThis, 'document', { + value: { activeElement }, + configurable: true, + }); +} + +afterEach(() => { + restorePlatform(); + Reflect.deleteProperty(globalThis, 'document'); +}); + +describe('AppDialog', () => { + it('returns focus to the element that opened it', () => { + mockPlatform('web'); + const trigger = { focus: jest.fn(), isConnected: true }; + stubDocument(trigger); + + const { rerender } = renderWithProviders( + + Body + , + ); + + rerender( + + Body + , + ); + expect(trigger.focus).not.toHaveBeenCalled(); + expect(screen.getByText('Body')).toBeOnTheScreen(); + + rerender( + + Body + , + ); + + expect(trigger.focus).toHaveBeenCalledTimes(1); + }); + + it('leaves focus alone when the trigger is gone', () => { + mockPlatform('web'); + const trigger = { focus: jest.fn(), isConnected: false }; + stubDocument(trigger); + + const { rerender } = renderWithProviders( + + Body + , + ); + rerender( + + Body + , + ); + rerender( + + Body + , + ); + + expect(trigger.focus).not.toHaveBeenCalled(); + }); + + it('restores native screen-reader focus to a passed-in triggerRef', () => { + mockPlatform('ios'); + const setFocus = jest + .spyOn(AccessibilityInfo, 'setAccessibilityFocus') + .mockImplementation(() => {}); + const triggerRef = createRef(); + // Only resolves a handle for the externally-supplied ref, so a stray + // internal (unattached) ref can't make this pass by accident. + mockedFindNodeHandle.mockImplementation((component) => + component === triggerRef.current ? 7 : null, + ); + + const { rerender } = renderWithProviders( + <> + + + Body + + , + ); + + rerender( + <> + + + Body + + , + ); + rerender( + <> + + + Body + + , + ); + + expect(setFocus).toHaveBeenCalledWith(7); + }); +}); diff --git a/app/src/components/base/__tests__/AppText.test.tsx b/app/src/components/base/__tests__/AppText.test.tsx new file mode 100644 index 000000000..5c253ec86 --- /dev/null +++ b/app/src/components/base/__tests__/AppText.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react-native'; +import { AppText } from '@/components/base/AppText'; + +test('renders body text by default', () => { + render(hello); + expect(screen.getByText('hello')).toBeOnTheScreen(); +}); + +test('data variant uses tabular numerals styling', () => { + render(42 g); + const el = screen.getByText('42 g'); + expect(el.props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ fontVariant: ['tabular-nums'] })]), + ); +}); + +test('default color comes from the text-foreground class', () => { + render(plain); + expect(screen.getByText('plain').props.className).toContain('text-foreground'); +}); + +test('caller color classes win over the default (tailwind-merge)', () => { + render(tinted); + const className = screen.getByText('tinted').props.className; + expect(className).toContain('text-primary'); + expect(className).not.toContain('text-foreground'); +}); + +test('body variant (default) is selectable — record content is documentation', () => { + render(record note); + expect(screen.getByText('record note').props.selectable).toBe(true); +}); + +test('data variant is selectable', () => { + render(42 g); + expect(screen.getByText('42 g').props.selectable).toBe(true); +}); + +test('display variant is not selectable', () => { + render(Heading); + expect(screen.getByText('Heading').props.selectable).not.toBe(true); +}); + +test('title variant is not selectable', () => { + render(Section title); + expect(screen.getByText('Section title').props.selectable).not.toBe(true); +}); + +test('label variant is not selectable', () => { + render(Field label); + expect(screen.getByText('Field label').props.selectable).not.toBe(true); +}); + +test('eyebrow variant uses label metrics, uppercase transform, and muted color', () => { + render(Section); + const el = screen.getByText('Section'); + expect(el.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ fontSize: 13, lineHeight: 18, textTransform: 'uppercase' }), + ]), + ); + expect(el.props.className).toContain('text-muted-foreground'); +}); + +test('eyebrow color is overridable via className (tailwind-merge)', () => { + render( + + Section + , + ); + const className = screen.getByText('Section').props.className; + expect(className).toContain('text-primary'); + expect(className).not.toContain('text-muted-foreground'); +}); + +test('defaults maxFontSizeMultiplier to the app-wide Dynamic Type cap', () => { + render(hello); + expect(screen.getByText('hello').props.maxFontSizeMultiplier).toBe(2); +}); + +test('maxFontSizeMultiplier is overridable per call', () => { + render(hello); + expect(screen.getByText('hello').props.maxFontSizeMultiplier).toBe(1.5); +}); diff --git a/app/src/components/base/__tests__/BottomNav.test.tsx b/app/src/components/base/__tests__/BottomNav.test.tsx new file mode 100644 index 000000000..86a21765a --- /dev/null +++ b/app/src/components/base/__tests__/BottomNav.test.tsx @@ -0,0 +1,155 @@ +import { useSegments } from 'expo-router'; +import type { BottomTabBarProps } from 'expo-router/js-tabs'; +import { BottomNav } from '@/components/base/BottomNav'; +import { + fireEvent, + mockPlatform, + renderWithProviders, + restorePlatform, + screen, +} from '@/test-utils'; + +jest.mock('expo-router', () => ({ + useSegments: jest.fn(), +})); + +jest.mock('@/hooks/useBreakpoint', () => ({ + useBreakpoint: jest.fn(), +})); + +const mockUseAuth = jest.fn(); +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +const mockUseRpiIntegration = jest.fn(); +jest.mock('@/features/cameras/rpi/useRpiIntegration', () => ({ + useRpiIntegration: () => mockUseRpiIntegration(), +})); + +const navigate = jest.fn(); +const emit = jest.fn(() => ({ defaultPrevented: false })); +const TAB_ROUTES = ['(products)', '(cameras)', '(account)']; + +/** The slice of BottomTabBarProps this bar actually reads. */ +function renderBar(activeIndex = 0) { + const props = { + state: { + index: activeIndex, + routes: TAB_ROUTES.map((name) => ({ key: `${name}-key`, name })), + }, + navigation: { navigate, emit }, + } as unknown as BottomTabBarProps; + return renderWithProviders(); +} + +beforeEach(() => { + jest.clearAllMocks(); + const { useBreakpoint } = jest.requireMock('@/hooks/useBreakpoint'); + (useSegments as jest.Mock).mockReturnValue(['(tabs)', '(products)', 'products', 'index']); + (useBreakpoint as jest.Mock).mockReturnValue({ isLg: false }); + emit.mockReturnValue({ defaultPrevented: false }); + mockUseAuth.mockReturnValue({ user: { id: '1' } }); + mockUseRpiIntegration.mockReturnValue({ enabled: true }); +}); + +test('shows Products, Cameras, Account inside the tab group at phone width', () => { + renderBar(); + expect(screen.getByLabelText('Products')).toBeTruthy(); + expect(screen.getByLabelText('Cameras')).toBeTruthy(); + expect(screen.getByLabelText('Account')).toBeTruthy(); +}); + +// The point of the tab groups: a detail screen belongs to a tab, so the bar +// stays put and every other tab is still one tap away. +test('stays visible on a detail screen inside a tab', () => { + (useSegments as jest.Mock).mockReturnValue(['(tabs)', '(products)', 'products', '[id]']); + renderBar(); + expect(screen.getByLabelText('Products')).toBeTruthy(); +}); + +test('renders nothing outside the tab group', () => { + (useSegments as jest.Mock).mockReturnValue(['category-selection']); + renderBar(); + expect(screen.queryByLabelText('Products')).toBeNull(); +}); + +test('renders nothing at lg', () => { + const { useBreakpoint } = jest.requireMock('@/hooks/useBreakpoint'); + (useBreakpoint as jest.Mock).mockReturnValue({ isLg: true }); + renderBar(); + expect(screen.queryByLabelText('Products')).toBeNull(); +}); + +test('hides Cameras when rpi integration is disabled', () => { + mockUseRpiIntegration.mockReturnValue({ enabled: false }); + renderBar(); + expect(screen.getByLabelText('Products')).toBeTruthy(); + expect(screen.queryByLabelText('Cameras')).toBeNull(); +}); + +test('hides Account when signed out', () => { + mockUseAuth.mockReturnValue({ user: null }); + renderBar(); + expect(screen.getByLabelText('Products')).toBeTruthy(); + expect(screen.queryByLabelText('Account')).toBeNull(); +}); + +test('marks the navigator’s focused tab as selected', () => { + renderBar(1); + expect(screen.getByLabelText('Cameras').props.accessibilityState).toEqual({ selected: true }); + expect(screen.getByLabelText('Products').props.accessibilityState).toEqual({ selected: false }); +}); + +// Navigating by route name (not href) is what returns the user to that tab's +// preserved trail instead of resetting it to the tab's root screen. +test('pressing an unfocused tab navigates to its route name', () => { + renderBar(); + fireEvent.press(screen.getByLabelText('Cameras')); + expect(navigate).toHaveBeenCalledWith('(cameras)'); +}); + +// tabPress is the event the focused tab's own listeners (the nested stack's +// pop-to-top) hang off; re-navigating on top of it would be a no-op at best. +test('pressing the focused tab emits tabPress instead of navigating', () => { + renderBar(); + fireEvent.press(screen.getByLabelText('Products')); + expect(emit).toHaveBeenCalledWith({ + type: 'tabPress', + target: '(products)-key', + canPreventDefault: true, + }); + expect(navigate).not.toHaveBeenCalled(); +}); + +test('a listener that prevents the default press blocks the navigation', () => { + emit.mockReturnValue({ defaultPrevented: true }); + renderBar(); + fireEvent.press(screen.getByLabelText('Cameras')); + expect(emit).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); +}); + +test('tabs carry active-state opacity feedback', () => { + renderBar(); + const className = screen.getByLabelText('Products').props.className as string; + expect(className).toEqual(expect.stringContaining('active:opacity-60')); +}); + +test('tabs carry a web focus-visible ring', () => { + mockPlatform('web'); + renderBar(); + const className = screen.getByLabelText('Products').props.className as string; + // Asserts the outline mechanism, not `ring`. Tailwind's ring compiles to a + // box-shadow layer that never composed here (these controls also carry + // `shadow-none`), so the old assertion passed while focus painted nothing at + // all. Outline cannot be clipped and does not depend on shadow composition. + expect(className).toEqual(expect.stringContaining('focus-visible:outline-2')); + expect(className).toEqual(expect.stringContaining('focus-visible:outline-ring')); + // The style utility is the one that was missing and made the indicator + // invisible while width and colour computed correctly. A class-string test + // cannot prove it paints — see the e2e focus test for that — but it can stop + // this specific utility being dropped again. + expect(className).toEqual(expect.stringContaining('focus-visible:outline-solid')); + restorePlatform(); +}); diff --git a/app/src/components/base/__tests__/Card.test.tsx b/app/src/components/base/__tests__/Card.test.tsx new file mode 100644 index 000000000..67ad459bc --- /dev/null +++ b/app/src/components/base/__tests__/Card.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { Card } from '@/components/base/Card'; + +const SHADOW_CLASS_PATTERN = /\bshadow-(sm|md|lg|xl)\b/; + +test('renders children with the card surface classes', () => { + render( + + Hello + , + ); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); + +test('merges caller className with the base surface classes', () => { + render( + + Hello + , + ); + const className = screen.getByTestId('card').props.className; + expect(className).toEqual(expect.stringContaining('bg-card')); + expect(className).toEqual(expect.stringContaining('border-border')); + expect(className).toEqual(expect.stringContaining('rounded-lg')); + expect(className).toEqual(expect.stringContaining('mx-4')); +}); + +// DESIGN.md "Form language — Flat & Sharp": inline surfaces are flat — a hairline +// border + surface fill, no shadow. Only floating surfaces get the overlay tier. +// (Tailwind's rounded-lg === 8px === DESIGN.md radius-card, generated from +// assets/tokens.json into --radius-lg in brand.generated.css.) +test('Card is a flat hairline surface at the card radius', () => { + render( + + Hello + , + ); + const className = screen.getByTestId('card').props.className; + expect(className).not.toMatch(SHADOW_CLASS_PATTERN); + expect(className).toEqual(expect.stringContaining('border')); + expect(className).toEqual(expect.stringContaining('rounded-lg')); +}); + +test('forwards a style prop', () => { + render( + + Hello + , + ); + expect(screen.getByTestId('card').props.style).toEqual({ marginHorizontal: 14 }); +}); diff --git a/app/src/components/base/__tests__/CenteredSpinner.test.tsx b/app/src/components/base/__tests__/CenteredSpinner.test.tsx new file mode 100644 index 000000000..750e62cbb --- /dev/null +++ b/app/src/components/base/__tests__/CenteredSpinner.test.tsx @@ -0,0 +1,10 @@ +import { screen } from '@testing-library/react-native'; +import { CenteredSpinner } from '@/components/base/CenteredSpinner'; +import { renderWithProviders } from '@/test-utils/index'; + +test('exposes a busy progressbar for screen readers', () => { + renderWithProviders(); + expect(screen.getByRole('progressbar').props.accessibilityState).toEqual( + expect.objectContaining({ busy: true }), + ); +}); diff --git a/app/src/components/base/__tests__/Chip.test.tsx b/app/src/components/base/__tests__/Chip.test.tsx new file mode 100644 index 000000000..10eb65f6e --- /dev/null +++ b/app/src/components/base/__tests__/Chip.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import { View } from 'react-native'; +import { Chip } from '@/components/base/Chip'; +import { MIN_TAP_TARGET } from '@/constants'; +import { useEffectiveColorScheme } from '@/context/themeMode'; +import { setupUser } from '@/test-utils/index'; +import { renderWithProviders } from '@/test-utils/render'; +import { getAppTheme, getStatusTone } from '@/theme'; + +jest.mock('@/context/themeMode', () => ({ + useEffectiveColorScheme: jest.fn(() => 'light'), +})); + +// react-test-renderer's ReactTestInstance tree interleaves composite and host +// nodes with identical displayNames (RN's `View` forwardRef vs. the host +// primitive both read as "View"), so walking `.parent`/`.children` can't +// reliably tell "inside the Text node" from "next to it". `toJSON()` returns +// only the host tree, so sibling-ness there is unambiguous: find the +// `children` array that directly contains a node matching `matches`. +type JsonNode = { type: string; props?: Record; children?: unknown[] | null }; + +function findContainingChildren( + node: JsonNode | null, + matches: (n: JsonNode) => boolean, +): unknown[] | null { + const children = (node?.children ?? []) as unknown[]; + if (children.some((c) => typeof c === 'object' && c && matches(c as JsonNode))) { + return children; + } + for (const child of children) { + if (typeof child === 'object' && child) { + const found = findContainingChildren(child as JsonNode, matches); + if (found) return found; + } + } + return null; +} + +describe('Chip', () => { + const user = setupUser(); + + it('renders children text', () => { + renderWithProviders(My Label); + expect(screen.getByText('My Label')).toBeOnTheScreen(); + }); + + it('renders title when provided', () => { + renderWithProviders(Content); + expect(screen.getByText('Title Text')).toBeOnTheScreen(); + }); + + it('renders without title by default', () => { + renderWithProviders(No Title); + expect(screen.queryByText('Title Text')).toBeNull(); + }); + + it('calls onPress handler when pressed', async () => { + const onPress = jest.fn(); + renderWithProviders(Press Me); + await user.press(screen.getByText('Press Me')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('applies a tinted danger style when error prop is set', () => { + renderWithProviders(Error Chip); + const danger = getAppTheme('light').tokens.status.danger; + // The icon-wrapping View, two composite levels above the Text's raw string node. + expect(screen.getByText('Error Chip').parent?.parent?.parent).toHaveStyle({ + backgroundColor: getStatusTone(danger), + borderColor: danger, + borderWidth: 1, + }); + expect(screen.getByText('Error Chip')).toHaveStyle({ + color: danger, + }); + }); + + it('renders a compact alert icon alongside the value text when error is set (WCAG 1.4.1)', () => { + const { UNSAFE_root } = renderWithProviders(Error Chip); + const { Svg } = jest.requireActual('react-native-svg'); + expect(UNSAFE_root.findAllByType(Svg).length).toBeGreaterThan(0); + }); + + it('renders no alert icon when error is not set', () => { + const { UNSAFE_root } = renderWithProviders(Normal Chip); + const { Svg } = jest.requireActual('react-native-svg'); + expect(UNSAFE_root.findAllByType(Svg)).toHaveLength(0); + }); + + it('applies primary style when error prop is not set', () => { + renderWithProviders(Normal Chip); + expect(screen.getByText('Normal Chip').parent?.parent?.parent).toHaveStyle({ + backgroundColor: getAppTheme('light').colors.primary, + }); + expect(screen.getByText('Normal Chip')).toHaveStyle({ + color: getAppTheme('light').colors.onPrimary, + }); + }); + + it('applies dark mode styles when the system theme is dark', () => { + jest.mocked(useEffectiveColorScheme).mockReturnValue('dark'); + + renderWithProviders(Dark Chip); + + expect(screen.getByText('Dark Chip').parent?.parent?.parent).toHaveStyle({ + backgroundColor: getAppTheme('dark').colors.primary, + }); + expect(screen.getByText('Dark Chip')).toHaveStyle({ + color: getAppTheme('dark').colors.onPrimary, + }); + + jest.mocked(useEffectiveColorScheme).mockReturnValue('light'); + }); + + it('renders an icon when one is provided', () => { + renderWithProviders( + } title="With Icon"> + Chip Content + , + ); + + expect(screen.getByTestId('chip-icon')).toBeOnTheScreen(); + }); + + it('renders the icon as a sibling of the value text, not nested inside it', () => { + const { toJSON } = renderWithProviders( + }>Chip Content, + ); + const json = toJSON() as unknown as JsonNode; + + const isIcon = (n: JsonNode) => n.props?.testID === 'chip-icon'; + const isValueText = (n: JsonNode) => + n.type === 'Text' && !!n.children?.includes('Chip Content'); + + const iconSiblings = findContainingChildren(json, isIcon); + // Same children array as the icon, and it contains the value Text too — + // proves they're siblings in one flex row, not the icon nested inside + // the Text (ProductCard's old `{children}{icon}` pattern). + expect(iconSiblings).not.toBeNull(); + expect( + iconSiblings?.some((c) => typeof c === 'object' && c && isValueText(c as JsonNode)), + ).toBe(true); + }); + + it('renders no icon node when icon is false (ProductTags gates it on editMode)', () => { + const { toJSON } = renderWithProviders(No Icon); + const json = toJSON() as unknown as JsonNode; + + expect(JSON.stringify(json)).not.toContain('chip-icon'); + // The value segment's children array holds only the Text — `false` + // renders nothing, so no stray empty node sits where the icon would go. + const isValueText = (n: JsonNode) => n.type === 'Text' && !!n.children?.includes('No Icon'); + const valueSiblings = findContainingChildren(json, isValueText); + expect(valueSiblings).toHaveLength(1); + }); + + it('defaults accessibilityRole to button', () => { + renderWithProviders(Role Chip); + expect(screen.getByRole('button')).toBeOnTheScreen(); + }); + + it('lets a caller override accessibilityRole', () => { + renderWithProviders(Link Chip); + expect(screen.getByRole('link')).toBeOnTheScreen(); + }); + + it('composes an accessibilityLabel from title and value', () => { + renderWithProviders(CircularTech); + expect(screen.getByLabelText('Brand: CircularTech')).toBeOnTheScreen(); + }); + + it('appends ", required" to the composed label when error is set', () => { + renderWithProviders( + + Unknown + , + ); + expect(screen.getByLabelText('Brand: Unknown, required')).toBeOnTheScreen(); + }); + + it('lets a caller override the composed accessibilityLabel', () => { + renderWithProviders( + + Unknown + , + ); + expect(screen.getByLabelText('Add a new brand')).toBeOnTheScreen(); + }); + + it('sets accessibilityState.disabled when disabled', () => { + renderWithProviders(Disabled Chip); + expect(screen.getByRole('button').props.accessibilityState).toEqual({ disabled: true }); + }); + + it('meets the MIN_TAP_TARGET floor', () => { + renderWithProviders(Tap Target); + expect(screen.getByRole('button')).toHaveStyle({ minHeight: MIN_TAP_TARGET }); + }); +}); diff --git a/app/src/components/base/__tests__/ControlledTextField.test.tsx b/app/src/components/base/__tests__/ControlledTextField.test.tsx new file mode 100644 index 000000000..ca4516ae1 --- /dev/null +++ b/app/src/components/base/__tests__/ControlledTextField.test.tsx @@ -0,0 +1,32 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { fireEvent, screen } from '@testing-library/react-native'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { ControlledTextField } from '@/components/base/ControlledTextField'; +import { renderWithProviders } from '@/test-utils'; + +const schema = z.object({ name: z.string().min(2, 'Name is too short') }); + +// biome-ignore lint/style/useComponentExportOnlyModules: test-only harness, not a real module export. +function Harness() { + const { control } = useForm({ + resolver: zodResolver(schema), + mode: 'onChange', + defaultValues: { name: '' }, + }); + return ( + + ); +} + +test('renders label, propagates input, and announces the zod error', async () => { + renderWithProviders(); + expect(screen.getByText('Camera name')).toBeTruthy(); + fireEvent.changeText(screen.getByPlaceholderText('Camera name'), 'x'); + expect(await screen.findByText('Name is too short')).toBeTruthy(); +}); diff --git a/app/src/components/base/__tests__/DialogProvider.destructive.test.tsx b/app/src/components/base/__tests__/DialogProvider.destructive.test.tsx new file mode 100644 index 000000000..39533d02c --- /dev/null +++ b/app/src/components/base/__tests__/DialogProvider.destructive.test.tsx @@ -0,0 +1,100 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, screen } from '@testing-library/react-native'; +import { Pressable, Text } from 'react-native'; +import { useDialog } from '@/components/base/dialogContext'; +import { renderWithProviders, setupUser } from '@/test-utils/index'; + +function renderTrigger(onPress: () => void) { + return ( + + open + + ); +} + +describe('DialogProvider destructive action hierarchy', () => { + const user = setupUser(); + + it('Enter in an input dialog never fires the destructive action', async () => { + const onDelete = jest.fn(); + + function Trigger() { + const dialog = useDialog(); + return renderTrigger(() => + dialog.input({ + title: 'Delete product', + placeholder: 'Product name', + buttons: [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: onDelete }, + ], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + // submitEditing is a custom event not supported by userEvent + fireEvent(screen.getByPlaceholderText('Product name'), 'submitEditing'); + + expect(onDelete).not.toHaveBeenCalled(); + }); + + it('a destructive button renders with the destructive fill', async () => { + function Trigger() { + const dialog = useDialog(); + return renderTrigger(() => + dialog.alert({ + title: 'Delete product', + buttons: [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive' }, + ], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + expect(screen.getByRole('button', { name: 'Delete' }).props.className).toEqual( + expect.stringContaining('bg-destructive'), + ); + expect(screen.getByRole('button', { name: 'Cancel' }).props.className).not.toEqual( + expect.stringContaining('bg-destructive'), + ); + }); + + // The button Enter/return submits (pickSubmitButton) gets the same visual + // weight as the keyboard default: AppButton's primary fill, not ghost. + it('the submit action gets the primary emphasis; cancel stays ghost', async () => { + function Trigger() { + const dialog = useDialog(); + return renderTrigger(() => + dialog.alert({ + title: 'Sign out?', + buttons: [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Sign out', onPress: () => {} }, + ], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + await user.press(screen.getByTestId('trigger')); + + const submitClassName = screen.getByRole('button', { name: 'Sign out' }).props + .className as string; + const cancelClassName = screen.getByRole('button', { name: 'Cancel' }).props + .className as string; + + // Exact-token check: ghost's className also contains the substring + // "bg-primary" (inside "active:bg-primary/10"), so a plain + // stringContaining check can't tell the variants apart. + expect(submitClassName.split(' ')).toContain('bg-primary'); + expect(cancelClassName.split(' ')).not.toContain('bg-primary'); + }); +}); diff --git a/app/src/components/base/__tests__/DialogProvider.test.tsx b/app/src/components/base/__tests__/DialogProvider.test.tsx new file mode 100644 index 000000000..4e5ba72b2 --- /dev/null +++ b/app/src/components/base/__tests__/DialogProvider.test.tsx @@ -0,0 +1,465 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { createRef } from 'react'; +import { AccessibilityInfo, findNodeHandle, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useDialog } from '@/components/base/dialogContext'; +import { mockPlatform, renderWithProviders, restorePlatform, setupUser } from '@/test-utils/index'; + +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + findNodeHandle: jest.fn(() => 7), +})); + +const mockedFindNodeHandle = jest.mocked(findNodeHandle); + +function renderAlertTrigger(onPress: () => void) { + return ( + + Open Alert + + ); +} + +// DialogProvider tests must use withDialog: true to wrap the UI in DialogProvider. +// We pass a *custom* wrapper here because DialogProvider-test needs the DialogProvider +// context to be available to the components under test; which renderWithProviders +// provides when withDialog: true is set. + +describe('DialogProvider', () => { + const user = setupUser(); + + afterEach(() => { + restorePlatform(); + }); + + it('renders children without showing a dialog by default', () => { + renderWithProviders(Hello World, { withDialog: true }); + expect(screen.getByText('Hello World')).toBeOnTheScreen(); + }); + + it('useDialog throws when used outside DialogProvider', () => { + function BadConsumer() { + useDialog(); + return Should not render; + } + expect(() => renderWithProviders()).toThrow( + 'useDialog must be used within DialogProvider', + ); + }); + + it('alert() shows dialog with title', async () => { + function AlertTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.alert({ title: 'Alert Title', buttons: [{ text: 'OK' }] }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + expect(screen.getByText('Alert Title')).toBeOnTheScreen(); + expect(screen.getByText('OK')).toBeOnTheScreen(); + }); + + it('alert() shows dialog with message', async () => { + function MessageTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.alert({ title: 'Title', message: 'Some message', buttons: [{ text: 'Close' }] }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + expect(screen.getByText('Some message')).toBeOnTheScreen(); + }); + + it('input() shows dialog with TextInput', async () => { + function InputTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.input({ + title: 'Input Dialog', + placeholder: 'Type something...', + buttons: [{ text: 'Submit' }], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + expect(screen.getByText('Input Dialog')).toBeOnTheScreen(); + expect(screen.getByPlaceholderText('Type something...')).toBeOnTheScreen(); + }); + + it('input() dialog onPress callback receives the typed value', async () => { + const onSubmit = jest.fn(); + + function InputTypingTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.input({ + title: 'Enter Name', + placeholder: 'Your name', + buttons: [{ text: 'Submit', onPress: onSubmit }], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + await user.type(screen.getByPlaceholderText('Your name'), 'hello world'); + + await user.press(screen.getByText('Submit')); + + expect(onSubmit).toHaveBeenCalledWith('hello world'); + }); + + it('threads options.triggerRef through to AppDialog for native focus restore', async () => { + mockPlatform('ios'); + const setFocus = jest + .spyOn(AccessibilityInfo, 'setAccessibilityFocus') + .mockImplementation(() => {}); + const triggerRef = createRef(); + // Only resolves a handle for the externally-supplied ref, so a stray internal + // (unattached) ref inside AppDialog can't make this pass by accident. + mockedFindNodeHandle.mockImplementation((component) => + component === triggerRef.current ? 7 : null, + ); + + function TriggerRefTest() { + const dialog = useDialog(); + return ( + <> + + {renderAlertTrigger(() => + dialog.input({ + title: 'Edit name', + placeholder: 'Name', + triggerRef, + buttons: [{ text: 'Cancel', style: 'cancel' }], + }), + )} + + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + expect(screen.getByText('Edit name')).toBeOnTheScreen(); + + await user.press(screen.getByText('Cancel')); + + expect(setFocus).toHaveBeenCalledWith(7); + }); + + it('dialog button onPress callback is called with value for alert', async () => { + const onConfirm = jest.fn(); + + function AlertTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.alert({ + title: 'Confirm', + buttons: [{ text: 'Yes', onPress: onConfirm }], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + await user.press(screen.getByText('Yes')); + + expect(onConfirm).toHaveBeenCalledWith(undefined); // alert mode → undefined value + }); + + it('pressing submit on the input keyboard calls handleClose with the last button', async () => { + const onSubmit = jest.fn(); + + function InputSubmitTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.input({ + title: 'Enter Name', + placeholder: 'Your name', + buttons: [{ text: 'Cancel' }, { text: 'OK', onPress: onSubmit }], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + await user.type(screen.getByPlaceholderText('Your name'), 'hello'); + // submitEditing is a custom event not supported by userEvent + fireEvent(screen.getByPlaceholderText('Your name'), 'submitEditing'); + + expect(onSubmit).toHaveBeenCalledWith('hello'); + }); + + it('submitEditing does not fire the primary action while it is disabled', async () => { + const onSubmit = jest.fn(); + + function DisabledSubmitTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.input({ + title: 'Enter Name', + placeholder: 'Your name', + buttons: [ + { text: 'Cancel' }, + { text: 'OK', onPress: onSubmit, disabled: (v) => !v.trim() }, + ], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + // Field is empty → OK's disabled gate is active; pressing Enter must not bypass it. + fireEvent(screen.getByPlaceholderText('Your name'), 'submitEditing'); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('default OK button renders when no buttons provided', async () => { + function DefaultTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => dialog.alert({ title: 'No Buttons' })); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + expect(screen.getByText('OK')).toBeOnTheScreen(); + }); + + it('toast() shows a transient snackbar message', async () => { + function ToastTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => dialog.toast('Saved')); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + expect(screen.getByText('Saved')).toBeOnTheScreen(); + }); + + it('dialog actions remain callable after the consumer rerenders', async () => { + function AlertTest({ title }: { title: string }) { + const dialog = useDialog(); + return renderAlertTrigger(() => dialog.alert({ title, buttons: [{ text: 'OK' }] })); + } + + const view = renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + expect(screen.getByText('First Title')).toBeOnTheScreen(); + await user.press(screen.getByText('OK')); + + view.rerender(); + + await user.press(screen.getByTestId('trigger')); + expect(screen.getByText('Second Title')).toBeOnTheScreen(); + }); + + it('pressing a button with no onPress closes the dialog without throwing', async () => { + function Test() { + const dialog = useDialog(); + return renderAlertTrigger(() => dialog.alert({ title: 'Plain', buttons: [{ text: 'OK' }] })); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + await user.press(screen.getByText('OK')); + }); + + it('submitEditing on input with no buttons calls handleClose without crashing', async () => { + function Test() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.input({ title: 'No-Button Input', placeholder: 'type here' }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + // submitEditing is a custom event not supported by userEvent + fireEvent(screen.getByPlaceholderText('type here'), 'submitEditing'); + }); + + it('pressing Cancel dismisses the dialog without invoking the confirm action', async () => { + const onConfirm = jest.fn(); + + function CancelTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.alert({ + title: 'Discard changes?', + buttons: [{ text: 'Cancel' }, { text: 'Discard', onPress: onConfirm }], + }), + ); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + expect(screen.getByText('Discard changes?')).toBeOnTheScreen(); + + await user.press(screen.getByText('Cancel')); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(screen.queryByText('Discard changes?')).toBeNull(); + }); + + it('toast() auto-dismisses after its duration and announces via aria-live without a Modal', async () => { + function ToastTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => dialog.toast('Saved')); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + + const toastText = screen.getByText('Saved'); + expect(toastText).toBeOnTheScreen(); + expect(toastText).toHaveProp('accessibilityLiveRegion', 'polite'); + + // A toast must not steal focus or block the rest of the screen — the + // trigger stays pressable while the toast is showing. + await user.press(screen.getByTestId('trigger')); + expect(screen.getByText('Saved')).toBeOnTheScreen(); + + act(() => { + jest.advanceTimersByTime(4000); + }); + + await waitFor(() => { + expect(screen.queryByText('Saved')).toBeNull(); + }); + }); + + it('repeating the same toast message resets the dismiss timer', async () => { + function ToastTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => dialog.toast('Saved')); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + expect(screen.getByText('Saved')).toBeOnTheScreen(); + + // ~3s in, fire the identical message again — the 4s timer must restart. + act(() => { + jest.advanceTimersByTime(3000); + }); + await user.press(screen.getByTestId('trigger')); + + // 2s after the second fire (5s after the first): still visible. + act(() => { + jest.advanceTimersByTime(2000); + }); + expect(screen.getByText('Saved')).toBeOnTheScreen(); + + // And it still auto-dismisses 4s after the second fire. + act(() => { + jest.advanceTimersByTime(2100); + }); + await waitFor(() => { + expect(screen.queryByText('Saved')).toBeNull(); + }); + }); + + it('toast() renders its optional action and dismisses when the action fires', async () => { + const onPress = jest.fn(); + function ToastTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => dialog.toast('Photo removed', { label: 'Undo', onPress })); + } + + renderWithProviders(, { withDialog: true }); + + await user.press(screen.getByTestId('trigger')); + expect(screen.getByText('Photo removed')).toBeOnTheScreen(); + + // The Inverse-Pair Rule, asserted on the paint rather than the class: the + // action label must carry the same ink as the message it sits beside. The + // button variant's own foreground assumes a same-polarity surface, and + // letting it through is the failure that made ActiveStreamBanner invisible. + const messageColor = StyleSheet.flatten(screen.getByText('Photo removed').props.style)?.color; + const actionColor = StyleSheet.flatten(screen.getByText('Undo').props.style)?.color; + expect(actionColor).toBe(messageColor); + expect(actionColor).toBeTruthy(); + + await user.press(screen.getByText('Undo')); + + expect(onPress).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(screen.queryByText('Photo removed')).toBeNull(); + }); + }); + + // A toast with a control gets longer than the 4s plain one: the reader has to + // notice it, read it and reach the button before it goes. + it('a toast with an action outlives the plain 4s dismiss', async () => { + function ToastTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => + dialog.toast('Photo removed', { label: 'Undo', onPress: () => {} }), + ); + } + + renderWithProviders(, { withDialog: true }); + await user.press(screen.getByTestId('trigger')); + + act(() => { + jest.advanceTimersByTime(4100); + }); + expect(screen.getByText('Undo')).toBeOnTheScreen(); + + act(() => { + jest.advanceTimersByTime(4000); + }); + await waitFor(() => { + expect(screen.queryByText('Photo removed')).toBeNull(); + }); + }); + + it('toast() announces on iOS, where accessibilityLiveRegion does nothing', async () => { + const announce = jest + .spyOn(AccessibilityInfo, 'announceForAccessibility') + .mockImplementation(() => {}); + function ToastTest() { + const dialog = useDialog(); + return renderAlertTrigger(() => dialog.toast('Saved')); + } + + renderWithProviders(, { withDialog: true }); + await user.press(screen.getByTestId('trigger')); + + expect(announce).toHaveBeenCalledWith('Saved'); + announce.mockRestore(); + }); +}); diff --git a/app/src/components/base/__tests__/ErrorState.test.tsx b/app/src/components/base/__tests__/ErrorState.test.tsx new file mode 100644 index 000000000..db3d059f4 --- /dev/null +++ b/app/src/components/base/__tests__/ErrorState.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, screen } from '@testing-library/react-native'; +import { ErrorState } from '@/components/base/ErrorState'; +import { renderWithProviders } from '@/test-utils'; + +test('renders title, message, and custom action label', () => { + const onRetry = jest.fn(); + renderWithProviders( + , + ); + expect(screen.getByText('Product not found')).toBeTruthy(); + expect(screen.getByText('It may have been removed.')).toBeTruthy(); + fireEvent.press(screen.getByText('Back to products')); + expect(onRetry).toHaveBeenCalled(); +}); + +test('never de-emphasizes the error message', () => { + renderWithProviders(); + const className = screen.getByText('Something went wrong.').props.className as string; + expect(className).not.toEqual(expect.stringContaining('opacity')); +}); diff --git a/app/src/components/base/__tests__/Fab.test.tsx b/app/src/components/base/__tests__/Fab.test.tsx new file mode 100644 index 000000000..bca2fb02b --- /dev/null +++ b/app/src/components/base/__tests__/Fab.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { StyleSheet } from 'react-native'; +import { Fab } from '@/components/base/Fab'; +import { MIN_TAP_TARGET, radius } from '@/constants'; +import { getAppTheme } from '@/theme'; + +describe('Fab', () => { + it('renders the label when extended', () => { + render( + , + ); + expect(screen.getByText('New product')).toBeOnTheScreen(); + }); + + it('omits the label when collapsed', () => { + render( + , + ); + expect(screen.queryByText('New product')).toBeNull(); + }); + + it('fires onPress', () => { + const onPress = jest.fn(); + render(); + fireEvent.press(screen.getByRole('button')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('blocks onPress when disabled', () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByRole('button')); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('renders nothing when not visible', () => { + render( + , + ); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('meets the 44px a11y tap-target floor', () => { + render(); + // Resolved through the state callback — the floor lives in the style + // function, never in a className (mixing the two drops the function). + const style = StyleSheet.flatten(screen.getByRole('button').props.style); + expect(style.minWidth).toBe(MIN_TAP_TARGET); + expect(style.minHeight).toBe(MIN_TAP_TARGET); + expect(style.minWidth).toBe(44); + expect(style.minHeight).toBe(44); + }); + + // DESIGN.md "Form language — Flat & Sharp": the FAB is a floating surface, so + // it takes the overlay radius (not the `full` pill radius, which is reserved + // for avatars/true pills) plus the single shared overlay elevation tier. + it('uses the overlay radius and the shared elevation tier', () => { + render(); + const button = screen.getByRole('button'); + const style = StyleSheet.flatten(button.props.style); + expect(style.borderRadius).toBe(radius.overlay); + const overlay = getAppTheme('light').tokens.elevation.overlay; + expect(style.shadowRadius).toBe(overlay.shadowRadius); + expect(style.shadowOpacity).toBe(overlay.shadowOpacity); + expect(style.elevation).toBe(overlay.elevation); + }); + + it('forwards arbitrary accessibility props (accessibilityHint)', () => { + render( + , + ); + expect(screen.getByRole('button').props.accessibilityHint).toBe('Creates a new product'); + }); + + it('exposes the accessibility label', () => { + render( + , + ); + expect(screen.getByLabelText('Create new product')).toBeOnTheScreen(); + }); +}); diff --git a/app/src/components/base/__tests__/FilterSelectionModal.test.tsx b/app/src/components/base/__tests__/FilterSelectionModal.test.tsx new file mode 100644 index 000000000..7a5a8ca67 --- /dev/null +++ b/app/src/components/base/__tests__/FilterSelectionModal.test.tsx @@ -0,0 +1,185 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import type React from 'react'; +import FilterSelectionModal, { + SingleSelectFilterModal, +} from '@/components/base/FilterSelectionModal'; +import { renderWithProviders, setupUser } from '@/test-utils/index'; + +type MultiProps = React.ComponentProps; +type SingleProps = React.ComponentProps; + +// Every test renders the modal open with a controlled, empty search; only the +// title, items, labels and the handler under assertion vary. +const renderMulti = (props: Partial) => + renderWithProviders( + , + { withDialog: true }, + ); + +const renderSingle = (props: Partial) => + renderWithProviders( + , + { withDialog: true }, + ); + +describe('FilterSelectionModal', () => { + const user = setupUser(); + + it('shows a loading indicator while items are being fetched', () => { + renderMulti({ title: 'Pick one', items: [], isLoading: true }); + + expect(screen.getByRole('progressbar')).toBeOnTheScreen(); + }); + + it('shows a chip by its label but still selects by its value', async () => { + // Product types imported from CPV store the code as the name and the label + // in `description`; filtering matches the stored name, so the value the + // caller gets back has to stay the code. + const onSelectionChange = jest.fn(); + renderMulti({ + title: 'Filter by product type', + items: ['CPV: 302132'], + labels: { 'CPV: 302132': 'Tablet computer' }, + onSelectionChange, + }); + + expect(screen.queryByText('CPV: 302132')).not.toBeOnTheScreen(); + await user.press(screen.getByText('Tablet computer')); + + expect(onSelectionChange).toHaveBeenCalledWith(['CPV: 302132']); + }); + + it('labels a selected value that is missing from the current results', () => { + renderMulti({ + title: 'Filter by product type', + items: ['Laptop'], + labels: { 'CPV: 302132': 'Tablet computer' }, + selectedValues: ['CPV: 302132'], + searchQuery: 'lap', + }); + + expect(screen.getByText('Tablet computer')).toBeOnTheScreen(); + expect(screen.queryByText('CPV: 302132')).not.toBeOnTheScreen(); + }); + + it('falls back to the raw value when no label is supplied', () => { + renderMulti({ title: 'Filter by brand', items: ['Dell'] }); + + expect(screen.getByText('Dell')).toBeOnTheScreen(); + }); + + it('shows an empty state when there are no results', () => { + renderMulti({ title: 'Pick one', items: [] }); + + expect(screen.getByText('No results')).toBeOnTheScreen(); + }); + + it('toggles selections', async () => { + const onSelectionChange = jest.fn(); + + renderMulti({ + title: 'Pick one', + items: ['alpha', 'beta'], + selectedValues: ['alpha'], + onSelectionChange, + }); + + await user.press(screen.getByText('beta')); + + expect(onSelectionChange).toHaveBeenCalledWith(['alpha', 'beta']); + }); + + it('does not show an add-new chip even when search yields no matches', () => { + renderMulti({ title: 'Filter by brand', items: [], searchQuery: 'BrandNew' }); + + expect(screen.getByText('No results')).toBeOnTheScreen(); + }); +}); + +describe('SingleSelectFilterModal', () => { + const user = setupUser(); + + it('shows add-new chip instead of "No results" when search yields no matches', () => { + // Regression: previously the add-new chip was hidden inside the visibleItems.length > 0 + // branch, so typing a brand not in the list showed "No results" with no way to add it. + renderSingle({ title: 'Select brand', items: [], searchQuery: 'BrandNew' }); + + expect(screen.queryByText('No results')).toBeNull(); + expect(screen.getByText('BrandNew')).toBeOnTheScreen(); + }); + + it('calls onValueChange and onDismiss when add-new chip is pressed with no existing items', async () => { + const onValueChange = jest.fn(); + const onDismiss = jest.fn(); + + renderSingle({ + title: 'Select brand', + items: [], + searchQuery: 'BrandNew', + onValueChange, + onDismiss, + }); + + await user.press(screen.getByText('BrandNew')); + + expect(onValueChange).toHaveBeenCalledWith('BrandNew'); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('creates a new value and closes', async () => { + const onValueChange = jest.fn(); + const onDismiss = jest.fn(); + + renderSingle({ + title: 'Pick one', + items: ['alpha'], + searchQuery: 'gamma', + onValueChange, + onDismiss, + }); + + await user.press(screen.getByText('gamma')); + + expect(onValueChange).toHaveBeenCalledWith('gamma'); + expect(onDismiss).toHaveBeenCalled(); + expect(screen.getByText('Cancel')).toBeOnTheScreen(); + }); + + it('selecting an existing item confirms and closes immediately', async () => { + const onValueChange = jest.fn(); + const onDismiss = jest.fn(); + + renderSingle({ + title: 'Select brand', + items: ['alpha', 'beta'], + onValueChange, + onDismiss, + }); + + await user.press(screen.getByText('beta')); + + expect(onValueChange).toHaveBeenCalledWith('beta'); + expect(onDismiss).toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/base/__tests__/FormField.test.tsx b/app/src/components/base/__tests__/FormField.test.tsx new file mode 100644 index 000000000..a39e703f1 --- /dev/null +++ b/app/src/components/base/__tests__/FormField.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import { AccessibilityInfo, Platform } from 'react-native'; +import { FormFieldError } from '@/components/base/FormField'; +import { renderWithProviders } from '@/test-utils/render'; + +jest.mock('@/context/themeMode', () => ({ + useEffectiveColorScheme: jest.fn(() => 'light'), +})); + +describe('FormFieldError', () => { + it('renders nothing when there is no message', () => { + renderWithProviders(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('renders the message with a matching nativeID for describedBy linkage', () => { + renderWithProviders(); + const errorText = screen.getByText('Required'); + expect(errorText).toBeOnTheScreen(); + expect(errorText.props.nativeID).toBe('field-error'); + }); + + it('keeps the alert role and fades rather than popping in', () => { + renderWithProviders(); + // The role has to survive the move to Animated.Text — it is what announces + // the error, and the fade is worthless if it costs the announcement. + expect(screen.getByRole('alert')).toBeOnTheScreen(); + const errorText = screen.getByText('Required'); + expect(errorText.props.entering).toBeDefined(); + expect(errorText.props.exiting).toBeDefined(); + }); + + // accessibilityRole="alert" alone announces nothing on Android or the web + // export — the node has to be a live region. A validation error that appears + // after the fact is otherwise silent for anyone not looking at the field. + it('marks the error as a polite live region', () => { + renderWithProviders(); + expect(screen.getByText('Required').props.accessibilityLiveRegion).toBe('polite'); + }); + + it('announces the error explicitly on iOS, where VoiceOver ignores live regions', () => { + const announce = jest + .spyOn(AccessibilityInfo, 'announceForAccessibility') + .mockImplementation(() => {}); + const platform = jest.replaceProperty(Platform, 'OS', 'ios'); + + const { rerender } = renderWithProviders( + , + ); + // Ignore whatever the shared render providers announce on mount; this + // asserts on the transition into an error. + announce.mockClear(); + + rerender(); + expect(announce).toHaveBeenCalledWith('Required'); + + announce.mockClear(); + rerender(); + expect(announce).not.toHaveBeenCalled(); + + platform.restore(); + announce.mockRestore(); + }); +}); diff --git a/app/src/components/base/__tests__/Icon.test.tsx b/app/src/components/base/__tests__/Icon.test.tsx new file mode 100644 index 000000000..f9aa84f74 --- /dev/null +++ b/app/src/components/base/__tests__/Icon.test.tsx @@ -0,0 +1,42 @@ +import { render } from '@testing-library/react-native'; +import { Svg } from 'react-native-svg'; +import { Icon } from '@/components/base/Icon'; + +test('renders a mapped lucide glyph with tokenised size', () => { + const { UNSAFE_root } = render(); + const svg = UNSAFE_root.findByType(Svg); + expect(svg.props.width).toBe(20); + expect(svg.props.height).toBe(20); +}); + +test('defaults to md size and 2px stroke width', () => { + const { UNSAFE_root } = render(); + const svg = UNSAFE_root.findByType(Svg); + expect(svg.props.width).toBe(20); + expect(svg.props.strokeWidth).toBe(2); +}); + +test('accepts a raw numeric size and custom stroke width', () => { + const { UNSAFE_root } = render(); + const svg = UNSAFE_root.findByType(Svg); + expect(svg.props.width).toBe(32); + expect(svg.props.strokeWidth).toBe(1.5); +}); + +test('passes the color prop straight through', () => { + const { UNSAFE_root } = render(); + const svg = UNSAFE_root.findByType(Svg); + expect(svg.props.stroke).toBe('#123456'); +}); + +test('unknown names fail typecheck (compile-time)', () => { + // @ts-expect-error not in the name map + render(); +}); + +test('renders a brand glyph as an Svg and respects a numeric size', () => { + const { UNSAFE_root } = render(); + const svg = UNSAFE_root.findByType(Svg); + expect(svg.props.width).toBe(32); + expect(svg.props.height).toBe(32); +}); diff --git a/app/src/components/base/__tests__/IconButton.test.tsx b/app/src/components/base/__tests__/IconButton.test.tsx new file mode 100644 index 000000000..efa4004dd --- /dev/null +++ b/app/src/components/base/__tests__/IconButton.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { StyleSheet } from 'react-native'; +import { IconButton } from '@/components/base/IconButton'; +import { MIN_TAP_TARGET, radius } from '@/constants'; + +describe('IconButton', () => { + it('fires onPress', () => { + const onPress = jest.fn(); + render(); + fireEvent.press(screen.getByRole('button')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('blocks onPress while loading', () => { + const onPress = jest.fn(); + render(); + fireEvent.press(screen.getByRole('button')); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('shows a spinner instead of the icon while loading', () => { + render( + , + ); + expect(screen.queryByTestId('icon-refresh')).toBeNull(); + }); + + it('exposes the accessibility label', () => { + render(); + expect(screen.getByLabelText('Edit name')).toBeOnTheScreen(); + }); + + it('meets the 44px a11y tap-target floor', () => { + render(); + // Resolved through the state callback — the floor lives in the style + // function, never in a className (mixing the two drops the function). + const style = StyleSheet.flatten(screen.getByRole('button').props.style); + expect(style.minWidth).toBe(MIN_TAP_TARGET); + expect(style.minHeight).toBe(MIN_TAP_TARGET); + expect(style.minWidth).toBe(44); + expect(style.minHeight).toBe(44); + }); + + it('forwards arbitrary accessibility props (accessibilityHint)', () => { + render( + , + ); + expect(screen.getByRole('button').props.accessibilityHint).toBe('Opens the name editor'); + }); + + it('uses the control radius, not a bespoke circle', () => { + render(); + const style = StyleSheet.flatten(screen.getByRole('button').props.style); + expect(style.borderRadius).toBe(radius.control); + }); +}); diff --git a/app/src/components/base/__tests__/InfoTooltip.test.tsx b/app/src/components/base/__tests__/InfoTooltip.test.tsx new file mode 100644 index 000000000..128651b4e --- /dev/null +++ b/app/src/components/base/__tests__/InfoTooltip.test.tsx @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, screen, waitFor } from '@testing-library/react-native'; +import { InfoTooltip } from '@/components/base/InfoTooltip'; +import { mockPlatform, renderWithProviders, restorePlatform, setupUser } from '@/test-utils/index'; + +describe('InfoTooltip component', () => { + const title = 'Test Tooltip Info'; + const user = setupUser(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + restorePlatform(); + }); + + it('renders correctly on standard platforms', () => { + renderWithProviders(); + expect(screen.getByTestId('info-icon')).toBeOnTheScreen(); + }); + + it('handles mobile web path', async () => { + mockPlatform('web'); + + const originalUserAgent = global.navigator.userAgent; + Object.defineProperty(global.navigator, 'userAgent', { + value: 'iPhone', + configurable: true, + }); + + renderWithProviders(); + + const pressable = screen.getByTestId('info-pressable'); + // 20px glyph + spacing.sm padding (36px) + 4px hitSlop/side = 44px a11y floor. + expect(pressable.props.hitSlop).toBe(4); + await user.press(pressable); + + expect(screen.getByText(title)).toBeOnTheScreen(); + + act(() => { + jest.advanceTimersByTime(1500); + }); + + await waitFor(() => { + expect(screen.queryByText(title)).toBeNull(); + }); + + Object.defineProperty(global.navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + }); + + it('clears timer on unmount', () => { + const { unmount } = renderWithProviders(); + unmount(); + }); +}); diff --git a/app/src/components/base/__tests__/Menu.test.tsx b/app/src/components/base/__tests__/Menu.test.tsx new file mode 100644 index 000000000..86111cd6b --- /dev/null +++ b/app/src/components/base/__tests__/Menu.test.tsx @@ -0,0 +1,99 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { Menu } from '@/components/base/Menu'; +import { getMenuPosition } from '@/components/base/menuPosition'; + +describe('getMenuPosition', () => { + const anchor = { anchorY: 100, anchorWidth: 40, anchorHeight: 40 }; + + it('pins to the anchor’s left edge when there is room to the right', () => { + expect(getMenuPosition({ ...anchor, anchorX: 20, windowWidth: 1440 })).toEqual({ + top: 144, + left: 20, + }); + }); + + it('flips to the anchor’s right edge when the menu would leave the viewport', () => { + // A sort button at the right of a 1024 toolbar: left-anchoring would put + // the menu's right edge at 964 + 180 = 1144, i.e. 120px off-screen. + const position = getMenuPosition({ ...anchor, anchorX: 964, windowWidth: 1024 }); + expect(position).toEqual({ top: 144, right: 20 }); + }); + + it('keeps the flipped menu inside the viewport', () => { + const windowWidth = 1024; + const position = getMenuPosition({ ...anchor, anchorX: 964, windowWidth }); + // right + minWidth must still fit, otherwise it spills out the other side. + const right = 'right' in position ? position.right : 0; + expect(windowWidth - right - 180).toBeGreaterThanOrEqual(0); + }); + + it('never pins hard against the left edge', () => { + expect(getMenuPosition({ ...anchor, anchorX: 0, windowWidth: 1440 })).toEqual({ + top: 144, + left: 8, + }); + }); +}); + +describe('Menu', () => { + it('always renders the anchor', () => { + render( + Sort}> + + , + ); + expect(screen.getByText('Sort')).toBeOnTheScreen(); + }); + + it('hides the items when not visible', () => { + render( + Sort}> + + , + ); + expect(screen.queryByText('A-Z')).toBeNull(); + }); + + it('shows the items when visible', () => { + render( + Sort}> + + , + ); + expect(screen.getByText('A-Z')).toBeOnTheScreen(); + }); + + it('exposes the popover container with a menu role', () => { + render( + Sort}> + + , + ); + expect(screen.getByRole('menu')).toBeOnTheScreen(); + }); + + it('fires onPress and does not dismiss via the item press itself', () => { + const onPress = jest.fn(); + const onDismiss = jest.fn(); + render( + Sort}> + + , + ); + fireEvent.press(screen.getByText('A-Z')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('dismisses when the backdrop is pressed', () => { + const onDismiss = jest.fn(); + render( + Sort}> + + , + ); + fireEvent.press(screen.getByLabelText('Dismiss menu')); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/base/__tests__/OfflineBanner.test.tsx b/app/src/components/base/__tests__/OfflineBanner.test.tsx new file mode 100644 index 000000000..ccc6ede68 --- /dev/null +++ b/app/src/components/base/__tests__/OfflineBanner.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from '@jest/globals'; +import { onlineManager } from '@tanstack/react-query'; +import { act, screen } from '@testing-library/react-native'; +import { OfflineBanner } from '@/components/base/OfflineBanner'; +import { renderWithProviders } from '@/test-utils/render'; + +const OFFLINE_TEXT = /offline/i; + +describe('OfflineBanner', () => { + afterEach(() => { + act(() => onlineManager.setOnline(true)); + }); + + it('renders nothing while online', () => { + renderWithProviders(); + expect(screen.queryByText(OFFLINE_TEXT)).toBeNull(); + }); + + it('shows a polite live-region message when offline', () => { + renderWithProviders(); + act(() => onlineManager.setOnline(false)); + const message = screen.getByText(OFFLINE_TEXT); + expect(message).toBeOnTheScreen(); + expect(message.props.accessibilityLiveRegion).toBe('polite'); + }); + + it('hides again once back online', () => { + renderWithProviders(); + act(() => onlineManager.setOnline(false)); + expect(screen.getByText(OFFLINE_TEXT)).toBeOnTheScreen(); + act(() => onlineManager.setOnline(true)); + expect(screen.queryByText(OFFLINE_TEXT)).toBeNull(); + }); +}); diff --git a/app/src/components/base/__tests__/PageContainer.test.tsx b/app/src/components/base/__tests__/PageContainer.test.tsx new file mode 100644 index 000000000..ddcca1c32 --- /dev/null +++ b/app/src/components/base/__tests__/PageContainer.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { PageContainer } from '@/components/base/PageContainer'; + +test('renders children', () => { + render( + + content + , + ); + expect(screen.getByText('content')).toBeOnTheScreen(); +}); + +test('fullBleed renders children without the width-constrained wrapper', () => { + render( + + hero + , + ); + expect(screen.getByText('hero')).toBeOnTheScreen(); + expect(screen.queryByTestId('page-container-constrained')).toBeNull(); +}); + +test('phoneFullBleed keeps the constrained (centered, max-width) wrapper', () => { + // Distinct from fullBleed: it only drops the phone gutter, so it must still + // render the width-constrained wrapper (desktop centering is preserved). + render( + + list + , + ); + expect(screen.getByText('list')).toBeOnTheScreen(); + expect(screen.getByTestId('page-container-constrained')).toBeOnTheScreen(); +}); diff --git a/app/src/components/base/__tests__/Searchbar.test.tsx b/app/src/components/base/__tests__/Searchbar.test.tsx new file mode 100644 index 000000000..9db0c9db2 --- /dev/null +++ b/app/src/components/base/__tests__/Searchbar.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { Searchbar } from '@/components/base/Searchbar'; + +describe('Searchbar', () => { + it('reports text changes', () => { + const onChangeText = jest.fn(); + render(); + fireEvent.changeText(screen.getByPlaceholderText('Search products'), 'drill'); + expect(onChangeText).toHaveBeenCalledWith('drill'); + }); + + it('shows a clear button when there is a query, and clears it on press', () => { + const onChangeText = jest.fn(); + render(); + fireEvent.press(screen.getByLabelText('Clear search')); + expect(onChangeText).toHaveBeenCalledWith(''); + }); + + it('gives the clear button a 44px tap target via hitSlop', () => { + render(); + // 20px glyph + 12px hitSlop/side = 44px a11y floor. + expect(screen.getByLabelText('Clear search').props.hitSlop).toBe(12); + }); + + it('omits the clear button when the query is empty', () => { + render(); + expect(screen.queryByLabelText('Clear search')).toBeNull(); + }); + + it('shows a loading spinner instead of the clear button while loading', () => { + render(); + expect(screen.queryByLabelText('Clear search')).toBeNull(); + }); +}); diff --git a/app/src/components/base/__tests__/Section.test.tsx b/app/src/components/base/__tests__/Section.test.tsx new file mode 100644 index 000000000..fcd40ba95 --- /dev/null +++ b/app/src/components/base/__tests__/Section.test.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { Section } from '@/components/base/Section'; +import { SectionNavContext } from '@/components/base/SectionNavContext'; + +const child = section body; + +test('renders title and children when not empty', () => { + render( +
+ {child} +
, + ); + expect(screen.getByText('Physical properties')).toBeOnTheScreen(); + expect(screen.getByText('section body')).toBeOnTheScreen(); +}); + +test('view mode + empty renders nothing', () => { + render( +
+ {child} +
, + ); + expect(screen.queryByText('Circularity')).toBeNull(); + expect(screen.queryByText('section body')).toBeNull(); +}); + +test('edit mode + empty shows the add row, expands in place on press', () => { + render( +
+ {child} +
, + ); + expect(screen.queryByText('section body')).toBeNull(); + const addRow = screen.getByRole('button', { name: 'Add circularity notes' }); + expect(addRow.props.accessibilityState).toMatchObject({ expanded: false }); + fireEvent.press(addRow); + expect(screen.getByText('section body')).toBeOnTheScreen(); +}); + +test('renders titleSuffix and tooltip beside the title', () => { + render( +
+ {child} +
, + ); + expect(screen.getByText('Components')).toBeOnTheScreen(); + expect(screen.getByText('(3)')).toBeOnTheScreen(); +}); + +test('unregisters from the nav registry when it collapses to empty in view mode', () => { + const registerSection = jest.fn(); + const unregisterSection = jest.fn(); + const nav = { + registerSection, + unregisterSection, + scrollTo: jest.fn(), + activeKey: 'overview' as const, + }; + + const { rerender } = render( + +
+ {child} +
+
, + ); + expect(screen.getByText('section body')).toBeOnTheScreen(); + expect(unregisterSection).not.toHaveBeenCalled(); + + // Context identity churns on every scroll-spy tick (activeKey). A section + // that stays visible must NOT be unregistered by that churn — onLayout never + // re-fires, so an unregister here would permanently orphan the section. + rerender( + +
+ {child} +
+
, + ); + expect(unregisterSection).not.toHaveBeenCalled(); + + rerender( + +
+ {child} +
+
, + ); + + expect(unregisterSection).toHaveBeenCalledWith('circularity'); +}); diff --git a/app/src/components/base/__tests__/SectionNavLayout.test.tsx b/app/src/components/base/__tests__/SectionNavLayout.test.tsx new file mode 100644 index 000000000..2c379b8ac --- /dev/null +++ b/app/src/components/base/__tests__/SectionNavLayout.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { SectionNavLayout } from '@/components/base/SectionNavLayout'; +import { mockPlatform, restorePlatform } from '@/test-utils/index'; + +const sections = [ + { key: 'overview', label: 'Overview' }, + { key: 'components', label: 'Components' }, +] as const; + +afterEach(() => { + restorePlatform(); +}); + +test('fires onPressSection with the section key', () => { + const onPressSection = jest.fn(); + render( + + {null} + , + ); + fireEvent.press(screen.getByText('Components')); + expect(onPressSection).toHaveBeenCalledWith('components'); +}); + +test('marks the active item for accessibility', () => { + render( + + {null} + , + ); + expect(screen.getByText('Components').parent).toBeTruthy(); + expect(screen.getByLabelText('Components, current section')).toBeOnTheScreen(); +}); + +test('has web hover, cursor, and focus-visible affordances', () => { + mockPlatform('web'); + render( + + {null} + , + ); + const className = screen.getByLabelText('Overview, current section').props.className; + expect(className).toEqual(expect.stringContaining('cursor-pointer')); + expect(className).toEqual(expect.stringContaining('hover:')); + expect(className).toEqual(expect.stringContaining('focus-visible:')); +}); diff --git a/app/src/components/base/__tests__/SignedOutState.test.tsx b/app/src/components/base/__tests__/SignedOutState.test.tsx new file mode 100644 index 000000000..1e797c1e0 --- /dev/null +++ b/app/src/components/base/__tests__/SignedOutState.test.tsx @@ -0,0 +1,17 @@ +import { fireEvent, screen } from '@testing-library/react-native'; +import { useRouter } from 'expo-router'; +import { SignedOutState } from '@/components/base/SignedOutState'; +import { renderWithProviders } from '@/test-utils/index'; + +test('renders the default message and routes to login on sign-in press', () => { + renderWithProviders(); + expect(screen.getByText('Sign in to use this part of Relab.')).toBeOnTheScreen(); + + fireEvent.press(screen.getByText('Sign in')); + expect(useRouter().replace).toHaveBeenCalledWith('/login'); +}); + +test('renders a custom message', () => { + renderWithProviders(); + expect(screen.getByText('Sign in to browse categories.')).toBeOnTheScreen(); +}); diff --git a/app/src/components/base/__tests__/Skeleton.test.tsx b/app/src/components/base/__tests__/Skeleton.test.tsx new file mode 100644 index 000000000..a6a2e2c59 --- /dev/null +++ b/app/src/components/base/__tests__/Skeleton.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render } from '@testing-library/react-native'; +// NOTE: jest.spyOn needs the module's namespace object to patch `withRepeat` in place; +// a named import would just be a local binding and can't be spied on. +// biome-ignore lint/performance/noNamespaceImport: required for jest.spyOn to patch this export +import * as Reanimated from 'react-native-reanimated'; +import { Skeleton } from '@/components/base/Skeleton'; + +describe('Skeleton', () => { + it('gates the pulse loop behind the OS reduce-motion setting', () => { + const withRepeatSpy = jest.spyOn(Reanimated, 'withRepeat'); + + render(); + + expect(withRepeatSpy).toHaveBeenCalledWith( + expect.anything(), + -1, + false, + undefined, + Reanimated.ReduceMotion.System, + ); + + withRepeatSpy.mockRestore(); + }); +}); diff --git a/app/src/components/base/__tests__/StaticBackground.test.tsx b/app/src/components/base/__tests__/StaticBackground.test.tsx new file mode 100644 index 000000000..88f585d8b --- /dev/null +++ b/app/src/components/base/__tests__/StaticBackground.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen } from '@testing-library/react-native'; +import { StaticBackground } from '@/components/base/StaticBackground'; +import { mockPlatform, restorePlatform } from '@/test-utils/index'; + +describe('StaticBackground', () => { + afterEach(restorePlatform); + + // The background is wrapped in an aria-hidden View (decorative — hidden from + // screen readers and axe), so queries must opt into hidden elements. + it('renders the background image on native platforms', () => { + mockPlatform('ios'); + render(); + expect(screen.getByTestId('expo-image-bg', { includeHiddenElements: true })).toBeOnTheScreen(); + }); + + it('renders the background image on web platform', () => { + mockPlatform('web'); + render(); + expect(screen.getByTestId('expo-image-bg', { includeHiddenElements: true })).toBeOnTheScreen(); + }); +}); diff --git a/app/src/components/base/__tests__/StatusPill.test.tsx b/app/src/components/base/__tests__/StatusPill.test.tsx new file mode 100644 index 000000000..a7112f6d0 --- /dev/null +++ b/app/src/components/base/__tests__/StatusPill.test.tsx @@ -0,0 +1,25 @@ +import { screen } from '@testing-library/react-native'; +import { StyleSheet } from 'react-native'; +import { StatusPill } from '@/components/base/StatusPill'; +import { renderWithProviders } from '@/test-utils/index'; + +test('renders the label', () => { + renderWithProviders(); + expect(screen.getByText('LIVE')).toBeOnTheScreen(); +}); + +test('solid variant fills with a background and bold text, no border', () => { + renderWithProviders(); + const pill = StyleSheet.flatten(screen.getByTestId('pill').props.style); + expect(pill.backgroundColor).toBeTruthy(); + expect(pill.borderWidth).toBeFalsy(); + expect((screen.getByText('LIVE').props.className as string).includes('font-bold')).toBe(true); +}); + +test('soft variant uses a tinted fill with a border and non-bold text', () => { + renderWithProviders(); + const pill = StyleSheet.flatten(screen.getByTestId('pill').props.style); + expect(pill.backgroundColor).toBeTruthy(); + expect(pill.borderWidth).toBe(1); + expect((screen.getByText('Warm').props.className as string).includes('font-bold')).toBe(false); +}); diff --git a/app/src/components/base/__tests__/TextInput.test.tsx b/app/src/components/base/__tests__/TextInput.test.tsx new file mode 100644 index 000000000..6d847bfb6 --- /dev/null +++ b/app/src/components/base/__tests__/TextInput.test.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import { TextInput } from '@/components/base/TextInput'; +import { radius } from '@/constants'; +import { useEffectiveColorScheme } from '@/context/themeMode'; +import { renderWithProviders } from '@/test-utils/index'; +import { getAppTheme } from '@/theme'; + +jest.mock('@/context/themeMode', () => ({ + useEffectiveColorScheme: jest.fn(() => 'light'), +})); + +describe('', () => { + it('renders placeholder correctly', () => { + renderWithProviders(); + expect(screen.getByPlaceholderText('Enter text')).toBeOnTheScreen(); + }); + + it('applies a danger border when errorOnEmpty is set and value is empty', () => { + renderWithProviders(); + const input = screen.getByTestId('test-input'); + expect(input).toHaveStyle({ + borderWidth: 1, + borderColor: getAppTheme('light').tokens.status.danger, + }); + }); + + it('applies a danger border when customValidation returns false', () => { + const failValidation = (val: string) => val.includes('valid'); + renderWithProviders( + , + ); + const input = screen.getByTestId('validation-input'); + expect(input).toHaveStyle({ + borderWidth: 1, + borderColor: getAppTheme('light').tokens.status.danger, + }); + }); + + it('applies default text color when there is no error', () => { + renderWithProviders(); + const input = screen.getByTestId('normal-input'); + expect(input).toHaveStyle({ color: getAppTheme('light').colors.onSurface }); + }); + + it('applies dark mode placeholder and text colors when there is no error', () => { + jest.mocked(useEffectiveColorScheme).mockReturnValue('dark'); + + renderWithProviders(); + + const input = screen.getByTestId('dark-input'); + expect(input).toHaveStyle({ color: getAppTheme('dark').colors.onSurface }); + expect(input).toHaveProp('placeholderTextColor', getAppTheme('dark').colors.onSurfaceVariant); + + jest.mocked(useEffectiveColorScheme).mockReturnValue('light'); + }); + + // DESIGN.md "Form language — Flat & Sharp": the primitive owns the control + // radius so call sites don't hardcode one; a caller style may still override. + it('applies the control radius by default', () => { + renderWithProviders(); + expect(screen.getByTestId('radius-default')).toHaveStyle({ borderRadius: radius.control }); + }); + + it('lets a caller style override the default radius', () => { + renderWithProviders( + , + ); + expect(screen.getByTestId('radius-override')).toHaveStyle({ borderRadius: 2 }); + }); + + it('does not treat a passing customValidation function as an error', () => { + const passValidation = (val: string) => val.length >= 3; + renderWithProviders( + , + ); + const input = screen.getByTestId('passing-validation'); + expect(input).not.toHaveStyle({ + borderColor: getAppTheme('light').tokens.status.danger, + }); + expect(input).toHaveStyle({ color: getAppTheme('light').colors.onSurface }); + }); +}); diff --git a/app/src/components/base/__tests__/TopNav.test.tsx b/app/src/components/base/__tests__/TopNav.test.tsx new file mode 100644 index 000000000..5c778185e --- /dev/null +++ b/app/src/components/base/__tests__/TopNav.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { usePathname, useRouter } from 'expo-router'; +import { TopNav } from '@/components/base/TopNav'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { mockPlatform, restorePlatform } from '@/test-utils/index'; + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(), + usePathname: jest.fn(), +})); + +jest.mock('@/hooks/useBreakpoint', () => ({ + useBreakpoint: jest.fn(), +})); + +jest.mock('@/context/auth', () => ({ + useAuth: jest.fn(() => ({ user: null })), +})); + +const mockUseRpiIntegration = jest.fn(); +jest.mock('@/features/cameras/rpi/useRpiIntegration', () => ({ + useRpiIntegration: () => mockUseRpiIntegration(), +})); + +const push = jest.fn(); +const navigate = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + mockPlatform('web'); + (useRouter as jest.Mock).mockReturnValue({ push, navigate }); + (usePathname as jest.Mock).mockReturnValue('/products'); + mockUseRpiIntegration.mockReturnValue({ enabled: true }); +}); + +afterEach(() => { + restorePlatform(); +}); + +test('renders nothing below lg', () => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: false }); + render(); + expect(screen.queryByText('Products')).toBeNull(); +}); + +test('renders destinations at lg', () => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: true }); + render(); + expect(screen.getByText('Products')).toBeOnTheScreen(); + expect(screen.getByText('Cameras')).toBeOnTheScreen(); +}); + +test('marks the active destination from the pathname', () => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: true }); + (usePathname as jest.Mock).mockReturnValue('/cameras'); + render(); + expect(screen.getByLabelText('Cameras, current page')).toBeOnTheScreen(); + expect(screen.getByLabelText('Products')).toBeOnTheScreen(); +}); + +test('marks the active destination on a detail route (prefix match)', () => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: true }); + (usePathname as jest.Mock).mockReturnValue('/products/123'); + render(); + expect(screen.getByLabelText('Products, current page')).toBeOnTheScreen(); + expect(screen.getByLabelText('Cameras')).toBeOnTheScreen(); +}); + +test('pressing a destination routes', () => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: true }); + render(); + fireEvent.press(screen.getByText('Cameras')); + expect(navigate).toHaveBeenCalledWith('/cameras'); +}); + +test('destinations have a web hover affordance', () => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: true }); + render(); + expect(screen.getByLabelText('Cameras').props.className).toEqual( + expect.stringContaining('hover:'), + ); +}); + +test('hides Cameras when rpi cameras are disabled', () => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: true }); + mockUseRpiIntegration.mockReturnValue({ enabled: false }); + render(); + expect(screen.getByText('Products')).toBeOnTheScreen(); + expect(screen.queryByText('Cameras')).toBeNull(); +}); + +test('shows Cameras when rpi cameras are enabled', () => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: true }); + mockUseRpiIntegration.mockReturnValue({ enabled: true }); + render(); + expect(screen.getByText('Products')).toBeOnTheScreen(); + expect(screen.getByText('Cameras')).toBeOnTheScreen(); +}); + +test.each([ + '/', + '/login', + '/onboarding', + '/new-account', + '/forgot-password', + '/reset-password', + '/mfa', + '/category-selection', +])('renders nothing on the chrome-free route %s, even at lg', (path) => { + (useBreakpoint as jest.Mock).mockReturnValue({ isMd: true, isLg: true }); + (usePathname as jest.Mock).mockReturnValue(path); + render(); + expect(screen.queryByText('Products')).toBeNull(); +}); diff --git a/app/src/components/base/__tests__/nativewind-smoke.test.tsx b/app/src/components/base/__tests__/nativewind-smoke.test.tsx new file mode 100644 index 000000000..f55d71e42 --- /dev/null +++ b/app/src/components/base/__tests__/nativewind-smoke.test.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; +import { View } from 'react-native'; + +test('className prop is accepted on core components', () => { + render(); + expect(screen.getByTestId('nw-smoke')).toBeOnTheScreen(); +}); diff --git a/app/src/components/base/appButtonVariants.ts b/app/src/components/base/appButtonVariants.ts new file mode 100644 index 000000000..9e09f0b37 --- /dev/null +++ b/app/src/components/base/appButtonVariants.ts @@ -0,0 +1,16 @@ +import type { AppColors } from '@/theme'; + +export type AppButtonVariant = 'primary' | 'tonal' | 'outline' | 'ghost' | 'destructive'; + +// Mirrors buttonTextVariants' per-variant text color (ui/button.tsx), so the +// loading spinner — and any caller composing its own icon inside an AppButton +// (see ProductDelete) — matches the label instead of a hand-picked color. +// Split out of AppButton.tsx: that file must export components only (Fast +// Refresh), and this constant isn't one. +export const VARIANT_FOREGROUND_COLOR: Record string> = { + primary: (colors) => colors.onPrimary, + tonal: (colors) => colors.primary, + outline: (colors) => colors.onSurface, + ghost: (colors) => colors.onSurface, + destructive: () => '#FFFFFF', // buttonTextVariants hard-codes text-white for destructive +}; diff --git a/app/src/components/base/dialogContext.ts b/app/src/components/base/dialogContext.ts new file mode 100644 index 000000000..c7855972e --- /dev/null +++ b/app/src/components/base/dialogContext.ts @@ -0,0 +1,56 @@ +import { createContext, type RefObject, useContext } from 'react'; +import type { View } from 'react-native'; + +export type DialogButton = { + text: string; + onPress?: (value?: string) => void; + disabled?: boolean | ((value: string) => boolean); + /** Visual role: destructive gets the filled destructive variant; Enter never triggers it. */ + style?: 'default' | 'cancel' | 'destructive'; +}; + +/** Optional single action on a toast — the undo affordance for a reversible change. */ +export type ToastAction = { + label: string; + onPress: () => void; +}; + +export type DialogOptions = { + title?: string; + message?: string; + buttons?: DialogButton[]; + input?: boolean; + defaultValue?: string; + placeholder?: string; + helperText?: string; + error?: boolean; + /** Return-focus target for native screen readers on close; see AppDialog's `triggerRef`. */ + triggerRef?: RefObject; +}; + +export type DialogContextType = { + alert: (options: DialogOptions) => void; + input: (options: DialogOptions) => void; + toast: (message: string, action?: ToastAction) => void; +}; + +/** + * The button Enter/return submits: the last button that is neither destructive nor + * cancel. A dialog whose only actions are destructive/cancel has no safe default, so + * this returns undefined rather than falling back to firing one of them. + */ +export function pickSubmitButton(buttons: DialogButton[]): DialogButton | undefined { + return [...buttons].reverse().find((b) => b.style !== 'destructive' && b.style !== 'cancel'); +} + +export const DialogContext = createContext(undefined); + +export function useDialog() { + const ctx = useContext(DialogContext); + if (!ctx) throw new Error('useDialog must be used within DialogProvider'); + return ctx; +} + +export function useOptionalDialog() { + return useContext(DialogContext); +} diff --git a/app/src/components/base/dialogStyles.ts b/app/src/components/base/dialogStyles.ts new file mode 100644 index 000000000..b546420be --- /dev/null +++ b/app/src/components/base/dialogStyles.ts @@ -0,0 +1,16 @@ +import type { TextStyle, ViewStyle } from 'react-native'; +import { spacing } from '@/constants'; + +/** Shared dialog title/actions styles — reused across the app's dialogs so the + * heading weight and the right-aligned action row stay consistent. Size comes + * from `AppText variant="title"`; only weight and spacing live here. */ +export const dialogTitleStyle: TextStyle = { + fontWeight: '600', + marginBottom: spacing.sm, +}; +export const dialogActionsStyle: ViewStyle = { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.xs, + marginTop: spacing.md, +}; diff --git a/app/src/components/base/menuPosition.ts b/app/src/components/base/menuPosition.ts new file mode 100644 index 000000000..d5bdeb4ac --- /dev/null +++ b/app/src/components/base/menuPosition.ts @@ -0,0 +1,40 @@ +import { spacing } from '@/constants'; + +/** Kept in sync with `Menu`'s `styles.content.minWidth` — the flip needs it. */ +export const MENU_MIN_WIDTH = 180; +/** Breathing room between the menu and the viewport edge. */ +export const EDGE_MARGIN = spacing.sm; + +export type MenuPosition = { top: number; left: number } | { top: number; right: number }; + +/** + * Where to pin an anchored menu, given its measured anchor. + * + * Left-anchored by default, so the menu grows rightwards from the anchor. For + * an anchor near the right edge that runs it off-screen, so flip to + * right-anchored and let it grow inwards instead. Flipping (rather than + * clamping `left`) stays correct for menus wider than the minimum, whose width + * isn't known until after layout. + * + * Lives apart from Menu.tsx because a component file can only export + * components without breaking fast refresh. + */ +export function getMenuPosition({ + anchorX, + anchorY, + anchorWidth, + anchorHeight, + windowWidth, +}: { + anchorX: number; + anchorY: number; + anchorWidth: number; + anchorHeight: number; + windowWidth: number; +}): MenuPosition { + const top = anchorY + anchorHeight + spacing.xs; + const overflowsRight = anchorX + MENU_MIN_WIDTH + EDGE_MARGIN > windowWidth; + return overflowsRight + ? { top, right: Math.max(EDGE_MARGIN, windowWidth - (anchorX + anchorWidth)) } + : { top, left: Math.max(EDGE_MARGIN, anchorX) }; +} diff --git a/app/src/components/base/ui/badge.tsx b/app/src/components/base/ui/badge.tsx new file mode 100644 index 000000000..1071a4f6e --- /dev/null +++ b/app/src/components/base/ui/badge.tsx @@ -0,0 +1,71 @@ +import { Slot } from '@rn-primitives/slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Platform, View } from 'react-native'; +import { TextClassContext } from '@/components/base/ui/text'; +import { cn } from '@/utils/cn'; + +const badgeVariants = cva( + cn( + 'border-border group shrink-0 flex-row items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5', + Platform.select({ + // No focus styling: Badge is a static label here (ComponentRow, HeroStats), + // never focusable. The upstream ring-based focus styling was dead, and a ring + // is the wrong mechanism on this codebase anyway — see WEB_FOCUS_RING. If a + // badge ever becomes interactive, compose WEB_FOCUS_RING rather than a ring. + web: 'aria-invalid:border-destructive w-fit whitespace-nowrap transition-[color,box-shadow] [&>svg]:pointer-events-none [&>svg]:size-3', + }), + ), + { + variants: { + variant: { + default: cn( + 'bg-primary border-transparent', + Platform.select({ web: '[a&]:hover:bg-primary/90' }), + ), + secondary: cn( + 'bg-secondary border-transparent', + Platform.select({ web: '[a&]:hover:bg-secondary/90' }), + ), + destructive: cn( + 'bg-destructive border-transparent', + Platform.select({ web: '[a&]:hover:bg-destructive/90' }), + ), + outline: Platform.select({ web: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground' }), + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const badgeTextVariants = cva('text-xs font-medium', { + variants: { + variant: { + default: 'text-primary-foreground', + secondary: 'text-secondary-foreground', + destructive: 'text-white', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +type BadgeProps = React.ComponentProps & + React.RefAttributes & { + asChild?: boolean; + } & VariantProps; + +function Badge({ className, variant, asChild, ...props }: BadgeProps) { + const Component = asChild ? Slot : View; + return ( + + + + ); +} + +export type { BadgeProps }; +export { Badge, badgeTextVariants, badgeVariants }; diff --git a/app/src/components/base/ui/button.tsx b/app/src/components/base/ui/button.tsx new file mode 100644 index 000000000..a60789551 --- /dev/null +++ b/app/src/components/base/ui/button.tsx @@ -0,0 +1,123 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { Platform, Pressable } from 'react-native'; +import { TextClassContext } from '@/components/base/ui/text'; +import { WEB_FOCUS_RING } from '@/constants'; +import { cn } from '@/utils/cn'; + +const buttonVariants = cva( + cn( + 'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none', + Platform.select({ + web: cn( + "cursor-pointer aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-[color,background-color,border-color,box-shadow,opacity] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + WEB_FOCUS_RING, + ), + }), + ), + { + variants: { + variant: { + // Pressed/hover use the real `primary-strong` brand shade, not alpha on + // primary: assets/brand.css has carried --relab-brand-primary-strong for + // web all along, and palette.json now defines primaryStrong so the app + // reads the same value. Parity is enforced by BRAND_PARITY in + // scripts/sync_brand_assets.py. + default: cn( + 'bg-primary active:bg-primary-strong', + Platform.select({ web: 'hover:bg-primary-strong' }), + ), + destructive: cn( + 'bg-destructive active:bg-destructive/90 dark:bg-destructive/60', + Platform.select({ + web: 'hover:bg-destructive/90', + }), + ), + outline: cn( + 'border-border bg-background active:bg-primary/10 dark:bg-input/30 dark:border-input border', + Platform.select({ + web: 'hover:bg-primary/10', + }), + ), + secondary: cn( + 'bg-secondary active:bg-secondary/80', + Platform.select({ web: 'hover:bg-secondary/80' }), + ), + // Soft-primary fill: low-emphasis-but-filled CTA (flat, no shadow). + tonal: cn( + 'bg-primary/12 active:bg-primary/20', + Platform.select({ web: 'hover:bg-primary/20' }), + ), + ghost: cn('active:bg-primary/10', Platform.select({ web: 'hover:bg-primary/10' })), + link: '', + }, + size: { + default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })), + sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })), + lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })), + icon: 'h-10 w-10 sm:h-9 sm:w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +const buttonTextVariants = cva( + cn( + 'text-foreground text-sm font-medium', + Platform.select({ web: 'pointer-events-none transition-colors' }), + ), + { + variants: { + variant: { + default: 'text-primary-foreground', + destructive: 'text-white', + outline: cn( + 'group-active:text-primary', + Platform.select({ web: 'group-hover:text-primary' }), + ), + secondary: 'text-secondary-foreground', + tonal: 'text-primary', + ghost: cn( + 'group-active:text-primary', + Platform.select({ web: 'group-hover:text-primary' }), + ), + link: cn( + 'text-primary group-active:underline', + Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' }), + ), + }, + size: { + default: '', + sm: '', + lg: '', + icon: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +type ButtonProps = React.ComponentProps & + React.RefAttributes & + VariantProps; + +function Button({ className, variant, size, ...props }: ButtonProps) { + return ( + + + + ); +} + +export type { ButtonProps }; +export { Button, buttonTextVariants, buttonVariants }; diff --git a/app/src/components/base/ui/input.tsx b/app/src/components/base/ui/input.tsx new file mode 100644 index 000000000..f849beb79 --- /dev/null +++ b/app/src/components/base/ui/input.tsx @@ -0,0 +1,41 @@ +import { Platform, TextInput } from 'react-native'; +import { WEB_FOCUS_RING } from '@/constants'; +import { cn } from '@/utils/cn'; + +function Input({ + className, + ...props +}: React.ComponentProps & React.RefAttributes) { + return ( + + ); +} + +export { Input }; diff --git a/app/src/components/base/ui/separator.tsx b/app/src/components/base/ui/separator.tsx new file mode 100644 index 000000000..895a8eee5 --- /dev/null +++ b/app/src/components/base/ui/separator.tsx @@ -0,0 +1,24 @@ +import * as SeparatorPrimitive from '@rn-primitives/separator'; +import { cn } from '@/utils/cn'; + +function Separator({ + className, + orientation = 'horizontal', + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/app/src/components/base/ui/switch.tsx b/app/src/components/base/ui/switch.tsx new file mode 100644 index 000000000..5ceb4eca5 --- /dev/null +++ b/app/src/components/base/ui/switch.tsx @@ -0,0 +1,38 @@ +import * as SwitchPrimitives from '@rn-primitives/switch'; +import { Platform } from 'react-native'; +import { WEB_FOCUS_RING } from '@/constants'; +import { cn } from '@/utils/cn'; + +function Switch({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { Switch }; diff --git a/app/src/components/base/ui/text.tsx b/app/src/components/base/ui/text.tsx new file mode 100644 index 000000000..b68ee84b2 --- /dev/null +++ b/app/src/components/base/ui/text.tsx @@ -0,0 +1,46 @@ +import { Slot } from '@rn-primitives/slot'; +import * as React from 'react'; +import { Platform, Text as RNText } from 'react-native'; +import { cn } from '@/utils/cn'; + +const TEXT_CLASS_NAME = cn( + 'text-foreground text-base', + Platform.select({ + web: 'select-text', + }), +); + +const TextClassContext = React.createContext(undefined); + +/** + * `maxFontSizeMultiplier` defaults to the same app-wide Dynamic Type cap (2x) + * that `AppText` applies. + * + * DESIGN.md states the cap is app-wide, and it was not: this vendored primitive + * renders user-facing copy in HeroStats, ComponentRow, GoLiveDialog, + * ProductDelete and every AppButton label, and carried no cap at all, so those + * strings scaled without limit and broke fixed layouts. Defaulting it here fixes + * every consumer at once; a caller that genuinely wants unbounded scaling can + * still pass its own value. + */ +function Text({ + className, + asChild = false, + maxFontSizeMultiplier = 2, + ...props +}: React.ComponentProps & + React.RefAttributes & { + asChild?: boolean; + }) { + const textClass = React.useContext(TextClassContext); + const Component = asChild ? Slot : RNText; + return ( + + ); +} + +export { Text, TextClassContext }; diff --git a/app/src/components/base/ui/toggle-group.tsx b/app/src/components/base/ui/toggle-group.tsx new file mode 100644 index 000000000..1433542e0 --- /dev/null +++ b/app/src/components/base/ui/toggle-group.tsx @@ -0,0 +1,96 @@ +import * as ToggleGroupPrimitive from '@rn-primitives/toggle-group'; +import type { VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { Platform } from 'react-native'; +import { TextClassContext } from '@/components/base/ui/text'; +import { toggleVariants } from '@/components/base/ui/toggle'; +import { cn } from '@/utils/cn'; + +const ToggleGroupContext = React.createContext | null>(null); + +function ToggleGroup({ + className, + variant, + size, + children, + ...props +}: React.ComponentProps & VariantProps) { + return ( + + + {children} + + + ); +} + +function useToggleGroupContext() { + const context = React.useContext(ToggleGroupContext); + if (context === null) { + throw new Error( + 'ToggleGroup compound components cannot be rendered outside the ToggleGroup component', + ); + } + return context; +} + +function ToggleGroupItem({ + className, + children, + variant, + size, + isFirst, + isLast, + ...props +}: React.ComponentProps & + VariantProps & { + isFirst?: boolean; + isLast?: boolean; + }) { + const context = useToggleGroupContext(); + const { value } = ToggleGroupPrimitive.useRootContext(); + + return ( + + + {children} + + + ); +} + +export { ToggleGroup, ToggleGroupItem }; diff --git a/app/src/components/base/ui/toggle.tsx b/app/src/components/base/ui/toggle.tsx new file mode 100644 index 000000000..443130353 --- /dev/null +++ b/app/src/components/base/ui/toggle.tsx @@ -0,0 +1,73 @@ +import * as TogglePrimitive from '@rn-primitives/toggle'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type * as React from 'react'; +import { Platform } from 'react-native'; +import { TextClassContext } from '@/components/base/ui/text'; +import { WEB_FOCUS_RING } from '@/constants'; +import { cn } from '@/utils/cn'; + +const toggleVariants = cva( + cn( + 'active:bg-muted group flex flex-row items-center justify-center gap-2 rounded-md', + Platform.select({ + web: cn( + 'hover:bg-muted hover:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex cursor-default whitespace-nowrap outline-none transition-[color,box-shadow] disabled:pointer-events-none [&_svg]:pointer-events-none', + WEB_FOCUS_RING, + ), + }), + ), + { + variants: { + variant: { + default: 'bg-transparent', + outline: cn( + 'border-input active:bg-accent border bg-transparent', + // NOTE: shadow removed per DESIGN.md flat elevation; upstream RNR ships one + Platform.select({ + web: 'hover:bg-accent hover:text-accent-foreground', + }), + ), + }, + size: { + default: 'h-10 min-w-10 px-2.5 sm:h-9 sm:min-w-9 sm:px-2', + sm: 'h-9 min-w-9 px-2 sm:h-8 sm:min-w-8 sm:px-1.5', + lg: 'h-11 min-w-11 px-3 sm:h-10 sm:min-w-10 sm:px-2.5', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & VariantProps) { + return ( + + + + ); +} + +export { Toggle, toggleVariants }; diff --git a/app/src/components/base/useBottomNav.ts b/app/src/components/base/useBottomNav.ts new file mode 100644 index 000000000..e2807359e --- /dev/null +++ b/app/src/components/base/useBottomNav.ts @@ -0,0 +1,68 @@ +import { useSegments } from 'expo-router'; +import { MIN_TAP_TARGET } from '@/constants'; +import { useAuth } from '@/context/auth'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { useVisibleDestinations } from '@/navigation/destinations'; +import type { IconName } from './Icon'; + +// Non-component module (not .tsx) so the Fast-Refresh-only-exports-components +// eslint rule doesn't apply — BottomNav.tsx stays component-only, this file +// carries the shared hooks/constants/types both it and ActiveStreamBanner need. + +const DESTINATION_ICONS: Record = { + products: 'package', + cameras: 'camera', +}; + +/** + * Approximate rendered height of the bar itself (icon + label + the row's own + * py-2 padding, which runs a little over MIN_TAP_TARGET) — NOT including the + * device's safe-area bottom inset, which the bar also pads for but which + * varies per device. Exported so other floating chrome (ActiveStreamBanner) + * can clear the bar without hand-measuring or duplicating its layout. Good + * enough for a floating-offset bump; swap for an onLayout measurement if a + * pixel-perfect gap is ever needed. + */ +export const BOTTOM_NAV_CLEARANCE = MIN_TAP_TARGET + 16; + +export type Tab = { + key: string; + label: string; + icon: IconName; +}; + +/** + * Route name of a destination's tab. Every tab is a group segment named after + * its destination key — `(products)` holds both the /products and /components + * trees, so the folder can't just be called `products` (see (tabs)/_layout.tsx). + */ +export function tabRouteName(key: string): string { + return `(${key})`; +} + +export function useBottomNavTabs(): Tab[] { + const { user } = useAuth(); + const destinations = useVisibleDestinations(); + return [ + ...destinations.map(({ key, label }) => ({ + key, + label, + icon: DESTINATION_ICONS[key] ?? 'package', + })), + ...(user ? [{ key: 'account', label: 'Account', icon: 'user' as IconName }] : []), + ]; +} + +/** + * True exactly when `` will render: phone-width (below lg, always + * true on native) and the current route is inside the (tabs) group — which now + * includes every tab's detail screens, not just the three tab roots. Exported + * as the single source of truth so callers that need to reserve space below the + * bar (ActiveStreamBanner, the FABs, SaveBar's web dock) key off the same + * computation instead of a duplicated, driftable condition. + */ +export function useBottomNavVisible(): boolean { + const { isLg } = useBreakpoint(); + const segments = useSegments(); + return !isLg && segments[0] === '(tabs)'; +} diff --git a/app/src/components/cameras/ActiveStreamBanner.tsx b/app/src/components/cameras/ActiveStreamBanner.tsx new file mode 100644 index 000000000..9ef2f7623 --- /dev/null +++ b/app/src/components/cameras/ActiveStreamBanner.tsx @@ -0,0 +1,182 @@ +import { usePathname } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { Platform, Pressable, StyleSheet, View } from 'react-native'; +import Animated, { FadeInDown, FadeOut, ReduceMotion } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { AppText } from '@/components/base/AppText'; +import { BOTTOM_NAV_CLEARANCE, useBottomNavVisible } from '@/components/base/useBottomNav'; +import { useStreamSession } from '@/context/streamSession'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { useElapsed } from '@/hooks/useElapsed'; +import { useReturnFocus } from '@/hooks/useReturnFocus'; +import { useAppTheme, useInverseSurface } from '@/theme'; +import { getFloatingPosition } from '@/utils/platformLayout'; +import { StreamingSheet } from './StreamingSheet'; + +// Baseline float above the bottom edge, before any tab-bar clearance. +// +// NOTE (native trace, kept 88): the only floating chrome the banner ever needs +// to clear on native is a Fab (list screens' "New product"/"New camera" FABs +// and the detail screens' PrimaryProductFab. The flow SaveBar below md is +// accounted for separately by FLOW_SAVE_BAR_CLEARANCE below. +// Every Fab is MIN_TAP_TARGET (44) tall and docks 16px from the bottom of the +// screen it lives in (list and detail now share the same offset), so its top +// edge sits ~60px above that edge; 88 clears that with a ~28px visual gap. Routes +// with no Fab (cameras detail/add, account, users/[username], +// category-selection, the *_/new creation screens) render the banner ~72px +// higher than strictly necessary there, but that's a cosmetic gap, not an +// overlap — splitting the inset by route would need the same route-detection +// machinery as SAVE_BAR_DOCK_RESERVE below for a purely visual gain. Left as +// one constant; revisit only if a route grows a Fab-adjacent element the 88 +// stops clearing. +// +// That 88 measures from the *tab scene's* bottom, but this banner is mounted at +// the app root, OUTSIDE the tab navigator — the bar never shrinks the box it +// positions against, on native any more than on web. So whenever the bar is +// rendering (useBottomNavVisible below) the banner has to add the bar's full +// height back: BOTTOM_NAV_CLEARANCE plus the safe-area padding the bar puts +// under it (0 on web). Without the safe-area term a notched device would put +// the banner (88+60) straight through a detail screen's Fab, whose top edge is +// then at 63 + 60 + inset. +const BASE_BOTTOM_INSET = Platform.OS === 'web' ? 16 : 88; + +// SaveBar (FabControls.tsx) docks fixed at right:24/bottom:24 on >=md web +// product/component detail routes ('/products/:id' and '/components/:id' +// exactly — not '/products/new' or '.../components/new', which use a +// different screen with no SaveBar). The (?!new$) exclusion matters: '/products/new' +// would otherwise match '/products/:id' too, since 'new' satisfies [^/]+. The +// banner can't cheaply read SaveBar's own edit/dirty/validation state, so it +// reserves the zone whenever the route+breakpoint combination *could* render +// it — the same isMd gate ProductFabControls itself uses. The reserve width +// is a conservative estimate of SaveBar's widest realistic content (an +// error-summary button plus the primary Save/Edit button, with the dock's +// own padding and gaps), not a measured value — it only needs to be +// comfortably wider than SaveBar ever gets, not pixel-exact. +// Known over-reservation: SaveBar itself also hides for a non-owner viewer +// (SaveBar.tsx: `if (!ownedByMe) return null`), which this route+breakpoint +// check can't see — read-only visitors on a detail route get the 400px +// reservation with nothing behind it to protect. Accepted trade-off, not +// wired: threading ownership into this globally-mounted banner is real +// architectural cost (fetching/propagating per-product ownership into chrome +// that doesn't otherwise know about products) for a purely cosmetic gain +// (an empty gap, not an overlap) on a visitor-viewing-someone-else's-product +// path. Revisit only if that path gets materially more common. +// SaveBar.tsx / FabControls.tsx carry a back-reference comment — keep both +// in sync if the route pattern or SaveBar's render condition changes. +const SAVE_BAR_DOCK_ROUTE = /^\/(products|components)\/(?!new$)[^/]+$/; +// Not exported: this file is component-only for Fast Refresh +// (react-refresh/only-export-components), unlike useBottomNav.ts's +// intentionally-non-.tsx module. The test below duplicates this value. +const SAVE_BAR_DOCK_RESERVE = 400; +// Below md, product/component edit controls sit in normal flow directly above +// BottomNav. The globally-mounted banner cannot cheaply observe edit state, so +// detail routes conservatively reserve the tallest expected two-row SaveBar. +// This is an absolute baseline from the tab scene's bottom (before BottomNav), +// not an additive bump: native's existing 88px FAB clearance already covers +// most of it. Read-only detail routes accept the extra empty space. +const FLOW_SAVE_BAR_CLEARANCE = 120; + +export function ActiveStreamBanner() { + const theme = useAppTheme(); + const inverse = useInverseSurface(); + const { activeStream } = useStreamSession(); + const elapsed = useElapsed(activeStream?.startedAt ?? null); + const [sheetVisible, setSheetVisible] = useState(false); + const openSheet = useCallback(() => setSheetVisible(true), []); + const closeSheet = useCallback(() => setSheetVisible(false), []); + const bannerRef = useReturnFocus(sheetVisible); + const bottomNavVisible = useBottomNavVisible(); + const insets = useSafeAreaInsets(); + const pathname = usePathname(); + const { isMd } = useBreakpoint(); + const detailRoute = SAVE_BAR_DOCK_ROUTE.test(pathname); + const flowSaveBarActive = !isMd && detailRoute; + const baseBottomInset = flowSaveBarActive + ? Math.max(BASE_BOTTOM_INSET, FLOW_SAVE_BAR_CLEARANCE) + : BASE_BOTTOM_INSET; + const bottomInset = bottomNavVisible + ? baseBottomInset + BOTTOM_NAV_CLEARANCE + insets.bottom + : baseBottomInset; + const saveBarDockActive = Platform.OS === 'web' && isMd && detailRoute; + const rightInset = saveBarDockActive ? SAVE_BAR_DOCK_RESERVE : 16; + + // Reset the sheet whenever the active stream changes (ends elsewhere, or a new + // one starts) so it never auto-reopens for a stream the user didn't tap into. + const [trackedStream, setTrackedStream] = useState(activeStream); + if (activeStream !== trackedStream) { + setTrackedStream(activeStream); + setSheetVisible(false); + } + + if (!activeStream) return null; + + return ( + <> + + + + + + {activeStream.productName} + + + {elapsed} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + // NOTE: no local shadow. The manila glow this used to paint broke two + // DESIGN.md rules at once — the One Tier Rule (a second, coloured elevation + // tier) and the Data-Label Rule (accent as mass rather than as a small label). + // The live dot is the sanctioned manila usage; the banner takes the single + // sanctioned `tokens.elevation.overlay` tier, applied inline like Fab does. + elapsed: { + // The `data` variant supplies the mono family and tabular figures; only the + // smaller banner size is pinned here — stepped to the caption size (13) + // rather than an arbitrary 12. + fontSize: 13, + }, +}); diff --git a/app/src/components/cameras/CameraCard.tsx b/app/src/components/cameras/CameraCard.tsx new file mode 100644 index 000000000..e9ec8f9ea --- /dev/null +++ b/app/src/components/cameras/CameraCard.tsx @@ -0,0 +1,162 @@ +import { Image } from 'expo-image'; +import { memo, useCallback, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { AppText } from '@/components/base/AppText'; +import { Card } from '@/components/base/Card'; +import { Icon } from '@/components/base/Icon'; +import { StatusPill, type StatusTone } from '@/components/base/StatusPill'; +import { STATUS_LABEL } from '@/components/cameras/detail/styles'; +import type { EffectiveCameraConnection } from '@/features/cameras/useEffectiveCameraConnection'; +import { useAuthedMediaSource } from '@/services/api/authedMedia'; +import type { CameraConnectionStatus, CameraReadWithStatus } from '@/services/api/rpiCamera'; +import { useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; +import { TelemetryBadge } from './TelemetryBadge'; + +// Mirrors theme/color.ts's getStatusColor mapping, but as StatusPill tone +// keys rather than resolved colors — StatusPill resolves the color itself. +const CONNECTION_TONE: Record = { + online: 'success', + offline: 'offline', + unauthorized: 'warning', + forbidden: 'warning', + error: 'danger', +}; + +/** + * Format an ISO-8601 timestamp as a compact relative string for the offline + * overlay, e.g. ``42s ago``, ``3m ago``, ``5h ago``, ``2d ago``. + */ +function formatLastSeen(lastSeenAt: string | null | undefined): string { + if (!lastSeenAt) return 'never seen'; + const lastSeen = new Date(lastSeenAt).getTime(); + if (!Number.isFinite(lastSeen)) return 'never seen'; + const diffSeconds = Math.max(0, Math.round((Date.now() - lastSeen) / 1000)); + if (diffSeconds < 60) return `${diffSeconds}s ago`; + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +function CameraCardComponent({ + camera, + effectiveConnection, +}: { + camera: CameraReadWithStatus; + effectiveConnection?: EffectiveCameraConnection; +}) { + const theme = useAppTheme(); + const [failedThumbnailUrl, setFailedThumbnailUrl] = useState(null); + const connection = effectiveConnection?.status ?? camera.status?.connection ?? 'offline'; + const isOnline = connection === 'online'; + const thumbnailUrl = camera.preview_thumbnail_url ?? null; + // Preview thumbnails are owner-checked, so the request has to carry credentials. + // Null while a native token resolves, which keeps the placeholder up instead of + // firing a spurious onError. + const thumbnailSource = useAuthedMediaSource(thumbnailUrl); + const hasThumbnail = isOnline && Boolean(thumbnailSource) && failedThumbnailUrl !== thumbnailUrl; + const handleThumbnailError = useCallback( + () => setFailedThumbnailUrl(thumbnailUrl), + [thumbnailUrl], + ); + + return ( + + {/* Thumbnail (online only) or placeholder */} + + {hasThumbnail ? ( + // expo-image's Image is not cssInterop-wrapped in this app; styling stays JS-side. + + ) : ( + + {isOnline ? ( + <> + + + + + No preview available + + + ) : ( + <> + + + + + Offline + + + )} + + )} + + + + + + {camera.name} + + {camera.description ? ( + + {camera.description} + + ) : null} + + + {isOnline ? ( + effectiveConnection?.detailLabel ? ( + + {effectiveConnection.detailLabel} + + ) : ( + + ) + ) : ( + + Last seen {formatLastSeen(camera.status?.last_seen_at)} + + )} + + + + + ); +} + +export const CameraCard = memo(CameraCardComponent); + +const styles = StyleSheet.create({ + thumbnailFrame: { + // 16:10 has no exact Tailwind aspect-ratio step (aspect-video is 16:9). + aspectRatio: 16 / 10, + }, + thumbnail: { + width: '100%', + height: '100%', + }, + lastSeenText: { + // opacity 0.65 has no exact Tailwind step. + opacity: 0.65, + }, +}); diff --git a/app/src/components/cameras/CameraPickerDialog.tsx b/app/src/components/cameras/CameraPickerDialog.tsx new file mode 100644 index 000000000..3b8594fc0 --- /dev/null +++ b/app/src/components/cameras/CameraPickerDialog.tsx @@ -0,0 +1,136 @@ +import { useRouter } from 'expo-router'; +import { type RefObject, useCallback, useMemo } from 'react'; +import { ActivityIndicator, Pressable, View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { AppText } from '@/components/base/AppText'; +import { dialogTitleStyle } from '@/components/base/dialogStyles'; +import { Icon } from '@/components/base/Icon'; +import { MutedText } from '@/components/base/MutedText'; +import { useCamerasQuery } from '@/features/cameras/rpi/hooks'; +import { + resolveEffectiveCameraConnection, + useEffectiveCameraConnection, +} from '@/features/cameras/useEffectiveCameraConnection'; +import type { CameraReadWithStatus } from '@/services/api/rpiCamera'; +import { useAppTheme } from '@/theme'; +import { palette } from '@/theme/palette.generated'; + +interface CameraPickerDialogProps { + visible: boolean; + onDismiss: () => void; + /** Called with the selected camera (only online cameras are selectable). */ + onSelect: (camera: CameraReadWithStatus) => void; + title?: string; + triggerRef?: RefObject; +} + +/** + * Reusable camera picker dialog — lists all registered cameras sorted online + * first. Offline cameras are shown dimmed and non-interactive. A "Manage" + * button navigates to the camera management screen. + */ +export function CameraPickerDialog({ + visible, + onDismiss, + onSelect, + title = 'Select camera', + triggerRef, +}: CameraPickerDialogProps) { + const theme = useAppTheme(); + const router = useRouter(); + const { data: cameras, isLoading } = useCamerasQuery(true, { enabled: visible }); + + const handleManage = useCallback(() => { + onDismiss(); + router.navigate('/cameras'); + }, [onDismiss, router]); + + const sorted = useMemo( + () => + [...(cameras ?? [])].sort((a, b) => { + const aReachable = resolveEffectiveCameraConnection(a).isReachable ? 0 : 1; + const bReachable = resolveEffectiveCameraConnection(b).isReachable ? 0 : 1; + return aReachable - bReachable; + }), + [cameras], + ); + + return ( + + + {title} + + + {isLoading ? ( + + ) : sorted.length === 0 ? ( + + + No cameras registered + + ) : ( + sorted.map((cam) => ) + )} + + + + + Manage + + + + Cancel + + + + ); +} + +function CameraPickerRow({ + camera, + onSelect, +}: { + camera: CameraReadWithStatus; + onSelect: (camera: CameraReadWithStatus) => void; +}) { + const theme = useAppTheme(); + const effectiveConnection = useEffectiveCameraConnection(camera); + const isReachable = effectiveConnection.isReachable; + const handleSelect = useCallback(() => { + if (!isReachable) { + return; + } + onSelect(camera); + }, [isReachable, onSelect, camera]); + + return ( + + + + {camera.name} + {effectiveConnection.detailLabel ? ( + + Direct + + ) : null} + {!isReachable && ( + + Offline + + )} + + ); +} diff --git a/app/src/components/cameras/CameraStreamPicker.tsx b/app/src/components/cameras/CameraStreamPicker.tsx new file mode 100644 index 000000000..13c93a5ea --- /dev/null +++ b/app/src/components/cameras/CameraStreamPicker.tsx @@ -0,0 +1,56 @@ +import { type RefObject, useCallback } from 'react'; +import type { View } from 'react-native'; +import { useCameraStreamPicker } from '@/features/cameras/youtube/useCameraStreamPicker'; +import { CameraPickerDialog } from './CameraPickerDialog'; +import { GoLiveDialog } from './GoLiveDialog'; + +interface CameraStreamPickerProps { + productId: number; + productName: string; + visible: boolean; + onDismiss: () => void; + triggerRef?: RefObject; +} + +export function CameraStreamPicker({ + productId, + productName, + visible, + onDismiss, + triggerRef, +}: CameraStreamPickerProps) { + const { state, actions } = useCameraStreamPicker({ + productId, + productName, + onDismiss, + }); + const { handleStartStream } = actions; + const handleStart = useCallback(async () => handleStartStream(), [handleStartStream]); + + return ( + <> + + + + ); +} diff --git a/app/src/components/cameras/GoLiveDialog.tsx b/app/src/components/cameras/GoLiveDialog.tsx new file mode 100644 index 000000000..bdda746d6 --- /dev/null +++ b/app/src/components/cameras/GoLiveDialog.tsx @@ -0,0 +1,100 @@ +import { type RefObject, useCallback } from 'react'; +import { View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { AppText } from '@/components/base/AppText'; +import { dialogTitleStyle } from '@/components/base/dialogStyles'; +import { Icon } from '@/components/base/Icon'; +import { TextInput } from '@/components/base/TextInput'; +import { Text as UiText } from '@/components/base/ui/text'; +import { ToggleGroup, ToggleGroupItem } from '@/components/base/ui/toggle-group'; +import type { YouTubePrivacyStatus } from '@/services/api/rpiCamera'; +import { useAppTheme } from '@/theme'; + +type GoLiveDialogProps = { + visible: boolean; + cameraName: string; + title: string; + privacy: YouTubePrivacyStatus; + loading: boolean; + onDismiss: () => void; + onChangeTitle: (value: string) => void; + onChangePrivacy: (value: YouTubePrivacyStatus) => void; + onStart: () => void; + secondaryLabel: string; + onSecondary: () => void; + /** Pushes the secondary action to the far left, e.g. a "Back" step in a picker flow. */ + showSpacer?: boolean; + triggerRef?: RefObject; +}; + +export function GoLiveDialog({ + visible, + cameraName, + title, + privacy, + loading, + onDismiss, + onChangeTitle, + onChangePrivacy, + onStart, + secondaryLabel, + onSecondary, + showSpacer = false, + triggerRef, +}: GoLiveDialogProps) { + const theme = useAppTheme(); + const handleValueChange = useCallback( + // Single-select toggle groups can report `undefined` (pressing the + // already-active item) — visibility must always have a value, so that's + // treated as a no-op rather than clearing the selection. + (value: string | undefined) => { + if (value) onChangePrivacy(value as YouTubePrivacyStatus); + }, + [onChangePrivacy], + ); + + return ( + + + Go Live on {cameraName} + + + + + Visibility + + + + + Private + + + + Unlisted + + + + Public + + + + + + {secondaryLabel} + + {showSpacer ? : null} + + Go Live + + + + ); +} diff --git a/app/src/components/cameras/LivePreview.tsx b/app/src/components/cameras/LivePreview.tsx new file mode 100644 index 000000000..e25ca2ba8 --- /dev/null +++ b/app/src/components/cameras/LivePreview.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from 'react'; +import { Component } from 'react'; +import { View } from 'react-native'; +import { PreviewPlayer } from '@/components/cameras/live-preview/PreviewPlayer'; +import { + PreviewErrorOverlay, + PreviewShell, +} from '@/components/cameras/live-preview/previewOverlays'; +import type { CameraConnectionInfo } from '@/features/cameras/local-connection/useLocalConnection'; +import { useCameraLivePreview } from '@/features/cameras/rpi/hooks'; +import type { CameraRead } from '@/services/api/rpiCamera'; + +/** + * LL-HLS live preview for a single camera. + * + * Web: renders a ``