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..47e0144e4 --- /dev/null +++ b/.devcontainer/app/devcontainer.json @@ -0,0 +1,14 @@ +{ + "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": {}, + "ghcr.io/devcontainers-extra/features/expo-cli:1": {} + } +} diff --git a/.devcontainer/backend/devcontainer.json b/.devcontainer/backend/devcontainer.json index 47c944a62..b592154ce 100644 --- a/.devcontainer/backend/devcontainer.json +++ b/.devcontainer/backend/devcontainer.json @@ -6,7 +6,7 @@ "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": {} }, diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0426dc04b..08d8ba72b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,8 @@ { "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": { @@ -10,5 +10,5 @@ "ghcr.io/devcontainers-extra/features/expo-cli:1": {}, "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {} }, - "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..f25d5c9d9 100644 --- a/.devcontainer/docs/devcontainer.json +++ b/.devcontainer/docs/devcontainer.json @@ -1,11 +1,11 @@ { "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": {} 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..e0121362c --- /dev/null +++ b/.devcontainer/www/devcontainer.json @@ -0,0 +1,13 @@ +{ + "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": {} + } +} 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..8215c2592 100644 --- a/.env.example +++ b/.env.example @@ -1,49 +1,59 @@ -# 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. - -COMPOSE_BAKE=true - -# --- Cloudflare tunnel ----------------------------------------------- -TUNNEL_TOKEN=your_token # 🔀 prod or staging token depending on this host - -# --- 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). +# 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. # -# One-time, per host, before enabling Loki shipping: -# docker plugin install grafana/loki-docker-driver:latest \ -# --alias loki --grant-all-permissions - -# Logs → Loki push endpoint (auth embedded in URL) -# LOKI_URL=https://relab:@logs.cml-relab.org/loki/api/v1/push - -# Traces/metrics → OTLP HTTP collector (+ matching auth header) -# OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.cml-relab.org +# 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, +# and FEATURED_PRODUCT_ID. + +# --- Required host-local deploy inputs -------------------------------- + +# Type: secret | Consumer: Cloudflare tunnel | Purpose: authenticates this host's tunnel. +CLOUDFLARE_TUNNEL_TOKEN=replace-me + +# 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 + +# --- Optional host-local deploy inputs -------------------------------- + +# Type: secret URL/header | Consumer: telemetry | Purpose: optional central monitoring. +# See deployment docs before enabling Loki/OTLP shipping. +# LOKI_PUSH_URL=https://relab:@/loki/api/v1/push +# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.cml-relab.org # OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20 -# --- Backup destinations --------------------------------------------- -BACKUP_DIR=./backups - -# 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 # 🔀 - -# 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: backup | Purpose: local restic repository path. +BACKUP_HOST_DIR=./backups 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..14740ca4c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,18 +1,18 @@ -# 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 +32,13 @@ 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 +46,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 +59,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 +80,24 @@ 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 +106,24 @@ 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/cache and run migrations. ```bash - just dev + just dev-db + just dev-migrate ``` -1. Run migrations on first start. +1. Start the stack with file watching. ```bash - just dev-migrate + 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 +144,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 +169,61 @@ 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 + +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.1 +A/AA rule tags and strips animations for deterministic runs. + +| 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. 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 +232,28 @@ 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 . +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 @@ -192,27 +265,16 @@ Keep examples centralized and predictable: - 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 +285,46 @@ 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 +336,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 @@ -336,120 +370,41 @@ 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 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..b8ddb9957 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -17,3 +17,59 @@ Instead, email [relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl) with: - 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 +- uploads and media +- 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. + +## 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. + +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 +- 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..f7659962a --- /dev/null +++ b/.github/actions/detect-changes/action.yml @@ -0,0 +1,63 @@ +# 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 }} + + # --- 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-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 + if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' + 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-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..1b7f3d6e0 --- /dev/null +++ b/.github/actions/docker-smoke/action.yml @@ -0,0 +1,38 @@ +# 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: Checkout + 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 test + shell: bash + env: + RECIPE: ${{ inputs.recipe }} + 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]+|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..e600d1ed3 100644 --- a/.github/gitleaks.toml +++ b/.github/gitleaks.toml @@ -1,14 +1,28 @@ [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" +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..955f972c5 100644 --- a/.github/path-filters.yml +++ b/.github/path-filters.yml @@ -1,50 +1,72 @@ shared: + - 'assets/**' - '.github/**' - '.devcontainer/**' - '.vscode/**' - - '.python-version' - '.env.example' - 'backend/.env*.example' - 'justfile' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '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' - - 'uv.lock' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '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' 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..410085ed3 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -7,91 +7,47 @@ ":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 - }, + "pre-commit": { "enabled": 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"] - }, - { - "matchUpdateTypes": ["minor", "patch"], - "automerge": true, - "labels": ["automerge"], + "description": "Expo SDK-managed packages, versions pinned by the SDK, don't bump independently", + "matchFileNames": ["app/package.json"], "matchPackageNames": [ - "*" - ] - }, - { - "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" + "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" ], - "labels": ["tooling"] - }, - { - "groupName": "backend-python-runtime", - "matchFileNames": ["backend/pyproject.toml", "backend/uv.lock", "backend/Dockerfile*"] - }, - { - "groupName": "docs-site-runtime-tooling", - "matchFileNames": ["docs/package.json", "docs/pnpm-lock.yaml", "docs/Dockerfile*"] - }, - { - "groupName": "frontend-web-runtime-tooling", - "matchFileNames": [ - "frontend-web/package.json", - "frontend-web/pnpm-lock.yaml", - "frontend-web/Dockerfile*" - ] - }, - { - "groupName": "frontend-app-runtime-tooling", - "matchFileNames": [ - "frontend-app/package.json", - "frontend-app/pnpm-lock.yaml", - "frontend-app/Dockerfile*" - ] + "enabled": false }, { - "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"] + ] } 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..fd761772f 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,76 @@ 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 }} + # 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..4a42e4dcc 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,13 +1,13 @@ # 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 ] workflow_dispatch: @@ -19,7 +19,6 @@ concurrency: permissions: contents: read - security-events: write defaults: run: @@ -28,27 +27,89 @@ 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-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 }} + 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 or manual runs scan every runtime image and emit SBOMs. + if [[ "$IS_SCHEDULE" == "true" || "$IS_DISPATCH" == "true" ]]; then + SHARED=true + fi + + shared_trigger=$([[ "$SHARED" == "true" || "$CONTAINERS" == "true" ]] && echo true || echo false) + echo "run-iac=$shared_trigger" >> "$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 +117,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 + persist-credentials: false - name: Run gitleaks uses: gacts/gitleaks@c9a0338361dc45a01aa7ebaaa5330179f3c62873 @@ -64,133 +126,24 @@ 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' - runs-on: ubuntu-latest - timeout-minutes: 15 - 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 - - - name: Run root dependency audit - run: just audit-root - - backend-python-audit: - name: Python Audit (backend) - needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-backend-python == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - - name: Set up runtime - uses: ./.github/actions/setup-runtime - with: - setup-python: "true" - uv-cache-dependency-glob: backend/uv.lock - - - name: Install dependencies - run: just backend/install - - - name: Run dependency audit - run: just backend/audit - - docs-node-audit: - name: Node Audit (docs) - needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-docs-node == 'true' + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' runs-on: ubuntu-latest - timeout-minutes: 15 - continue-on-error: true + 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-node: "true" - cache-dependency-path: docs/pnpm-lock.yaml - - - name: Install dependencies - run: just docs/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 + persist-credentials: false - frontend-app-node-audit: - name: Node Audit (frontend-app) - needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-frontend-app-node == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - - name: Set up runtime - uses: ./.github/actions/setup-runtime + - name: Review dependency changes + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 with: - setup-node: "true" - cache-dependency-path: frontend-app/pnpm-lock.yaml - - - name: Install dependencies - run: just frontend-app/install - - - name: Run dependency audit - run: just frontend-app/audit - - frontend-web-node-audit: - name: Node Audit (frontend-web) - needs: [ changes ] - if: github.event_name != 'pull_request' && needs.changes.outputs.run-frontend-web-node == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - - name: Set up runtime - uses: ./.github/actions/setup-runtime - with: - setup-node: "true" - cache-dependency-path: frontend-web/pnpm-lock.yaml - - - name: Install dependencies - run: just frontend-web/install - - - name: Run dependency audit - run: just frontend-web/audit + fail-on-severity: moderate container-security: name: Container Security (${{ matrix.service }}) @@ -204,6 +157,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 +169,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 +211,146 @@ 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 + 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 + - 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..10b552ae0 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,15 +1,12 @@ # 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 ] - merge_group: + branches: [ main ] workflow_dispatch: schedule: - cron: "0 3 * * 1" @@ -31,22 +28,64 @@ 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 }} + 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 }} + 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 }} + run: | + # Scheduled runs only exercise docker-smoke; skip all other jobs. + if [[ "$IS_SCHEDULE" == "true" ]]; then + for k in run-policy run-backend run-www run-app run-docs run-e2e; do + echo "$k=false" >> "$GITHUB_OUTPUT" + done + exit 0 + fi + + # Manual dispatch runs exercise everything. + if [[ "$IS_DISPATCH" == "true" ]]; then + for k in run-policy run-backend run-www run-app run-docs run-e2e; do + echo "$k=true" >> "$GITHUB_OUTPUT" + done + 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" 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 +93,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime @@ -77,24 +118,37 @@ jobs: - name: Install root dependencies run: pnpm install --frozen-lockfile + - name: Set up OpenTofu + uses: opentofu/setup-opentofu@9d84900f3238fab8cd84ce47d658d25dd008be2f + with: + tofu_wrapper: false + - name: Run pre-commit 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 - - name: Check shared dep version alignment - run: pnpm run syncpack:check + - name: Validate Cloudflare edge IaC + run: just cloudflare-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 "- deploy secret file paths matched rendered Compose" echo "- GitHub workflow lint passed via pre-commit" } >> "$GITHUB_STEP_SUMMARY" @@ -107,6 +161,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up runtime uses: ./.github/actions/setup-runtime @@ -123,149 +179,134 @@ 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 + - 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 +317,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,9 +341,9 @@ jobs: run: just docs/test-ci e2e-full-stack: - name: Frontend App Full-Stack E2E + name: App Full-Stack E2E needs: [ changes ] - if: github.event_name == 'workflow_dispatch' || (needs.changes.outputs.run-e2e == 'true' && (github.event_name == 'push' || github.event_name == 'merge_group')) + if: github.event_name == 'workflow_dispatch' || (needs.changes.outputs.run-e2e == 'true' && github.event_name == 'push') runs-on: ubuntu-latest timeout-minutes: 35 env: @@ -307,6 +351,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 +361,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,8 +381,8 @@ 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: @@ -349,19 +396,40 @@ jobs: recipe: - docker-smoke-backend - docker-orchestration-smoke - - docker-smoke-frontend-web - - docker-smoke-frontend-app + - docker-smoke-www + - docker-smoke-app - docker-smoke-docs - - docker-smoke-user-upload-backups + - docker-smoke-backups + steps: + - 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: Set up runtime - uses: ./.github/actions/setup-runtime - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - - - 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..4acb5fadb 100644 --- a/.gitignore +++ b/.gitignore @@ -77,16 +77,28 @@ __pycache__/ # 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 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 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..44939cc94 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,16 @@ # 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: v8.30.0 hooks: - id: gitleaks - args: ["--config", ".github/gitleaks.toml"] + args: [ "--config", ".github/gitleaks.toml" ] - repo: https://github.com/executablebooks/mdformat rev: 1.0.0 @@ -43,6 +45,13 @@ repos: - id: actionlint files: ^\.github/workflows/.*\.ya?ml$ + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: 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 hooks: @@ -50,20 +59,52 @@ repos: args: [ "--external-sources", "--source-path=SCRIPTDIR:." ] exclude: (^|/)(node_modules|\.venv)/ + - repo: https://github.com/scop/pre-commit-shfmt + rev: 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: v4.16.4 hooks: - id: commitizen stages: [ commit-msg ] - repo: https://github.com/simonvanlierde/check-json5 - rev: v1.1.0 + rev: v1.2.0 hooks: - id: check-json5 exclude: '^(?:frontend[^/]*(?:/.*)?/data/.*\.json|(?:.*/)?package-lock\.json)$' + - repo: https://github.com/semgrep/pre-commit + rev: 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 semgrep --test .' + pass_filenames: false + files: ^\.semgrep/ + stages: [ pre-push ] + - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.1 + rev: 0.11.28 hooks: - id: uv-lock name: uv-lock-root @@ -73,29 +114,40 @@ 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: 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 + files: ^(?:assets/.*|scripts/sync_brand_assets\.py|justfile|docs/src/styles/brand\.css|www/src/styles/brand\.css|app/src/assets/images/(?:bg-(?:light|dark)\.jpg|favicon\.png)|docs/public/(?:fonts/ibm-plex-sans-latin(?:-ext)?\.woff2|images/(?:apple-touch-icon\.png|bg-(?:light|dark)\.jpg|favicon\.(?:ico|svg)|logo\.svg))|www/public/(?:fonts/ibm-plex-sans-latin(?:-ext)?\.woff2|images/(?:apple-touch-icon\.png|bg-(?:light|dark)\.jpg|favicon\.(?:ico|svg)|logo\.(?:png|svg)))|backend/app/static/favicon\.ico)$ + 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..8f45b3b16 --- /dev/null +++ b/.semgrep/browser-runtime-safety.tsx @@ -0,0 +1,35 @@ +// 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

'); + +// 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'); diff --git a/.semgrep/browser-runtime-safety.yml b/.semgrep/browser-runtime-safety.yml new file mode 100644 index 000000000..f7eb69040 --- /dev/null +++ b/.semgrep/browser-runtime-safety.yml @@ -0,0 +1,42 @@ +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" + - "/app/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/**" + - "/www/src/**" + - "/app/src/**" + pattern-regex: '\.(?:innerHTML|outerHTML)\s*=|\binsertAdjacentHTML\s*\(|\bdocument\.write\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..e320e7751 --- /dev/null +++ b/.semgrep/third-party-browser-js.yml @@ -0,0 +1,50 @@ +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 ] + paths: + include: + - "/app/**" + - "/docs/src/components/**" + - "/docs/src/pages/**" + - "/docs/src/scripts/**" + - "/docs/public/**" + - "/docs/Caddyfile" + - "/docs/package.json" + - "/www/src/**" + - "/www/public/**" + - "/www/Caddyfile" + - "/www/package.json" + 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: + include: + - "/app/**" + - "/docs/src/components/**" + - "/docs/src/pages/**" + - "/docs/src/scripts/**" + - "/docs/public/**" + - "/docs/Caddyfile" + - "/docs/package.json" + - "/www/src/**" + - "/www/public/**" + - "/www/Caddyfile" + - "/www/package.json" + 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..bfe6b827e --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,12 @@ +**/__tests__/** +**/*.test.* +**/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..7daea63ac 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,7 +12,7 @@ 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: diff --git a/README.md b/README.md index d111c11f0..43e00d37a 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,20 @@ -# RELab: Reverse Engineering Lab +

+ 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/) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.16637742.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 +23,20 @@ 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: - -- support structured data collection during disassembly work -- make that data easier to publish, browse, and reuse later +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. -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. +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. -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 -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. +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: data that is collected collaboratively, publicly accessible, linkable to existing and upcoming databases, and structured enough for machine-learning use. ## Start Here @@ -41,21 +46,30 @@ 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 @@ -64,19 +78,26 @@ 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) 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..3f139fd05 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,54 @@ +### Auto-generated +# Dependencies +node_modules/ + +# Expo +.expo/ +expo-env.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/ARCHITECTURE.md b/app/ARCHITECTURE.md new file mode 100644 index 000000000..32c09e00d --- /dev/null +++ b/app/ARCHITECTURE.md @@ -0,0 +1,125 @@ +# Architecture + +High-level map of `app`. + +## 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:** NativeWind v5 + 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 + +``` +src/ +├── app/ # Expo Router tree, one file per route. +├── components/ # Feature folders (auth, cameras, product, profile, common, base). +├── hooks/ # Cross-feature custom hooks. +├── services/ # Backend integration: api/, media/, storage, domain stores. +├── context/ # React context providers (auth session, theme, etc.). +├── types/ # Hand-written types + api.generated.ts (do not edit). +├── 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. +├── assets/ # Fonts, images, icons. +└── public/ # Files copied verbatim into the web export. +``` + +`base/` components are generic primitives; `common/` are app-wide composites; +the rest are domain-scoped. Keep imports flowing inward (features may use +`base`/`common`, not the reverse). + +`src/components/base/ui/` is vendored react-native-reusables output — regenerate via the RNR CLI rather than hand-refactoring. + +## 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. Routes for auth, cameras, +products, profile, users live directly under `src/app/`. + +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. + +## Testing 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. | + +**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__/`. + +Run via `just test-unit`, `just test-integration`, `just test-e2e`. See +[README.md](README.md) for profiling tips. + +**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. + +## Build & 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. + +## Linting ownership + +Biome is primary (formatting + most correctness). ESLint runs a narrow config +for rules Biome doesn't yet expose (`react-hooks/*`, `react-refresh`, +`react-native-a11y`). This is intentional overlap; see the "Lint Ownership" +section of [README.md](README.md). When Biome ships equivalents for these, +remove ESLint and its plugins. 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/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..6a7607942 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,93 @@ +# 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@latest && 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/ +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..fddffc0bf --- /dev/null +++ b/app/README.md @@ -0,0 +1,122 @@ +# Relab App + +The `app` subrepo contains the Expo / React Native app used for authenticated data collection. + +For the stack, source layout, routing, data flow, and testing layers, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Quick Start + +```bash +just install +just dev +``` + +## 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 +``` + +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. + +## Common Commands + +```bash +just check # lint +just test # 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 + +Three layers with different speed and confidence tradeoffs: `unit` and `integration` (both Jest; `just test` runs both), and `e2e` (Playwright against a built web app; requires `just build-web` and the `compose.e2e.yaml` stack). See [ARCHITECTURE.md](ARCHITECTURE.md#testing-layers) for the location rules and lane selection. + +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. + +## 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 +``` + +## More + +For emulator and device setup, testing patterns, and app-specific development notes, see [CONTRIBUTING.md](../.github/CONTRIBUTING.md#frontend-development). + +## Styling And Theming + +Styling in `app` is built on NativeWind v5, with colors, type scale, and semantic tokens delivered through `AppThemeProvider` (see [ARCHITECTURE.md](ARCHITECTURE.md)). + +- 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/` or `src/components/common/` +- 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. + +## 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`. + +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. diff --git a/app/app.json b/app/app.json new file mode 100644 index 000000000..201f18d1d --- /dev/null +++ b/app/app.json @@ -0,0 +1,77 @@ +{ + "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 + } + }, + "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" + ], + "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..d71c86605 --- /dev/null +++ b/app/biome.jsonc @@ -0,0 +1,129 @@ +{ + "$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" + ] + }, + "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..463c61384 --- /dev/null +++ b/app/config/cssMock.js @@ -0,0 +1,4 @@ +// Jest has no CSS loader; NativeWind/react-native-css apply 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/frontend-app/config/jest/setup.shared.ts b/app/config/setup.shared.ts similarity index 85% rename from frontend-app/config/jest/setup.shared.ts rename to app/config/setup.shared.ts index dc401b47b..7768d800a 100644 --- a/frontend-app/config/jest/setup.shared.ts +++ b/app/config/setup.shared.ts @@ -1,9 +1,9 @@ import { afterAll, afterEach, beforeEach, jest } from '@jest/globals'; import { cleanup } from '@testing-library/react-native'; import type React from 'react'; -import { server } from '../../src/test-utils/server'; +import { server } from '@/test-utils/server'; -process.env.EXPO_PUBLIC_API_URL = 'http://localhost:8000/api'; +process.env.EXPO_PUBLIC_API_URL = 'http://127.0.0.1:18010'; if (typeof window !== 'undefined' && typeof window.dispatchEvent !== 'function') { Object.defineProperty(window, 'dispatchEvent', { @@ -78,74 +78,6 @@ jest.mock('@expo/vector-icons/MaterialIcons', () => ({ jest.requireMock('@expo/vector-icons').MaterialIcons, })); -// Mock react-native-paper Icon to a stable component to avoid act() warnings -jest.mock('react-native-paper', () => { - const React = require('react'); - const actual = jest.requireActual( - 'react-native-paper', - ) as Record; - const { Pressable, Text, View } = require('react-native'); - - const Icon = React.memo( - ({ - source, - name, - testID, - ...props - }: { - source?: string; - name?: string; - testID?: string; - [key: string]: unknown; - }) => - React.createElement( - Text, - { testID: testID ?? 'mock-icon', ...props }, - typeof source === 'string' ? source : (name ?? 'icon'), - ), - ); - - const Menu = ({ - anchor, - children, - visible, - }: { - anchor?: React.ReactNode; - children?: React.ReactNode; - visible?: boolean; - }) => - React.createElement( - React.Fragment, - null, - anchor, - visible ? React.createElement(View, { testID: 'mock-menu' }, children) : null, - ); - - Menu.Item = ({ - title, - onPress, - accessibilityLabel, - testID, - }: { - title: string; - onPress?: () => void; - accessibilityLabel?: string; - testID?: string; - }) => - React.createElement( - Pressable, - { - accessibilityRole: 'button', - accessibilityLabel: accessibilityLabel ?? title, - onPress, - testID, - }, - React.createElement(Text, null, title), - ); - - return { ...actual, Icon, Menu }; -}); - // Mock expo-secure-store (replaces AsyncStorage for token persistence on native) jest.mock('expo-secure-store', () => ({ getItemAsync: jest.fn(), @@ -163,6 +95,12 @@ 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()]), @@ -264,6 +202,7 @@ jest.mock('react-native-reanimated', () => { withSequence: (...values: number[]) => values[values.length - 1], interpolate: (value: number) => value, Extrapolation: { CLAMP: 'clamp', EXTEND: 'extend', IDENTITY: 'identity' }, + ReduceMotion: { System: 'system', Always: 'always', Never: 'never' }, SensorType: { ROTATION: 'ROTATION', GRAVITY: 'GRAVITY', diff --git a/frontend-app/config/jest/setup.unit.ts b/app/config/setup.unit.ts similarity index 77% rename from frontend-app/config/jest/setup.unit.ts rename to app/config/setup.unit.ts index 9a7690349..c65b9d50a 100644 --- a/frontend-app/config/jest/setup.unit.ts +++ b/app/config/setup.unit.ts @@ -5,7 +5,13 @@ import type React from 'react'; // can then opt into expo-router/testing-library with the real router module. jest.mock('expo-router', () => { const React = require('react'); + // Theme values/provider moved from @react-navigation/native to expo-router in SDK 57; + // re-expose the real objects so theme adaptation still works under the mock. + const { DefaultTheme, DarkTheme, ThemeProvider } = require('@react-navigation/native'); return { + DefaultTheme, + DarkTheme, + ThemeProvider, useRouter: jest.fn().mockReturnValue({ push: jest.fn(), replace: jest.fn(), @@ -13,6 +19,7 @@ jest.mock('expo-router', () => { setParams: jest.fn(), }), useSegments: () => [], + useFocusEffect: jest.fn(), useLocalSearchParams: jest.fn().mockReturnValue({}), useGlobalSearchParams: jest.fn().mockReturnValue({}), useNavigation: jest.fn().mockReturnValue({ diff --git a/app/e2e/accessibility.spec.ts b/app/e2e/accessibility.spec.ts new file mode 100644 index 000000000..33ec5b904 --- /dev/null +++ b/app/e2e/accessibility.spec.ts @@ -0,0 +1,55 @@ +/** + * 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 / react-native-paper + * emit 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.1, level A + AA — the real-world +// baseline. (WCAG 2.2-only criteria are omitted; axe-core's rule coverage for +// them is too sparse to gate on.) +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; +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([]); + }); +}); 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/frontend-app/e2e/auth.spec.ts b/app/e2e/auth.spec.ts similarity index 79% rename from frontend-app/e2e/auth.spec.ts rename to app/e2e/auth.spec.ts index 87851e286..9986b6b23 100644 --- a/frontend-app/e2e/auth.spec.ts +++ b/app/e2e/auth.spec.ts @@ -6,8 +6,8 @@ * - the Expo web app has been built for E2E * * Test user credentials come from backend/.env.test: - * SUPERUSER_EMAIL=e2e-admin@example.com - * SUPERUSER_PASSWORD=E2eTestPass123! + * BOOTSTRAP_SUPERUSER_EMAIL=e2e-admin@example.com + * BOOTSTRAP_SUPERUSER_PASSWORD=E2eTestPass123! */ import { expect, test } from '@playwright/test'; @@ -15,6 +15,7 @@ import { EMAIL, finishOnboardingIfVisible, PASSWORD } 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/; @@ -24,8 +25,8 @@ test.describe('Authentication flow', () => { 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.getByText('Sign In', { exact: true })).toBeVisible(); + // Header shows Sign in pill for guests + await expect(page.getByText('Sign in', { exact: true })).toBeVisible(); }); test('login page shows expected fields and navigation links', async ({ page }) => { @@ -40,32 +41,32 @@ test.describe('Authentication flow', () => { await page.goto('/login'); await page.getByPlaceholder('Email or username').fill(EMAIL); await page.getByPlaceholder('Password').fill('wrong-password'); - await page.getByRole('button', { name: 'Login' }).click(); - // The app shows a "Login Failed" dialog on bad credentials - await expect(page.getByText('Login Failed')).toBeVisible({ + 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', + tag: ['@cross-browser', '@auth'], }, async ({ page }) => { await page.goto('/login'); await page.getByPlaceholder('Email or username').fill(EMAIL); await page.getByPlaceholder('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Login' }).click(); + 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' }, async ({ - page, - }) => { + test('full new-user flow: login → onboarding → products', { + tag: ['@cross-browser', '@auth'], + }, async ({ page }) => { await page.goto('/login'); // ── Login ─────────────────────────────────────────────────────────────── await page.getByPlaceholder('Email or username').fill(EMAIL); await page.getByPlaceholder('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Login' }).click(); + await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(ONBOARDING_OR_PRODUCTS_URL_PATTERN, { timeout: 30_000 }); await finishOnboardingIfVisible(page); @@ -85,14 +86,14 @@ test.describe('Account registration', () => { await expect(page.getByPlaceholder('Username', { exact: true })).toBeVisible(); }); - test('full registration flow: username → email → password → products', { + 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 = 'E2eNewPass123!'; + const password = 'correct-horse-battery-staple-v42'; await page.goto('/new-account'); @@ -112,22 +113,25 @@ test.describe('Account registration', () => { timeout: 3_000, }); await page.getByPlaceholder('Password').fill(password); - await page.getByRole('button', { name: 'Create Account' }).click(); + await page.getByRole('button', { name: 'Create account' }).click(); - // After registration + auto-login the app navigates to /products. - await expect(page).toHaveURL(PRODUCTS_URL_PATTERN, { timeout: 30_000 }); + // 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(); + 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 page.getByRole('button', { name: 'Send reset link' }).click(); await expect(page.getByText(FORGOT_PASSWORD_SUCCESS_PATTERN)).toBeVisible({ timeout: 15_000, 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..796437210 --- /dev/null +++ b/app/e2e/helpers.ts @@ -0,0 +1,215 @@ +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$/; +const SEEDED_PRODUCT_NAME_PATTERN = /^(Dell XPS 13|iPhone 12)$/; +const PRODUCT_DETAIL_URL_PATTERN = /products\/\d+/; +const VIEW_IMAGE_LABEL_PATTERN = /^View image \d+$/; +const DISMISS_BUTTON_NAMES = ['Got it', 'Maybe later', 'Continue'] as const; +// 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. + */ +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). + const welcomeHeading = page.getByText('Welcome to Relab', { exact: true }); + const appeared = await welcomeHeading.isVisible({ timeout: 1_000 }).catch(() => false); + if (!appeared) return; + + for (const name of DISMISS_BUTTON_NAMES) { + const button = page.getByRole('button', { name, exact: true }); + // biome-ignore lint/performance/noAwaitInLoops: probe dismiss buttons in order; first visible wins. + if (await button.isVisible({ timeout: 500 }).catch(() => false)) { + await button.click(); + await expect(welcomeHeading).not.toBeVisible({ timeout: 5_000 }); + return; + } + } +} + +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, + }); +} + +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 }); +} + +export async function loginAndReachProducts(page: Page) { + await suppressGuestWelcomeCard(page); + await page.goto('/login'); + await page.getByPlaceholder('Email or username').fill(EMAIL); + await page.getByPlaceholder('Password').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, + }); +} + +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: 'Create 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`, + ); +} + +export async function openSeededProductFromProductsPage(page: Page) { + const seededProduct = page.getByText(SEEDED_PRODUCT_NAME_PATTERN).first(); + await expect(seededProduct).toBeVisible({ timeout: 10_000 }); + await seededProduct.click(); + await expect(page).toHaveURL(PRODUCT_DETAIL_URL_PATTERN, { timeout: 10_000 }); +} + +export async function openProductByNameFromProductsPage(page: Page, name: string) { + const product = page.getByText(name, { exact: true }).first(); + await expect(product).toBeVisible({ timeout: 15_000 }); + 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.getByLabel(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/product-detail.spec.ts b/app/e2e/product-detail.spec.ts new file mode 100644 index 000000000..d303eb268 --- /dev/null +++ b/app/e2e/product-detail.spec.ts @@ -0,0 +1,229 @@ +/** + * 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; +// 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'; +const ADD_PHYSICAL_PROPERTIES_LABEL = 'Add physical properties'; +const ADD_CIRCULARITY_NOTES_LABEL = 'Add circularity notes'; +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_PHYSICAL_PROPERTIES_LABEL }).click(); + const weightInput = page.getByPlaceholder('> 0').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 Physical 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: 'Physical 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('Physical properties', { exact: true }).last(); + await expect(heading).toBeVisible({ timeout: 5_000 }); + // Poll: scrollTo animates, so the heading needs a moment to settle near + // the viewport top. Pre-fix it landed ~a full section short (y >> 200). + await expect + .poll(async () => (await heading.boundingBox())?.y ?? Number.POSITIVE_INFINITY, { + timeout: 5_000, + }) + .toBeLessThan(200); + }); +}); + +// ─── 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: 'Physical 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_PHYSICAL_PROPERTIES_LABEL })).toBeVisible({ + timeout: 5_000, + }); + await expect(page.getByRole('button', { name: ADD_CIRCULARITY_NOTES_LABEL })).toBeVisible({ + timeout: 5_000, + }); + // The Details section (product metadata) is never empty, so it always + // renders in full rather than collapsing to an "Add …" row. Its always- + // present "Product ID: N" line proves it rendered (the section no longer + // carries a redundant inner "Metadata" header — the Section title labels it). + await expect(page.getByText(/Product ID: \d+/)).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); + }); +}); diff --git a/app/e2e/products.spec.ts b/app/e2e/products.spec.ts new file mode 100644 index 000000000..7ab0a889d --- /dev/null +++ b/app/e2e/products.spec.ts @@ -0,0 +1,116 @@ +/** + * 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, +} from './helpers'; + +const LOGIN_URL_PATTERN = /login/; +const ONBOARDING_OR_PRODUCTS_URL_PATTERN = /onboarding|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.getByPlaceholder('Username', { exact: true }).fill(username); + await page.getByTestId('username-next').click(); + await page.getByPlaceholder('Email address').fill(email); + await page.getByTestId('email-next').click(); + await page.getByPlaceholder('Password').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.getByPlaceholder('Email or username').fill(email); + await page.getByPlaceholder('Password').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 }) => { + 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.getByText('Sign in', { exact: true })).toBeVisible(); + }); +}); + +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.getByText('Mine', { exact: true })).toBeVisible(); + await expect(page.getByText('Date', { exact: true })).toBeVisible(); + await expect(page.getByPlaceholder('Search products')).toBeVisible(); + }); + + test('empty state is shown when no products exist', async ({ page }) => { + await registerNewUserAndReachProducts(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.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 }) => { + await loginAndReachProducts(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 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..395840fff --- /dev/null +++ b/app/e2e/profile.spec.ts @@ -0,0 +1,166 @@ +/** + * 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 } 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 page.goto('/login'); + await page.getByPlaceholder('Email or username').fill(EMAIL); + await page.getByPlaceholder('Password').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.getByText('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.getByText('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 91% rename from frontend-app/e2e/sort-filter.spec.ts rename to app/e2e/sort-filter.spec.ts index f331a4a80..f8c8ce754 100644 --- a/frontend-app/e2e/sort-filter.spec.ts +++ b/app/e2e/sort-filter.spec.ts @@ -11,8 +11,10 @@ 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/; +// 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/; @@ -116,8 +118,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 +147,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 @@ -168,10 +172,10 @@ 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 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(); }); }); 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..8bfcb76ab --- /dev/null +++ b/app/global.css @@ -0,0 +1,42 @@ +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/preflight.css" layer(base); +@import "tailwindcss/utilities.css"; +@import "nativewind/theme"; +@import "./src/theme/brand.generated.css"; + +@source "./src"; + +@theme { + --breakpoint-md: 768px; + --breakpoint-lg: 1024px; +} + +@custom-variant dark (&:where(.dark, .dark *)); + +/* + * Phase-2 items before any screen consumes components/base/ui on web: + * 1. (done) dark: utilities follow the app toggle via @custom-variant dark below. + * 2. Native: react-native-css also feeds its colorScheme from an + * Appearance listener; the _layout sync effect re-asserts on system + * scheme changes to avoid being overwritten (see _layout.tsx). + */ + +/* Vendored RNR components (dropdown-menu) expect a "popover" surface; brand.generated.css + * (Task 2) has no separate popover token, so alias it to the existing card surface here + * rather than editing the generated file. */ +@theme inline { + --color-popover: var(--card); + --color-popover-foreground: var(--card-foreground); + + /* Token-semantics collision: the vendored RNR/shadcn primitives use `accent` + * as their NEUTRAL hover/pressed state-layer (button ghost+outline, + * dropdown-menu, toggle, badge, dialog), but brand.generated.css feeds + * `--accent` the *brand* manila (#8F6212) — so every pressed/hover state came + * out manila (the Browse / Forgot-password hovers most visibly). + * assets/DESIGN.md is explicit: manila is a text colour only, never a button + * fill or an interaction state. Alias the state token onto the neutral muted + * surface here rather than editing the generated file (same pattern as + * popover above). The brand manila stays available as `--accent` itself. */ + --color-accent: var(--muted); + --color-accent-foreground: var(--muted-foreground); +} diff --git a/app/jest.config.cjs b/app/jest.config.cjs new file mode 100644 index 000000000..0ac45678d --- /dev/null +++ b/app/jest.config.cjs @@ -0,0 +1,79 @@ +// 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/'], + transformIgnorePatterns: [ + 'node_modules/(?!(?:.pnpm/.*?/node_modules/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|standard-navigation|msw|@mswjs|@open-draft/.*|until-async|rettime|@rn-primitives/.*|lucide-react-native))', + ], + 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..6faf2722d --- /dev/null +++ b/app/justfile @@ -0,0 +1,124 @@ +# Frontend App Tasks + +# 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 + pnpm run test:e2e {{ if env("CI", "") != "" { "--project=chromium" } else { "" } }} + +# Run the full cross-browser E2E matrix. `playwright install` is idempotent (skips +# present browsers); set INSTALL=force to also run the slow --with-deps apt step. +test-e2e-cross-browser: build-web + {{ if env("INSTALL", "") == "force" { "pnpm exec playwright install --with-deps" } else { "pnpm exec playwright install" } }} + 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..ac2c2dada --- /dev/null +++ b/app/metro.config.js @@ -0,0 +1,5 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const { withNativewind } = require('nativewind/metro'); + +const config = getDefaultConfig(__dirname); +module.exports = withNativewind(config); diff --git a/app/nativewind-env.d.ts b/app/nativewind-env.d.ts new file mode 100644 index 000000000..293908981 --- /dev/null +++ b/app/nativewind-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/app/package.json b/app/package.json new file mode 100644 index 000000000..1bf1d05e1 --- /dev/null +++ b/app/package.json @@ -0,0 +1,126 @@ +{ + "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.5", + "@expo/vector-icons": "^15.1.1", + "@hookform/resolvers": "^5.2.2", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-navigation/bottom-tabs": "^7.18.3", + "@react-navigation/elements": "^2.9.26", + "@react-navigation/native": "^7.3.4", + "@rn-primitives/dialog": "^1.5.2", + "@rn-primitives/dropdown-menu": "^1.5.2", + "@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", + "@rn-primitives/tooltip": "^1.5.2", + "@tanstack/react-query": "^5.100.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "expo": "~57.0.6", + "expo-asset": "~57.0.5", + "expo-auth-session": "~57.0.3", + "expo-blur": "~57.0.2", + "expo-clipboard": "~57.0.1", + "expo-constants": "~57.0.5", + "expo-font": "~57.0.1", + "expo-haptics": "~57.0.1", + "expo-image": "~57.0.1", + "expo-image-manipulator": "~57.0.4", + "expo-image-picker": "~57.0.4", + "expo-linear-gradient": "~57.0.1", + "expo-linking": "~57.0.3", + "expo-router": "~57.0.6", + "expo-secure-store": "~57.0.1", + "expo-splash-screen": "~57.0.4", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.1", + "expo-video": "^57.0.1", + "expo-web-browser": "~57.0.1", + "hls.js": "^1.6.16", + "lucide-react-native": "^1.24.0", + "nativewind": "5.0.0-preview.4", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-hook-form": "^7.73.1", + "react-native": "0.86.0", + "react-native-css": "3.0.7", + "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.0", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.25.2", + "react-native-svg": "15.15.4", + "react-native-web": "^0.21.2", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.0", + "tailwind-merge": "^3.6.0", + "use-debounce": "^10.1.1", + "zod": "^4.3.6" + }, + "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.0", + "@tailwindcss/postcss": "4.3.2", + "@testing-library/react-native": "^13.3.3", + "@types/jest": "^29.5.14", + "@types/react": "~19.2.17", + "@typescript-eslint/parser": "^8.63.0", + "babel-plugin-react-compiler": "^1.0.0", + "baseline-browser-mapping": "^2.10.43", + "eslint": "^10.7.0", + "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.2", + "jest-junit": "^17.0.0", + "msw": "^2.15.0", + "openapi-typescript": "^7.13.0", + "postcss": "8.5.16", + "serve": "^14.2.6", + "tailwindcss": "4.3.2", + "ts-node": "^10.9.2", + "typescript": "catalog:" + }, + "private": true +} diff --git a/app/playwright.config.ts b/app/playwright.config.ts new file mode 100644 index 000000000..5029e0018 --- /dev/null +++ b/app/playwright.config.ts @@ -0,0 +1,68 @@ +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 + webServer: process.env.BASE_URL + ? undefined + : { + command: 'pnpm exec serve dist -l 18011 --no-clipboard', + url: 'http://localhost:18011', + reuseExistingServer: !process.env.CI, + }, +}); 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 85% rename from frontend-app/src/__tests__/theme-regressions.test.ts rename to app/src/__tests__/theme-regressions.test.ts index e8574e698..da60d69d5 100644 --- a/frontend-app/src/__tests__/theme-regressions.test.ts +++ b/app/src/__tests__/theme-regressions.test.ts @@ -6,25 +6,25 @@ 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', ].map((file) => path.join(SRC_ROOT, file)); 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 84% 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..a00293609 100644 --- a/frontend-app/src/__tests__/screens/forgot-password.integration.test.tsx +++ b/app/src/app/(auth)/__tests__/forgot-password.integration.test.tsx @@ -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..34c4d5904 --- /dev/null +++ b/app/src/app/(auth)/__tests__/login.integration.test.tsx @@ -0,0 +1,804 @@ +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>; + +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(); + }); + + 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 waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: expect.stringMatching(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 waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: expect.stringMatching(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 waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Account suspended', + message: expect.stringMatching(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 waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Account suspended', + message: expect.stringMatching(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 waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: expect.stringMatching(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 waitFor(() => { + expect(mockDialogApi.alert).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't sign in", + message: expect.stringMatching(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('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..0c4711fb5 --- /dev/null +++ b/app/src/app/(auth)/__tests__/new-account.integration.test.tsx @@ -0,0 +1,328 @@ +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, + 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..2bc96c6d5 --- /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 = /An error occurred/; +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..e9a159170 --- /dev/null +++ b/app/src/app/(auth)/forgot-password.tsx @@ -0,0 +1,119 @@ +import { useRouter } from 'expo-router'; +import { useCallback } from 'react'; +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 { Card } from '@/components/base/Card'; +import { TextInput } from '@/components/base/TextInput'; +import { useForgotPassword } from '@/features/auth/usePasswordReset'; +import { useAppTheme } from '@/theme'; + +export default function ForgotPasswordScreen() { + const theme = useAppTheme(); + const router = useRouter(); + const { control, fieldError, isValid, isSubmitting, success, error, submit } = + useForgotPassword(); + const goToLogin = useCallback(() => router.replace('/login'), [router]); + const renderEmail = useCallback( + ({ + field: { onChange, value }, + }: { + field: { onChange: (text: string) => void; value: string }; + }) => ( + + Email + + + ), + [isSubmitting, theme.colors.outline], + ); + + return ( + + + + Forgot password + + {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. + + + + + {error || fieldError ? ( + + {error ?? fieldError} + + ) : null} + + + Send reset link + + + + + Back to login + + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + cardContent: { + padding: 16, + gap: 16, + }, + field: { + gap: 4, + }, + input: { + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + successContainer: { + gap: 12, + alignItems: 'center', + paddingVertical: 16, + }, + actions: { + flexDirection: 'row', + gap: 16, + justifyContent: 'center', + marginTop: 8, + }, +}); diff --git a/app/src/app/(auth)/login.tsx b/app/src/app/(auth)/login.tsx new file mode 100644 index 000000000..4fb48151a --- /dev/null +++ b/app/src/app/(auth)/login.tsx @@ -0,0 +1,34 @@ +import { + LoginBrandHero, + LoginCard, + LoginDivider, + LoginFormSection, + LoginLayout, + LoginOAuthSection, + LoginSecondaryAction, +} from '@/components/auth/LoginSections'; +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 ( + + + + + + + + + + ); +} diff --git a/app/src/app/(auth)/mfa.tsx b/app/src/app/(auth)/mfa.tsx new file mode 100644 index 000000000..91d2aa6e1 --- /dev/null +++ b/app/src/app/(auth)/mfa.tsx @@ -0,0 +1,99 @@ +import { useCallback } from 'react'; +import { View } from 'react-native'; +import { AuthScreen } from '@/components/auth/AuthScreen'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { Card } from '@/components/base/Card'; +import { OtpInput } from '@/components/base/OtpInput'; +import { TextInput } from '@/components/base/TextInput'; +import { useMfaScreen } from '@/features/auth/useMfaScreen'; +import { useAppTheme } from '@/theme'; + +export default function MfaScreen() { + const theme = useAppTheme(); + const { + code, + recoveryCode, + useRecoveryCode, + isSubmitting, + canSubmit, + tokenPresent, + visibleError, + handleCodeChange, + handleRecoveryCodeChange, + toggleRecoveryMode, + submit, + goToLogin, + } = useMfaScreen(); + const submitCurrent = useCallback(() => submit(), [submit]); + + return ( + + + + + Two-step verification + + {useRecoveryCode + ? 'Enter one of your saved recovery codes.' + : 'Enter the 6-digit code from your authenticator app.'} + + + + {useRecoveryCode ? ( + + Recovery code + + + ) : ( + + )} + + {visibleError ? ( + {visibleError} + ) : null} + + + {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; +}) { + const theme = useAppTheme(); + const styles = createStyles(theme); + const renderUsername = useCallback( + ({ + field: { onChange, value }, + }: { + field: { onChange: (text: string) => void; value: string }; + }) => ( + + Username + + + ), + [submitUsername, theme.colors.outline, theme.tokens.surface.card, styles.input, styles.field], + ); + + return ( + + + {/* 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 router = useRouter(); + const dialog = useDialog(); + const { refetch } = useAuth(); + + const { + control, + handleSubmit, + formState: { isValid, isSubmitting }, + } = useForm({ + resolver: zodResolver(onboardingSchema), + mode: 'onChange', + defaultValues: { username: '' }, + }); + + const submitUsername = handleSubmit(async (data: OnboardingFormValues) => { + try { + await updateUser({ username: data.username }); + await refetch(false); + router.replace({ pathname: '/products', params: { authenticated: 'true' } }); + } catch (error: unknown) { + dialog.alert({ + title: "Couldn't save username", + message: getErrorMessage(error, 'It might already be taken.'), + }); + } + }); + + return ( + + + + ); +} + +const createStyles = memoizeByTheme((theme: AppTheme) => + StyleSheet.create({ + // Sizing and centering come from AuthScreen; this only sets inner rhythm. + body: { + gap: 12, + }, + title: { + fontSize: 32, + fontWeight: 'bold', + textAlign: 'center', + color: theme.colors.onBackground, + }, + subtitle: { + fontSize: 16, + textAlign: 'center', + marginBottom: 10, + color: theme.colors.onBackground, + }, + field: { + gap: 4, + }, + input: { + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + }), +); diff --git a/app/src/app/(auth)/reset-password.tsx b/app/src/app/(auth)/reset-password.tsx new file mode 100644 index 000000000..84b5828af --- /dev/null +++ b/app/src/app/(auth)/reset-password.tsx @@ -0,0 +1,157 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { Controller } from 'react-hook-form'; +import { Pressable, View } from 'react-native'; +import { AuthScreen } from '@/components/auth/AuthScreen'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { Card } from '@/components/base/Card'; +import { Icon } from '@/components/base/Icon'; +import { TextInput } from '@/components/base/TextInput'; +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, fieldError, 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]); + const renderPassword = useCallback( + ({ + field: { onChange, value }, + }: { + field: { onChange: (text: string) => void; value: string }; + }) => ( + + New password + + + + + + + + ), + [ + showPassword, + isSubmitting, + toggleShowPassword, + theme.colors.outline, + theme.colors.onSurfaceVariant, + ], + ); + const renderConfirmPassword = useCallback( + ({ + field: { onChange, value }, + }: { + field: { onChange: (text: string) => void; value: string }; + }) => ( + + Confirm new password + + + ), + [showPassword, isSubmitting, theme.colors.outline], + ); + + return ( + + + + Reset password + + {success ? ( + + + Password reset. You can now sign in. + + Redirecting to login… + + ) : ( + <> + + + + + {error || fieldError ? ( + + {error ?? fieldError} + + ) : null} + + + Reset password + + + + + Back to login + + + + )} + + + + ); +} diff --git a/app/src/app/(auth)/verify.tsx b/app/src/app/(auth)/verify.tsx new file mode 100644 index 000000000..68c67c383 --- /dev/null +++ b/app/src/app/(auth)/verify.tsx @@ -0,0 +1,73 @@ +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { AuthScreen } from '@/components/auth/AuthScreen'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import { Card } from '@/components/base/Card'; +import { useVerifyEmail } from '@/features/auth/useVerifyEmail'; +import { useAppTheme } from '@/theme'; + +export default function VerifyEmailScreen() { + const theme = useAppTheme(); + const { isLoading, error, success, isLoggedIn, goToLogin, goHome } = useVerifyEmail(); + + return ( + + + + Verify email + + {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: { + padding: 16, + gap: 16, + alignItems: 'center', + paddingVertical: 32, + }, + centeredState: { + gap: 12, + alignItems: 'center', + }, +}); diff --git a/app/src/app/__tests__/_layout.test.tsx b/app/src/app/__tests__/_layout.test.tsx new file mode 100644 index 000000000..d51e0bf3f --- /dev/null +++ b/app/src/app/__tests__/_layout.test.tsx @@ -0,0 +1,141 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen, waitFor } from '@testing-library/react-native'; +import type React from 'react'; +import { Text, View } from 'react-native'; +import { AppStack, HeaderRight, Providers } from '@/app/_layout'; +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', () => { + const { DefaultTheme, DarkTheme, ThemeProvider } = require('@react-navigation/native'); + 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 }, + })), + }; +}); + +describe('HeaderRight', () => { + 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', () => { + renderWithProviders( + + + Hello + + , + { withAuth: true }, + ); + expect(screen.getByTestId('child')).toBeOnTheScreen(); + }); +}); + +describe('AppStack header visibility', () => { + beforeEach(() => { + for (const key of Object.keys(mockScreenOptions)) delete mockScreenOptions[key]; + }); + + // AppStack is memo()'d on {isDark, isLg}. This proves isLg reaching the + // component as a real prop is enough for React.memo's default shallow + // compare to pick it up on rerender — the TopNav-covered screens' headers + // flip with it, and screens TopNav doesn't cover (back button / name + // editing / add-camera) are untouched either way. + it('hides headers only for the TopNav-covered screens, only when isLg, and recomputes on rerender', () => { + const { rerender } = render(); + + expect(mockScreenOptions['products/index']?.headerShown).not.toBe(false); + expect(mockScreenOptions.account?.headerShown).not.toBe(false); + expect(mockScreenOptions['cameras/index']?.headerShown).not.toBe(false); + expect(mockScreenOptions['cameras/add']?.headerShown).toBeUndefined(); + + rerender(); + + expect(mockScreenOptions['products/index']?.headerShown).toBe(false); + expect(mockScreenOptions.account?.headerShown).toBe(false); + expect(mockScreenOptions['cameras/index']?.headerShown).toBe(false); + // Detail/auth/add screens keep their header regardless of isLg. + expect(mockScreenOptions['cameras/add']?.headerShown).toBeUndefined(); + expect(mockScreenOptions['cameras/[id]']?.headerShown).toBeUndefined(); + + rerender(); + + expect(mockScreenOptions['products/index']?.headerShown).not.toBe(false); + expect(mockScreenOptions.account?.headerShown).not.toBe(false); + expect(mockScreenOptions['cameras/index']?.headerShown).not.toBe(false); + }); +}); diff --git a/app/src/app/__tests__/account.test.tsx b/app/src/app/__tests__/account.test.tsx new file mode 100644 index 000000000..361bef06c --- /dev/null +++ b/app/src/app/__tests__/account.test.tsx @@ -0,0 +1,391 @@ +import { act, fireEvent, waitFor } from '@testing-library/react-native'; +import type { ReactNode } from 'react'; + +// 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 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, + 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(), +})); + +jest.mock('@/services/api/auth/authentication', () => ({ + getToken: jest.fn().mockResolvedValue('mock-token'), + logout: mockLogout, + unlinkOAuth: jest.fn().mockResolvedValue(undefined), + updateUser: mockUpdateUser, + verify: mockVerify, +})); + +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 }, +}; + +// Both modules are require()d lazily, not imported: a top-level import of either +// pulls in @/services/api/auth/authentication before the mock* consts above are +// initialised, so the jest.mock factory would capture them as undefined. +function renderProfileTab() { + const { renderWithProviders } = require('@/test-utils/render.tsx'); + const ProfileTab = require('@/app/account.tsx').default; + 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 loading dots 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 { getAllByText } = renderProfileTab(); + // statsLoading=true renders '...' for each of the four stat values + expect(getAllByText('...')).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(mockRouterPush).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/__tests__/category-selection.test.tsx b/app/src/app/__tests__/category-selection.test.tsx new file mode 100644 index 000000000..d92174b34 --- /dev/null +++ b/app/src/app/__tests__/category-selection.test.tsx @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useRouter } from 'expo-router'; +import CategorySelection from '@/app/category-selection'; +import { setPendingTypeSelection } from '@/features/products/pendingTypeSelection'; +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/; + +jest.mock('@/context/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/services/cpv', () => ({ + loadCPV: jest.fn(), +})); + +jest.mock('@/features/products/pendingTypeSelection', () => ({ + setPendingTypeSelection: jest.fn(), + takePendingTypeSelection: jest.fn(), +})); + +const mockBack = jest.fn(); +const mockReplace = jest.fn(); + +describe('CategorySelection', () => { + beforeEach(() => { + jest.clearAllMocks(); + 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('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(); + }); + }); +}); 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/_layout.tsx b/app/src/app/_layout.tsx new file mode 100644 index 000000000..332e956b3 --- /dev/null +++ b/app/src/app/_layout.tsx @@ -0,0 +1,261 @@ +// biome-ignore lint/style/noRestrictedImports: global.css lives at the app root (outside src/), so it has no '@/' alias path. +import '../../global.css'; + +import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +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 { colorScheme as nativewindColorScheme } from 'react-native-css'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { KeyboardProvider } from 'react-native-keyboard-controller'; +import { BrandHeaderTitle } from '@/components/base/BrandHeaderTitle'; +import { DialogProvider } from '@/components/base/DialogProvider'; +import { HeaderBackButton } from '@/components/base/HeaderBackButton'; +import { HeaderRightPill } from '@/components/base/HeaderRightPill'; +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, useSystemColorScheme } from '@/context/themeMode'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { createNavigationThemes, getAppTheme } from '@/theme'; +import { AppThemeProvider } from '@/theme/AppThemeProvider'; +import { type BackgroundOverlay, useBackgroundOverlay } from '@/utils/router/background'; +import { getUsernameOnboardingRedirect } from '@/utils/router/onboarding'; +import { getProductsHeaderStyle } from '@/utils/router/styles'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, // 30 s; products are fresh for 30 s + retry: 1, + }, + }, +}); + +export default function RootLayout() { + return ( + + + + ); +} + +export function HeaderRight() { + 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. + + ) : ( + + )} + + ); +} + +// memo: AppShell re-renders on every route change and stream-telemetry tick, and +// this rebuilds every screen's options object and header renderer. Bail unless +// isDark or isLg changes. Both are plain props on AppStack — React.memo's default +// shallow-prop compare already covers isLg without a custom comparator, but it +// only works if isLg keeps being passed as an actual prop; don't move this back +// to reading useBreakpoint() from inside a component the memo doesn't see. +export const AppStack = memo(function AppStack({ + isDark, + isLg, +}: { + isDark: boolean; + isLg: boolean; +}) { + const router = useRouter(); + const theme = getAppTheme(isDark ? 'dark' : 'light'); + const goToProducts = useCallback(() => router.replace('/products'), [router]); + const goToCameras = useCallback(() => router.replace('/cameras'), [router]); + // TopNav (mounted in AppShell) already covers these on >=lg web, so the stack's + // own header would just duplicate it. Every other screen keeps its header. + const hideForTopNav = isLg; + return ( + + + , + ...getProductsHeaderStyle(theme), + headerRight: () => , + headerLeft: () => null, + headerShown: !hideForTopNav, + }} + /> + , + headerShown: !hideForTopNav, + }} + /> + + + + + + + + + + , + }} + /> + , + }} + /> + + ); +}); + +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); + const { isLg } = useBreakpoint(); + + // 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(); + const systemColorScheme = useSystemColorScheme(); + + // Keep NativeWind's (react-native-css) color scheme in sync with the app's own + // theme mode so `.dark:root` CSS variables (Task 2) apply to RNR components. + // biome-ignore lint/correctness/useExhaustiveDependencies: systemColorScheme is unused in the body on purpose — see the dep array comment below. + useEffect(() => { + if (Platform.OS === 'web') { + // react-native-css's setter routes through Appearance.setColorScheme, + // which react-native-web doesn't implement; drive the CSS hooks directly. + document.documentElement.classList.toggle('dark', colorScheme === 'dark'); + document.documentElement.dataset.theme = colorScheme; + return; + } + // react-native-css@3.0.7 also wires an Appearance listener that writes this + // same colorScheme observable, so an OS scheme flip can silently overwrite a + // user-forced theme after this effect last ran. Re-run on system changes too + // (not just `colorScheme`) so a forced theme gets re-asserted afterwards. + nativewindColorScheme.set(colorScheme); + }, [colorScheme, systemColorScheme]); + + return ( + + + + + {children} + + + + + ); +} diff --git a/app/src/app/account.tsx b/app/src/app/account.tsx new file mode 100644 index 000000000..8abaeafe6 --- /dev/null +++ b/app/src/app/account.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/frontend-app/src/app/cameras/[id].tsx b/app/src/app/cameras/[id].tsx similarity index 86% rename from frontend-app/src/app/cameras/[id].tsx rename to app/src/app/cameras/[id].tsx index 93c630902..8b4a8e39d 100644 --- a/frontend-app/src/app/cameras/[id].tsx +++ b/app/src/app/cameras/[id].tsx @@ -1,19 +1,17 @@ +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 { - CameraDetailErrorState, - CameraDetailLayout, - CameraDetailLoadingState, -} from '@/components/cameras/detail/shared'; -import { useCameraDetailScreen } from '@/hooks/cameras/useCameraDetailScreen'; +import { useCameraDetailScreen } from '@/features/cameras/useCameraDetailScreen'; function CameraDetailContent({ screen, @@ -85,14 +83,11 @@ export default function CameraDetailScreen() { const { screen, preview, dialogs, actions } = useCameraDetailScreen(); if (!screen.user) return null; - if (screen.isLoading) return ; + if (screen.isLoading) return ; if (screen.isError || !screen.camera) { return ( - + ); } diff --git a/app/src/app/cameras/__tests__/add.test.tsx b/app/src/app/cameras/__tests__/add.test.tsx new file mode 100644 index 000000000..6003f11b4 --- /dev/null +++ b/app/src/app/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/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 *'); + 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 *'); + 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 *'); + 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/cameras/__tests__/camera-detail.integration.test.tsx similarity index 93% rename from frontend-app/src/__tests__/screens/camera-detail.integration.test.tsx rename to app/src/app/cameras/__tests__/camera-detail.integration.test.tsx index c1efb6d18..e906df61f 100644 --- a/frontend-app/src/__tests__/screens/camera-detail.integration.test.tsx +++ b/app/src/app/cameras/__tests__/camera-detail.integration.test.tsx @@ -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 () => { + it('does not auto-load the live preview for an online camera and sets the screen title', async () => { renderWithProviders(); - 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 () => { + it('can load and stop the live preview without leaving the detail screen', async () => { renderWithProviders(); - 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(); - 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 () => { diff --git a/app/src/app/cameras/__tests__/index.test.tsx b/app/src/app/cameras/__tests__/index.test.tsx new file mode 100644 index 000000000..a432f8470 --- /dev/null +++ b/app/src/app/cameras/__tests__/index.test.tsx @@ -0,0 +1,526 @@ +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/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 mockUseIsDesktop = jest.fn<() => boolean>(() => 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/useIsDesktop', () => ({ + useIsDesktop: () => mockUseIsDesktop(), +})); + +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(); + + 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({ + data: [], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + mockUseLocalConnection.mockReturnValue({ + mode: 'relay', + localBaseUrl: null, + }); + mockUseIsDesktop.mockReturnValue(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({ + data: [ + { + id: 'cam-1', + name: 'Workbench Camera', + description: 'Bench setup', + status: { connection: 'online' }, + }, + ], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText('Workbench Camera')).toBeOnTheScreen(); + 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({ + data: [ + { + id: 'cam-1', + name: 'Direct Camera', + description: 'Ethernet setup', + status: { connection: 'offline', last_seen_at: null, details: null }, + }, + ], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + 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({ + data: undefined, + isLoading: false, + isFetching: false, + isError: true, + error: new Error('Broken camera list'), + refetch: mockRefetch, + }); + + renderWithProviders(, { withDialog: true }); + + expect(screen.getByText('Error: 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({ + data: undefined, + isLoading: true, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + 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({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + 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({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + 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({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + 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({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + 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({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + 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: Error: timeout')).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({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + 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({ + data: [ + { id: 'cam-1', name: 'Cam A', description: '', status: { connection: 'online' } }, + { id: 'cam-2', name: 'Cam B', description: '', status: { connection: 'online' } }, + ], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + 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({ + data: [ + { id: 'cam-1', name: 'Online Cam', description: '', status: { connection: 'online' } }, + { id: 'cam-2', name: 'Offline Cam', description: '', status: { connection: 'offline' } }, + ], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + 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({ + 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' } }, + ], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + 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({ + data: [{ id: 'cam-1', name: 'Cam', description: '', status: { connection: 'online' } }], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + 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({ + data: [], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + 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', () => { + mockUseIsDesktop.mockReturnValue(true); + mockUseCamerasQuery.mockReturnValue({ + data: [], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + const { UNSAFE_getByProps } = renderWithProviders(, { withDialog: true }); + + const list = UNSAFE_getByProps({ numColumns: 3 }); + expect(list).toBeTruthy(); + }); + + it('mobile layout uses 2 columns', () => { + mockUseIsDesktop.mockReturnValue(false); + mockUseCamerasQuery.mockReturnValue({ + data: [], + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + }); + + const { UNSAFE_getByProps } = renderWithProviders(, { withDialog: true }); + + const list = UNSAFE_getByProps({ numColumns: 2 }); + expect(list).toBeTruthy(); + }); +}); diff --git a/app/src/app/cameras/add.tsx b/app/src/app/cameras/add.tsx new file mode 100644 index 000000000..759ccecd7 --- /dev/null +++ b/app/src/app/cameras/add.tsx @@ -0,0 +1,228 @@ +import { useCallback } from 'react'; +import { Controller } from 'react-hook-form'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { AppText } from '@/components/base/AppText'; +import { Icon } from '@/components/base/Icon'; +import { MutedText } from '@/components/base/MutedText'; +import { PageContainer } from '@/components/base/PageContainer'; +import { TextInput } from '@/components/base/TextInput'; +import { Separator } from '@/components/base/ui/separator'; +import { radius } from '@/constants'; +import { sanitizePairingCode, useAddCameraForm } from '@/features/cameras/useAddCameraForm'; +import { useAppTheme } from '@/theme'; + +function PairingSuccessDialog({ visible, onDismiss }: { visible: boolean; onDismiss: () => void }) { + 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 renderPairingCode = useCallback( + ({ + field: { value, onChange }, + }: { + field: { value: string; onChange: (text: string) => void }; + }) => ( + onChange(sanitizePairingCode(v))} + maxLength={6} + autoCapitalize="characters" + accessibilityLabel="Pairing code" + style={[ + styles.input, + { + fontFamily: 'monospace', + fontSize: 20, + textAlign: 'center', + borderColor: theme.colors.outline, + }, + ]} + /> + ), + [theme.colors.outline], + ); + + const renderName = useCallback( + ({ + field: { value, onChange }, + fieldState: { error }, + }: { + field: { value: string; onChange: (text: string) => void }; + fieldState: { error?: unknown }; + }) => { + const hasError = Boolean(error) && value.trim().length > 0; + return ( + + ); + }, + [ + theme.colors.outline, + theme.colors.errorContainer, + theme.colors.onErrorContainer, + theme.tokens.status.danger, + ], + ); + + const renderDescription = useCallback( + ({ + field: { value, onChange }, + }: { + field: { value: string | undefined; onChange: (text: string) => void }; + }) => ( + + ), + [theme.colors.outline], + ); + + 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) + + + + + + + Make sure your Raspberry Pi is powered on and has{' '} + + PAIRING_BACKEND_URL + {' '} + set in its .env file. The pairing code appears on the RPi setup page and in the + startup logs. + + + + + + Pair camera + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingTop: 16, + paddingBottom: 48, + }, + form: { + gap: 12, + }, + sectionLabel: { + opacity: 0.5, + marginBottom: 4, + }, + sectionHelp: { + marginBottom: 8, + opacity: 0.6, + }, + fieldLabel: { + opacity: 0.6, + marginBottom: -4, + }, + divider: { + marginVertical: 4, + }, + input: { + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + marginBottom: 4, + }, + infoBox: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + padding: 12, + borderRadius: radius.card, + }, + successContent: { + alignItems: 'center', + gap: 12, + paddingTop: 24, + }, + dialogActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 16, + }, +}); diff --git a/app/src/app/cameras/index.tsx b/app/src/app/cameras/index.tsx new file mode 100644 index 000000000..7eeb7d1d5 --- /dev/null +++ b/app/src/app/cameras/index.tsx @@ -0,0 +1,72 @@ +import { useCallback } from 'react'; +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, CamerasSnackbar } from '@/components/cameras/screen/Chrome'; +import { CamerasGrid } from '@/components/cameras/screen/Grid'; +import { useCamerasScreen } from '@/features/cameras/useCamerasScreen'; + +export default function CamerasScreen() { + const { screen, selection, streaming, actions } = useCamerasScreen(); + const { refetch } = screen; + const handleStartStream = async () => streaming.handleStartStream(); + const handleRetry = useCallback(() => refetch(), [refetch]); + + if (!screen.user) return null; + if (screen.isLoading) return ; + if (screen.isError) { + return ( + + ); + } + + return ( + <> + + + + + + + + + + + + + ); +} diff --git a/app/src/app/category-selection.tsx b/app/src/app/category-selection.tsx new file mode 100644 index 000000000..ce42bbf62 --- /dev/null +++ b/app/src/app/category-selection.tsx @@ -0,0 +1,195 @@ +import { useCallback } from 'react'; +import { + FlatList, + Pressable, + type PressableStateCallbackType, + StyleSheet, + Text, + View, +} from 'react-native'; + +import { CenteredSpinner } from '@/components/base/CenteredSpinner'; +import { Icon } from '@/components/base/Icon'; +import { PageContainer } from '@/components/base/PageContainer'; +import { Searchbar } from '@/components/base/Searchbar'; +import CPVCard from '@/components/product/CPVCard'; +import { 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, + searchQuery, + setSearchQuery, + selectBranch, + moveUp, + selectType, + } = useCategorySelection(); + + const { colors } = useAppTheme(); + const renderItem = useCallback( + ({ item }: { item: CPVCategory }) => ( + + ), + [selectType, selectBranch], + ); + + if (!user) return null; + if (!cpvClass) { + return ; + } + + return ( + // phoneFullBleed: the search bar, blurb, and list already own their 15px + // phone insets, so only the desktop centering/cap is wanted here. + + + + Search by name or description, or browse with the 'Subcategories' button on each + card. Tap or click a card to select it. + + {history.length > 1 && } + 1 ? 152 : 85, + marginBottom: 20, + }} + data={filtered} + renderItem={renderItem} + /> + + ); +} + +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 CPVHistory({ history, onPress }: { history: CPVCategory[]; onPress?: () => void }) { + const { colors } = useAppTheme(); + const historyStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.historyContainer, + // Interactive surface — primary family, never the manila accent + // (MD3 `tertiary` maps to the brand accent; DESIGN.md keeps manila to text). + { backgroundColor: colors.primaryContainer }, + pressed && { opacity: 0.5 }, + ], + [colors], + ); + return ( + + + + {history[history.length - 1].description} + + + ); +} + +function CPVLink({ CPV, onPress }: { CPV: CPVCategory; onPress?: () => void }) { + const { colors } = useAppTheme(); + const linkStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.linkContainer, + { backgroundColor: colors.secondaryContainer }, + pressed && { opacity: 0.5 }, + ], + [colors], + ); + + if (CPV.directChildren.length <= 0) { + return ; + } + + return ( + + + {`${CPV.directChildren.length} subcategories`} + + + + ); +} + +const styles = StyleSheet.create({ + linkContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 5, + height: 30, + paddingHorizontal: 12, + }, + linkText: { + fontSize: 14, + textAlign: 'right', + }, + historyContainer: { + position: 'absolute', + flexDirection: 'row', + gap: 10, + padding: 10, + height: 60, + alignItems: 'center', + top: 80, + left: 15, + right: 15, + zIndex: 1, + borderRadius: radius.control, + }, + historyText: { + flexShrink: 1, + }, +}); diff --git a/app/src/app/components/[id]/__tests__/index.test.tsx b/app/src/app/components/[id]/__tests__/index.test.tsx new file mode 100644 index 000000000..da2c91cad --- /dev/null +++ b/app/src/app/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/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/components/[id]/components/__tests__/new.test.tsx b/app/src/app/components/[id]/components/__tests__/new.test.tsx new file mode 100644 index 000000000..ec6b97c32 --- /dev/null +++ b/app/src/app/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/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/components/[id]/components/new.tsx b/app/src/app/components/[id]/components/new.tsx new file mode 100644 index 000000000..5aeb28947 --- /dev/null +++ b/app/src/app/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/components/[id]/index.tsx b/app/src/app/components/[id]/index.tsx new file mode 100644 index 000000000..158da7a54 --- /dev/null +++ b/app/src/app/components/[id]/index.tsx @@ -0,0 +1,5 @@ +import { EntityDetailPage } from '@/components/product/detail/EntityDetailPage'; + +export default function ComponentPage() { + return ; +} 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/products/[id]/__tests__/index.test.tsx b/app/src/app/products/[id]/__tests__/index.test.tsx new file mode 100644 index 000000000..83cf66bab --- /dev/null +++ b/app/src/app/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/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/products/[id]/__tests__/product-page-state.integration.test.tsx b/app/src/app/products/[id]/__tests__/product-page-state.integration.test.tsx new file mode 100644 index 000000000..70cb0bf37 --- /dev/null +++ b/app/src/app/products/[id]/__tests__/product-page-state.integration.test.tsx @@ -0,0 +1,670 @@ +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/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(), + // Never invokes its callback, so useScreenFocused keeps its initial `true`. + useFocusEffect: 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-bold')).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('Something went wrong')).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')); + expect(indexOf('Physical properties')).toBeGreaterThan(indexOf('ProductComponents')); + expect(indexOf('ProductPhysicalProperties')).toBeGreaterThan(indexOf('Physical properties')); + expect(indexOf('Circularity')).toBeGreaterThan(indexOf('ProductPhysicalProperties')); + expect(indexOf('ProductCircularityProperties')).toBeGreaterThan(indexOf('Circularity')); + expect(indexOf('Media')).toBeGreaterThan(indexOf('ProductCircularityProperties')); + expect(indexOf('ProductVideo')).toBeGreaterThan(indexOf('Media')); + expect(indexOf('Details')).toBeGreaterThan(indexOf('ProductVideo')); + expect(indexOf(`Meta:${fullProduct.name}`)).toBeGreaterThan(indexOf('Details')); + }); + + it('hides an empty circularity section in view mode and shows an add-row in edit mode', () => { + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: baseProduct, + editMode: false, + } as never); + + const { rerender } = renderWithProviders(, { withDialog: true }); + + expect(screen.queryByText('Circularity')).toBeNull(); + expect(screen.queryByText('ProductCircularityProperties')).toBeNull(); + + mockUseProductForm.mockReturnValue({ + ...baseFormReturn, + product: baseProduct, + editMode: true, + } as never); + + rerender(); + + expect(screen.getByText('Add circularity notes')).toBeOnTheScreen(); + expect(screen.queryByText('ProductCircularityProperties')).toBeNull(); + }); + + 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/products/[id]/components/__tests__/new.test.tsx b/app/src/app/products/[id]/components/__tests__/new.test.tsx new file mode 100644 index 000000000..a95d70fc8 --- /dev/null +++ b/app/src/app/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/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/products/[id]/components/new.tsx b/app/src/app/products/[id]/components/new.tsx new file mode 100644 index 000000000..17614dcb1 --- /dev/null +++ b/app/src/app/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/products/[id]/index.tsx b/app/src/app/products/[id]/index.tsx new file mode 100644 index 000000000..47d83405c --- /dev/null +++ b/app/src/app/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/products/__tests__/new.test.tsx b/app/src/app/products/__tests__/new.test.tsx new file mode 100644 index 000000000..121b308a3 --- /dev/null +++ b/app/src/app/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/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/frontend-app/src/__tests__/screens/products.integration.test.tsx b/app/src/app/products/__tests__/products.integration.test.tsx similarity index 89% rename from frontend-app/src/__tests__/screens/products.integration.test.tsx rename to app/src/app/products/__tests__/products.integration.test.tsx index 65a55408a..0a00cd073 100644 --- a/frontend-app/src/__tests__/screens/products.integration.test.tsx +++ b/app/src/app/products/__tests__/products.integration.test.tsx @@ -29,7 +29,7 @@ jest.mock('@/context/auth', () => ({ useAuth: () => mockUseAuth(), })); -jest.mock('@/hooks/products/useProductsWelcomeCard', () => { +jest.mock('@/features/products/useProductsWelcomeCard', () => { const React = require('react'); return { @@ -63,9 +63,9 @@ jest.mock('@/hooks/products/useProductsWelcomeCard', () => { }; }); -jest.mock('@/components/common/dialogContext', () => { - const actual = jest.requireActual( - '@/components/common/dialogContext', +jest.mock('@/components/base/dialogContext', () => { + const actual = jest.requireActual( + '@/components/base/dialogContext', ); return { ...actual, @@ -128,7 +128,8 @@ jest.mock('react-native/Libraries/Lists/FlatList', () => { jest.mock('expo-image', () => { const { View } = jest.requireActual('react-native'); - return { ImageBackground: View }; + // WelcomeCard renders the brand mark via ; both exports must be stubbed. + return { Image: View, ImageBackground: View }; }); jest.mock('expo-linear-gradient', () => { @@ -136,11 +137,7 @@ jest.mock('expo-linear-gradient', () => { return { LinearGradient: View }; }); -jest.mock('@/services/newProductStore', () => ({ - setNewProductIntent: jest.fn(), -})); - -jest.mock('@/components/common/ProductCard', () => { +jest.mock('@/components/product/ProductCard', () => { return function ProductCardMock({ product }: { product: { name: string } }) { const React = jest.requireActual('react'); const { Text } = jest.requireActual('react-native'); @@ -148,7 +145,7 @@ jest.mock('@/components/common/ProductCard', () => { }; }); -jest.mock('@/components/common/ProductCardSkeleton', () => { +jest.mock('@/components/product/ProductCardSkeleton', () => { return function ProductCardSkeletonMock() { const React = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); @@ -175,17 +172,15 @@ jest.mock('@tanstack/react-query', () => { }; }); -jest.mock('@/hooks/useProductQueries', () => ({ +jest.mock('@/features/products/queries', () => ({ DEFAULT_PRODUCT_SORT: ['-created_at'], productsQueryOptions: (...args: unknown[]) => ({ __mockResult: mockUseProductsQuery(...args), }), - useProductsQuery: (...args: unknown[]) => mockUseProductsQuery(...args), useSearchBrandsQuery: (...args: unknown[]) => mockUseBrandsQuery(...args), useSearchProductTypesQuery: (...args: unknown[]) => mockUseProductTypesQuery(...args), - useProductTypesQuery: jest.fn().mockReturnValue({ data: [], isLoading: false }), PRODUCT_SORT_OPTIONS: [ - { label: 'Relevance', value: ['rank'] }, + { label: 'Relevance', value: [] }, { label: 'Newest first', value: ['-created_at'] }, { label: 'Oldest first', value: ['created_at'] }, { label: 'Name A→Z', value: ['name'] }, @@ -234,7 +229,7 @@ beforeEach(async () => { jest.clearAllMocks(); // Default to wide viewport (numColumns=3) so most tests get pagination mode jest.spyOn(require('react-native'), 'useWindowDimensions').mockReturnValue({ - width: 1024, + width: 1280, height: 768, scale: 1, fontScale: 1, @@ -288,7 +283,7 @@ describe('Products screen', () => { it('shows search-specific empty state when searching', async () => { renderProducts(); fireEvent.changeText(screen.getByPlaceholderText('Search products'), 'xyz'); - expect(screen.getByText('No products found matching your search.')).toBeOnTheScreen(); + expect(screen.getByText('No products match your search.')).toBeOnTheScreen(); }); it('resets page to 1 when search text changes (colocated in onChangeText)', async () => { @@ -335,7 +330,7 @@ describe('Products screen', () => { it('renders welcome banner on first visit', async () => { renderProducts(); - expect(screen.getByText('Welcome to RELab')).toBeOnTheScreen(); + expect(screen.getByText('Welcome to Relab')).toBeOnTheScreen(); expect( screen.getByText('Browse products freely. Sign in when you are ready to add your own.'), ).toBeOnTheScreen(); @@ -344,7 +339,7 @@ describe('Products screen', () => { it('dismisses welcome banner when Maybe later is pressed', async () => { renderProducts(); fireEvent.press(screen.getByText('Maybe later')); - expect(screen.queryByText('Welcome to RELab')).toBeNull(); + expect(screen.queryByText('Welcome to Relab')).toBeNull(); }); it('shows the signed-in welcome after a guest dismisses the first-visit banner', async () => { @@ -355,8 +350,8 @@ describe('Products screen', () => { rerender(); expect(screen.getByText('Ready to add products')).toBeOnTheScreen(); - expect(screen.getAllByText('New Product').length).toBeGreaterThan(0); - expect(screen.getByText('profile')).toBeOnTheScreen(); + expect(screen.getAllByText('New product').length).toBeGreaterThan(0); + expect(screen.getByTestId('profile-pill-label')).toBeOnTheScreen(); }); it('prompts unverified signed-in users to verify their email', async () => { @@ -365,14 +360,14 @@ describe('Products screen', () => { renderProducts(); expect(screen.getByText('Verify your email to start creating')).toBeOnTheScreen(); - expect(screen.getAllByText('New Product').length).toBeGreaterThan(0); - expect(screen.getByText('profile')).toBeOnTheScreen(); + expect(screen.getAllByText('New product').length).toBeGreaterThan(0); + 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(mockPush).toHaveBeenCalledWith('/profile'); + expect(mockPush).toHaveBeenCalledWith('/account'); }); }); @@ -382,34 +377,33 @@ describe('Products screen', () => { renderProducts(); expect(screen.getByText('Got it')).toBeOnTheScreen(); - expect(screen.getByText('profile')).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 create products')); + fireEvent.press(screen.getByLabelText('New product')); expect(mockDialogApi.alert).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Sign In Required' }), + expect.objectContaining({ title: 'Sign in required' }), ); }); - it('shows create-product dialog when verified user presses FAB', async () => { + it('navigates to /products/new when verified user presses FAB', async () => { mockUseAuth.mockReturnValue({ user: mockUser() }); renderProducts(); - fireEvent.press(screen.getByLabelText('Create new product')); - expect(mockDialogApi.input).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Create New Product' }), - ); + 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('Create new product')); + fireEvent.press(screen.getByLabelText('New product')); expect(mockDialogApi.alert).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Email Verification Required' }), + expect.objectContaining({ title: 'Verify your email first' }), ); }); }); @@ -418,13 +412,13 @@ describe('Filter chips and modals', () => { it('opens brand filter modal when Brand chip is pressed', async () => { renderProducts(); fireEvent.press(screen.getByText('Brand')); - expect(screen.getByText('Filter by Brand')).toBeOnTheScreen(); + expect(screen.getByText('Filter by brand')).toBeOnTheScreen(); }); it('opens product type filter modal when Type chip is pressed', async () => { renderProducts(); fireEvent.press(screen.getByText('Type')); - expect(screen.getByText('Filter by Product Type')).toBeOnTheScreen(); + expect(screen.getByText('Filter by product type')).toBeOnTheScreen(); }); it('shows Date chip and opens dropdown menu when pressed', async () => { @@ -452,8 +446,7 @@ describe('Filter chips and modals', () => { (useLocalSearchParams as jest.Mock).mockReturnValue({ days: '7' }); renderProducts(); expect(screen.getByText('Last 7d')).toBeOnTheScreen(); - // react-native-paper Chip renders its close button with accessibilityLabel="Close" - const closeBtn = screen.getByLabelText('Close'); + const closeBtn = screen.getByLabelText('Clear Last 7d filter'); fireEvent.press(closeBtn); expect(mockSetParams).toHaveBeenCalledWith({ days: undefined, page: '1' }); }); @@ -482,7 +475,7 @@ describe('Empty-state messages', () => { expect(mockSetParams).toHaveBeenCalledWith({ filterMode: 'mine', page: '1' }); }); - it('shows a mine-specific empty state with a New Product CTA', async () => { + it('shows a mine-specific empty state with a New product CTA', async () => { mockUseAuth.mockReturnValue({ user: mockUser() }); mockUseProductsQuery.mockReturnValue(emptyQueryResult); (useLocalSearchParams as jest.Mock).mockReturnValue({ filterMode: 'mine' }); @@ -490,7 +483,7 @@ describe('Empty-state messages', () => { renderProducts(); expect(screen.getByText("You haven't created any products yet. Tap the")).toBeOnTheScreen(); - expect(screen.getAllByText('New Product').length).toBeGreaterThan(0); + expect(screen.getAllByText('New product').length).toBeGreaterThan(0); }); it('shows creation prompt when authenticated user has no products', async () => { @@ -498,8 +491,8 @@ describe('Empty-state messages', () => { mockUseProductsQuery.mockReturnValue(emptyQueryResult); renderProducts(); - expect(screen.getByText('No products yet. Start by tapping the')).toBeOnTheScreen(); - expect(screen.getAllByText('New Product').length).toBeGreaterThan(0); + expect(screen.getByText('No products yet. Tap the')).toBeOnTheScreen(); + expect(screen.getAllByText('New product').length).toBeGreaterThan(0); }); }); @@ -507,7 +500,7 @@ describe('PaginationControls (multi-column)', () => { beforeEach(() => { // Wide viewport already set globally; confirm numColumns=3 → pagination jest.spyOn(require('react-native'), 'useWindowDimensions').mockReturnValue({ - width: 1024, + width: 1280, height: 768, scale: 1, fontScale: 1, @@ -681,6 +674,15 @@ describe('Mine filter chip', () => { fireEvent.press(screen.getByText('Mine')); expect(mockSetParams).toHaveBeenCalledWith({ filterMode: 'all', page: '1' }); }); + + 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', () => { @@ -715,12 +717,12 @@ describe('Date filter dropdown', () => { }); describe('Sort — Relevance default when searching', () => { - it('defaults to rank sort when a search query is in the URL', async () => { + it('omits explicit sort when a search query is in the URL', async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({ q: 'aluminum' }); renderProducts(); const sortArgs = (mockUseProductsQuery.mock.calls as unknown[][]).map((c) => c[3] as string[]); - expect(sortArgs.some((s) => s[0] === 'rank')).toBe(true); + expect(sortArgs.some((s) => s.length === 0)).toBe(true); }); it('defaults to newest-first sort when there is no search query', async () => { @@ -739,13 +741,6 @@ describe('Sort — Relevance default when searching', () => { expect(sortArgs.some((s) => s[0] === 'name')).toBe(true); }); - it('resets rank sort param when search is cleared', async () => { - // sort=rank in URL but no search query → effect should clear the sort param - (useLocalSearchParams as jest.Mock).mockReturnValue({ sort: 'rank' }); - renderProducts(); - expect(mockSetParams).toHaveBeenCalledWith({ sort: undefined }); - }); - it('shows Relevance option in the sort menu when a search is active', async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({ q: 'aluminum' }); renderProducts(); @@ -760,11 +755,11 @@ describe('Sort — Relevance default when searching', () => { expect(screen.queryByText('Relevance')).toBeNull(); }); - it('sends rank when Relevance is selected from the sort menu', async () => { + it('clears explicit sort when Relevance is selected from the sort menu', async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({ q: 'aluminum' }); renderProducts(); fireEvent.press(screen.getByLabelText('Sort products')); fireEvent.press(screen.getByText('Relevance')); - expect(mockSetParams).toHaveBeenCalledWith({ sort: 'rank', page: '1' }); + expect(mockSetParams).toHaveBeenCalledWith({ sort: undefined, page: '1' }); }, 15_000); }); diff --git a/app/src/app/products/index.tsx b/app/src/app/products/index.tsx new file mode 100644 index 000000000..1d27d29fb --- /dev/null +++ b/app/src/app/products/index.tsx @@ -0,0 +1,138 @@ +import Head from 'expo-router/head'; +import { useCallback } from 'react'; +import { type LayoutChangeEvent, 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 { 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(numColumns); + 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]); + + return ( + <> + + Products · Relab + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/src/app/products/new.tsx b/app/src/app/products/new.tsx new file mode 100644 index 000000000..0dfe344f7 --- /dev/null +++ b/app/src/app/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/users/[username].tsx b/app/src/app/users/[username].tsx new file mode 100644 index 000000000..8a4040b4e --- /dev/null +++ b/app/src/app/users/[username].tsx @@ -0,0 +1,196 @@ +import { Stack } from 'expo-router'; +import { ActivityIndicator, Platform, ScrollView, StyleSheet, View } from 'react-native'; + +import { AppText } from '@/components/base/AppText'; +import { Card } from '@/components/base/Card'; +import { HeaderBackButton } from '@/components/base/HeaderBackButton'; +import { Icon } from '@/components/base/Icon'; +import { PageContainer } from '@/components/base/PageContainer'; +import { Text } from '@/components/base/Text'; +import { radius } from '@/constants'; +import { usePublicProfileScreen } from '@/features/profile/usePublicProfileScreen'; +import { type AppTheme, alpha, memoizeByTheme, useAppTheme } from '@/theme'; + +export default function UserProfileScreen() { + const theme = useAppTheme(); + const styles = createStyles(theme); + const { profile, loading, hasError, errorMessage, goToProducts } = usePublicProfileScreen(); + + return ( + <> + , + }} + /> + + + {loading ? ( + + + + ) : null} + + {hasError ? ( + + + {errorMessage} + + ) : 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} + + + + + + + {profile.product_count} + Products + + + + + + + {profile.total_weight_kg} + Total kg + + + + + + + {profile.image_count} + Photos + + + + + + + + {profile.top_category || 'None'} + + Top category + + + + + ) : null} + + + + ); +} + +const createStyles = memoizeByTheme((theme: AppTheme) => + StyleSheet.create({ + container: { + flexGrow: 1, + paddingVertical: 16, + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + marginTop: 64, + }, + errorText: { + marginTop: 16, + fontSize: 16, + textAlign: 'center', + }, + profileContainer: { + marginTop: 32, + alignItems: 'center', + }, + heroSection: { + alignItems: 'center', + marginBottom: 48, + }, + avatarPlaceholder: { + width: 120, + height: 120, + borderRadius: radius.full, + backgroundColor: theme.colors.primaryContainer, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + elevation: 4, + ...(Platform.OS === 'web' + ? { boxShadow: `0px 2px 8px ${alpha(theme.colors.shadow, 0.1)}` } + : { + shadowColor: theme.colors.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + }), + }, + avatarText: { + fontSize: 48, + fontWeight: 'bold', + color: theme.colors.onPrimaryContainer, + }, + usernameText: { + fontSize: 32, + fontWeight: '800', + marginBottom: 8, + }, + joinedText: { + fontSize: 15, + opacity: 0.6, + }, + statsSection: { + width: '100%', + flexDirection: 'row', + justifyContent: 'center', + gap: 16, + flexWrap: 'wrap', + }, + statCard: { + flex: 1, + minWidth: 140, + maxWidth: 200, + alignItems: 'center', + }, + statContent: { + alignItems: 'center', + paddingVertical: 16, + }, + statValue: { + fontSize: 28, + fontWeight: 'bold', + marginTop: 12, + marginBottom: 4, + }, + statLabel: { + fontSize: 13, + opacity: 0.7, + textAlign: 'center', + }, + }), +); 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..e6097446f --- /dev/null +++ b/app/src/app/users/__tests__/username.test.tsx @@ -0,0 +1,107 @@ +import { 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(); + }); +}); 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/AuthScreen.tsx b/app/src/components/auth/AuthScreen.tsx new file mode 100644 index 000000000..1d60d795c --- /dev/null +++ b/app/src/components/auth/AuthScreen.tsx @@ -0,0 +1,33 @@ +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, + }, + column: { + width: '100%', + maxWidth: 420, + }, +}); diff --git a/app/src/components/auth/LoginSections.tsx b/app/src/components/auth/LoginSections.tsx new file mode 100644 index 000000000..b9b80d662 --- /dev/null +++ b/app/src/components/auth/LoginSections.tsx @@ -0,0 +1,238 @@ +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { type RefObject, useCallback } from 'react'; +import type { Control, 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 { Icon } from '@/components/base/Icon'; +import { TextInput } from '@/components/base/TextInput'; +import { radius } from '@/constants'; +import type { LoginFormValues } from '@/services/api/validation/userSchema'; +import { useAppTheme } from '@/theme'; + +// shared frame so the auth card and the logo wash read as one family +const cardFrame = { + borderRadius: radius.card, + borderWidth: StyleSheet.hairlineWidth, +} as const; + +type LoginLayoutProps = { + children: React.ReactNode; + onBrowse: () => void; +}; + +export function LoginLayout({ children, onBrowse }: LoginLayoutProps) { + const theme = useAppTheme(); + return ( + + + + 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 }, + }: { + field: ControllerRenderProps; + }) => ( + + Email or username + + + ), + [setEmailRef, theme.colors.outline], + ); + const renderPassword = useCallback( + ({ + field: { onChange, value }, + }: { + field: ControllerRenderProps; + }) => ( + + Password + + + ), + [onSubmit, theme.colors.outline], + ); + + 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({ + root: { + flex: 1, + }, + stack: { + gap: 12, + }, + card: { + ...cardFrame, + padding: 16, + gap: 10, + }, + brandWash: { + paddingVertical: 12, + paddingHorizontal: 18, + marginBottom: 4, + }, + brandLogo: { + width: '100%', + }, + // A visible name that survives typing: the placeholder used to be the only + // label, and it disappears the moment the field has a value. + field: { + gap: 4, + }, + input: { + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + dividerRow: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 4, + }, + dividerLine: { + flex: 1, + height: 1, + opacity: 0.3, + }, + dividerText: { + marginHorizontal: 10, + opacity: 0.5, + }, +}); diff --git a/app/src/components/auth/LogoutConfirm.tsx b/app/src/components/auth/LogoutConfirm.tsx new file mode 100644 index 000000000..58b1098e2 --- /dev/null +++ b/app/src/components/auth/LogoutConfirm.tsx @@ -0,0 +1,32 @@ +import { View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { dialogActionsStyle, dialogTitleStyle } from '@/components/base/dialogStyles'; +import { Text } from '@/components/base/Text'; + +export default function LogoutConfirm({ + visible, + onDismiss, + onConfirm, +}: { + visible: boolean; + onDismiss: () => void; + onConfirm: () => void; +}) { + 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..7c35a975c --- /dev/null +++ b/app/src/components/auth/NewAccountSections.tsx @@ -0,0 +1,475 @@ +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, + type PressableStateCallbackType, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { BrandWordmark } from '@/components/base/BrandWordmark'; +import { Icon } from '@/components/base/Icon'; +import { TextInput } from '@/components/base/TextInput'; +import { WEBSITE_URL } from '@/config'; +import { radius } from '@/constants'; +import { + type NewAccountFormValues, + PASSWORD_MIN_LENGTH, +} from '@/services/api/validation/userSchema'; +import { openExternalUrl } from '@/services/externalLinks'; +import { useAppTheme } from '@/theme'; + +// Every row is a fixed slot, so the three steps are identical and nothing moves +// as the error message comes and goes. +const LABEL_ROW_HEIGHT = 16; +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; +// The mark and the 80px name are different heights; pinning the slot keeps the +// card from jumping between step one and the rest. +const BRAND_SLOT_HEIGHT = 96; +// Shared by the step card and the footer card so the two line up. +const CARD_MAX_WIDTH = 380; + +const styles = StyleSheet.create({ + step: { + width: '100%', + maxWidth: CARD_MAX_WIDTH, + alignSelf: 'center', + }, + welcomeText: { + marginTop: 80, + fontSize: 40, + }, + // 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. + brandSlot: { + height: BRAND_SLOT_HEIGHT, + justifyContent: 'center', + }, + // 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, + fontWeight: 'bold', + }, + // 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, + marginTop: 80, + marginBottom: 40, + }, + // 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: { + borderRadius: radius.card, + borderWidth: StyleSheet.hairlineWidth, + padding: CARD_PADDING, + width: '100%', + height: CARD_HEIGHT, + justifyContent: 'center', + 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. + label: { + height: LABEL_ROW_HEIGHT, + fontSize: 12, + opacity: 0.7, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + height: INPUT_ROW_HEIGHT, + }, + textInput: { + flex: 1, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + // Back left, primary right — the wizard convention. The primary action gets + // its own row so "Create account" stops overflowing the card's padding. + actionRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + height: ACTION_ROW_HEIGHT, + }, + // 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. + helperSlot: { + height: HELPER_SLOT_HEIGHT, + justifyContent: 'center', + }, + helperText: { + fontSize: 12, + }, + // 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. + backSlot: { + flex: 1, + justifyContent: 'center', + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + }, + backButtonText: { + fontSize: 13, + marginLeft: 4, + }, + scroll: { + flexGrow: 1, + padding: 20, + paddingBottom: 120, + alignItems: 'center', + }, + // 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. + column: { + width: '100%', + maxWidth: 480, + }, + bottomContainer: { + position: 'absolute', + bottom: 20, + left: 20, + right: 20, + alignItems: 'center', + }, + footerCard: { + borderRadius: radius.card, + borderWidth: StyleSheet.hairlineWidth, + paddingVertical: 8, + paddingHorizontal: 16, + alignItems: 'center', + gap: 4, + width: '100%', + maxWidth: CARD_MAX_WIDTH, + }, + privacyText: { + fontSize: 12, + opacity: 0.7, + textAlign: 'center', + }, + privacyLink: { + fontSize: 12, + textDecorationLine: 'underline', + }, +}); + +type SharedStepProps = { + control: Control; + errors: FieldErrors; + headlineColor: string; + mutedColor: string; +}; + +export function PrivacyPolicy() { + const theme = useAppTheme(); + const url = WEBSITE_URL ? new URL('/privacy', WEBSITE_URL).toString() : ''; + const textColor = theme.colors.onBackground; + const openPrivacy = useCallback(() => { + if (url) { + void openExternalUrl(url); + } + }, [url]); + + return ( + + By creating an account, you agree to our{' '} + + 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 renderInput = useCallback( + ({ field: { onChange, value } }: { field: ControllerRenderProps }) => ( + + ), + [error, 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]} + + {brandLogo ? ( + + ) : ( + + {lines[1]} + + )} + + {lines[2]} + + {label} + + + + + {error ? ( + + {error.message} + + ) : null} + + + + {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 ( + + + {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/__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/base/AmountStepper.tsx b/app/src/components/base/AmountStepper.tsx new file mode 100644 index 000000000..0f7264e04 --- /dev/null +++ b/app/src/components/base/AmountStepper.tsx @@ -0,0 +1,37 @@ +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..b478a5f3b --- /dev/null +++ b/app/src/components/base/AppButton.tsx @@ -0,0 +1,70 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { Button } from '@/components/base/ui/button'; +import { Text } from '@/components/base/ui/text'; +import { type AppColors, useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; + +type RnrVariant = ComponentProps['variant']; + +const VARIANT_MAP = { + primary: 'default', + tonal: 'tonal', + outline: 'outline', + ghost: 'ghost', + destructive: 'destructive', +} as const satisfies Record; + +// Mirrors buttonTextVariants' per-variant text color (ui/button.tsx) so the +// loading spinner matches the label instead of a hard-coded default. +const SPINNER_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 +}; + +type AppButtonProps = { + variant?: keyof typeof VARIANT_MAP; + loading?: boolean; + disabled?: boolean; + onPress?: () => void; + children: ReactNode; + className?: string; + accessibilityLabel?: string; + testID?: string; +}; + +/** App button over the vendored RNR button; maps app variants and a loading state. */ +export function AppButton({ + variant = 'primary', + loading = false, + disabled = false, + onPress, + children, + className, + accessibilityLabel, + testID, +}: AppButtonProps) { + const { colors } = useAppTheme(); + return ( + + ); +} diff --git a/app/src/components/base/AppDialog.tsx b/app/src/components/base/AppDialog.tsx new file mode 100644 index 000000000..d3320e18d --- /dev/null +++ b/app/src/components/base/AppDialog.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react'; +import { Modal, Pressable, StyleSheet } from 'react-native'; +import { spacing } from '@/constants'; +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; + children: ReactNode; +}; + +const NOOP = () => {}; + +/** + * 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). + */ +export function AppDialog({ visible, onDismiss, dismissable = true, children }: AppDialogProps) { + const theme = useAppTheme(); + const handleDismiss = dismissable ? onDismiss : undefined; + + return ( + + + {/* Swallow presses so tapping inside the dialog doesn't dismiss it. */} + e.stopPropagation()} style={styles.dialogWrapper}> + + {children} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.md, + }, + dialogWrapper: { + width: '100%', + maxWidth: 480, + maxHeight: '85%', + }, + dialog: { + padding: spacing.md, + }, +}); diff --git a/app/src/components/base/AppText.tsx b/app/src/components/base/AppText.tsx new file mode 100644 index 000000000..05eccd07c --- /dev/null +++ b/app/src/components/base/AppText.tsx @@ -0,0 +1,34 @@ +import type { ComponentProps } from 'react'; +import { Text } from 'react-native'; +import { useAppTheme } from '@/theme'; +import { cn } from '@/utils/cn'; + +type Variant = 'display' | 'title' | 'body' | 'label' | 'data'; + +// 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 in + * react-native-css, so a style-based default would silently eat them. + */ +export function AppText({ variant = 'body', style, className, ...rest }: AppTextProps) { + const { tokens } = useAppTheme(); + const scale = tokens.type[variant]; + return ( + + ); +} 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..659741d50 --- /dev/null +++ b/app/src/components/base/CenteredSpinner.tsx @@ -0,0 +1,26 @@ +import { ActivityIndicator, StyleSheet, 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 ( + + + + ); +} + +const styles = StyleSheet.create({ + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, +}); diff --git a/app/src/components/base/Chip.tsx b/app/src/components/base/Chip.tsx new file mode 100644 index 000000000..63de2f1f2 --- /dev/null +++ b/app/src/components/base/Chip.tsx @@ -0,0 +1,78 @@ +import type React from 'react'; +import { useCallback } from 'react'; +import { + Pressable, + type PressableProps, + type PressableStateCallbackType, + StyleSheet, +} from 'react-native'; +import { radius, spacing } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { Text } from './Text'; + +interface Props extends PressableProps { + children?: string; + title?: string; + icon?: React.ReactNode; + error?: boolean; +} + +export const Chip = ({ style, children, title, icon, error, ...props }: Props) => { + const theme = useAppTheme(); + + const resolveStyle = useCallback( + (state: PressableStateCallbackType) => { + const resolvedStyle = typeof style === 'function' ? style(state) : style; + return [ + styles.container, + { backgroundColor: error ? theme.colors.surfaceVariant : theme.colors.primaryContainer }, + state.pressed && { opacity: 0.5 }, + resolvedStyle, + ]; + }, + [style, error, theme], + ); + + return ( + + {title ? ( + {title} + ) : null} + + {children} + {icon ? ' ' : null} + {icon} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + borderRadius: radius.control, + flexDirection: 'row', + }, + text: { + paddingVertical: spacing.sm, + paddingHorizontal: 12, + borderRadius: radius.control, + textAlign: 'center', + fontWeight: '500', + fontSize: 15, + }, + titleText: { + paddingVertical: spacing.sm, + paddingHorizontal: 12, + textAlign: 'center', + fontWeight: '500', + fontSize: 15, + }, +}); diff --git a/app/src/components/base/DetailCard.tsx b/app/src/components/base/DetailCard.tsx new file mode 100644 index 000000000..2121852ee --- /dev/null +++ b/app/src/components/base/DetailCard.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { Card } from './Card'; + +interface Props { + children: ReactNode; + style?: StyleProp; +} + +export default function DetailCard({ children, style }: Props) { + return ( + + {children} + + ); +} diff --git a/frontend-app/src/components/common/DetailSectionHeader.tsx b/app/src/components/base/DetailSectionHeader.tsx similarity index 75% rename from frontend-app/src/components/common/DetailSectionHeader.tsx rename to app/src/components/base/DetailSectionHeader.tsx index 90f341037..0bdce6ff2 100644 --- a/frontend-app/src/components/common/DetailSectionHeader.tsx +++ b/app/src/components/base/DetailSectionHeader.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; import { type StyleProp, StyleSheet, type TextStyle, View } from 'react-native'; -import { InfoTooltip } from '@/components/base/InfoTooltip'; -import { Text } from '@/components/base/Text'; +import { InfoTooltip } from './InfoTooltip'; +import { Text } from './Text'; interface Props { title: string; @@ -15,10 +15,9 @@ export default function DetailSectionHeader({ title, tooltipTitle, rightElement, return ( - - {title} - {tooltipTitle ? : null} - + {/* The tooltip renders a View, so it sits beside the Text, 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..6312b17e8 --- /dev/null +++ b/app/src/components/base/DialogProvider.tsx @@ -0,0 +1,229 @@ +import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { radius, spacing } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { AppButton } from './AppButton'; +import { AppDialog } from './AppDialog'; +import { + type DialogButton, + DialogContext, + type DialogContextType, + type DialogOptions, +} from './dialogContext'; +import { dialogActionsStyle, dialogTitleStyle } from './dialogStyles'; +import { OverlaySurface } from './OverlaySurface'; +import { Text } from './Text'; +import { TextInput } from './TextInput'; + +// Within WCAG's 3-5s auto-dismiss guidance for transient toasts. +const TOAST_DURATION_MS = 4000; + +export function DialogProvider({ children }: { children: ReactNode }) { + const [options, setOptions] = useState(null); + const [toastMessage, setToastMessage] = useState(null); + const [dialogVersion, setDialogVersion] = useState(0); + + const alert = useCallback((opts: DialogOptions) => { + setOptions({ ...opts, input: false }); + setDialogVersion((version) => version + 1); + }, []); + + const input = useCallback((opts: DialogOptions) => { + setOptions({ ...opts, input: true }); + setDialogVersion((version) => version + 1); + }, []); + + const toast = useCallback((message: string) => { + setToastMessage(message); + }, []); + + const clear = useCallback(() => { + setOptions(null); + }, []); + + const dismissToast = useCallback(() => { + setToastMessage(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, onDismiss }: { options: DialogOptions; 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 primary (last) action. + const handleSubmitEditing = useCallback(() => { + handleClose(buttons[buttons.length - 1]); + }, [handleClose, buttons]); + + 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, + onSelect, + disabled, +}: { + button: DialogButton; + onSelect: (btn: DialogButton) => void; + disabled: boolean; +}) { + const handlePress = useCallback(() => { + onSelect(button); + }, [onSelect, button]); + + 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({ message, onDismiss }: { message: string | null; onDismiss: () => void }) { + const theme = useAppTheme(); + + useEffect(() => { + if (!message) return; + const timer = setTimeout(onDismiss, TOAST_DURATION_MS); + return () => clearTimeout(timer); + }, [message, onDismiss]); + + if (!message) return null; + + return ( + + + + {message} + + + + ); +} + +const styles = StyleSheet.create({ + message: { + marginBottom: spacing.sm, + }, + input: { + borderWidth: 1, + borderRadius: radius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + }, + helperText: { + fontSize: 12, + marginTop: spacing.xs, + }, + toastContainer: { + position: 'absolute', + left: 0, + right: 0, + bottom: spacing.lg, + alignItems: 'center', + zIndex: 100, + }, + toast: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + maxWidth: '90%', + }, +}); diff --git a/app/src/components/base/ErrorState.tsx b/app/src/components/base/ErrorState.tsx new file mode 100644 index 000000000..ff2813e3b --- /dev/null +++ b/app/src/components/base/ErrorState.tsx @@ -0,0 +1,40 @@ +import { StyleSheet, View } from 'react-native'; +import { useAppTheme } from '@/theme'; +import { AppButton } from './AppButton'; +import { Icon, type IconName } from './Icon'; +import { Text } from './Text'; + +type Props = { + message: string; + onRetry: () => void; + icon?: IconName; +}; + +/** Full-height centered error state with an icon, message, and retry button. */ +export function ErrorState({ message, onRetry, icon = 'alert-circle-outline' }: Props) { + const theme = useAppTheme(); + + return ( + + + {message} + + Retry + + + ); +} + +const styles = StyleSheet.create({ + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + gap: 12, + }, + message: { + marginTop: 12, + textAlign: 'center', + }, +}); diff --git a/app/src/components/base/Fab.tsx b/app/src/components/base/Fab.tsx new file mode 100644 index 000000000..d5109efba --- /dev/null +++ b/app/src/components/base/Fab.tsx @@ -0,0 +1,128 @@ +import type { ReactNode } from 'react'; +import { useEffect } from 'react'; +import { Pressable, type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; +import Animated, { + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { AppText } from '@/components/base/AppText'; +import { radius, spacing } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { Icon, type IconName } from './Icon'; + +type FabProps = { + /** 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 an extend/collapse label, replacing + * react-native-paper's AnimatedFAB. Extending animates the label's width and + * opacity in via Reanimated (honoring the OS reduce-motion setting); + * 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, +}: FabProps) { + const theme = useAppTheme(); + const progress = useSharedValue(extended ? 1 : 0); + + useEffect(() => { + progress.value = extended + ? withTiming(1, { duration: ANIMATION_DURATION, reduceMotion: ReduceMotion.System }) + : 0; + }, [extended, progress]); + + const labelStyle = useAnimatedStyle(() => ({ + width: progress.value * LABEL_MAX_WIDTH, + opacity: progress.value, + })); + + if (!visible) return null; + + return ( + [ + styles.fab, + theme.tokens.elevation.overlay, + { backgroundColor: theme.colors.primaryContainer }, + disabled && styles.disabled, + pressed && !disabled && styles.pressed, + style, + ]} + > + {typeof icon === 'function' ? ( + icon() + ) : ( + + )} + {extended ? ( + + + {label} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + fab: { + flexDirection: 'row', + alignItems: 'center', + minWidth: 44, + minHeight: 44, + // Floating surface: overlay radius, not `full` — DESIGN.md reserves the pill + // radius for avatars/true pills. Elevation comes from the shared overlay + // tier, applied inline since it is theme-dependent. + borderRadius: radius.overlay, + paddingHorizontal: spacing.md, + }, + disabled: { + opacity: 0.5, + }, + pressed: { + opacity: 0.85, + }, + labelClip: { + overflow: 'hidden', + }, + label: { + marginLeft: spacing.sm, + }, +}); diff --git a/app/src/components/base/FilterSelectionModal.tsx b/app/src/components/base/FilterSelectionModal.tsx new file mode 100644 index 000000000..b3cd0278a --- /dev/null +++ b/app/src/components/base/FilterSelectionModal.tsx @@ -0,0 +1,229 @@ +import { useCallback } from 'react'; +import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { radius, spacing } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { AppButton } from './AppButton'; +import { Chip } from './Chip'; +import { OverlaySurface } from './OverlaySurface'; +import { Text } from './Text'; +import { TextInput } from './TextInput'; + +function SelectableChip({ + item, + selected, + onToggle, +}: { + item: string; + selected: boolean; + onToggle: (value: string) => void; +}) { + const { colors } = useAppTheme(); + const handlePress = useCallback(() => onToggle(item), [onToggle, item]); + return ( + + {item} + + ); +} + +type Props = { + visible: boolean; + onDismiss: () => void; + title: string; + /** Items to display; controlled by the parent (parent owns the search query + fetch). */ + items: string[]; + isLoading?: boolean; + selectedValues: string[]; + onSelectionChange: (values: string[]) => void; + /** Controlled search query; parent owns it so it can debounce/fetch. */ + searchQuery: string; + onSearchChange: (q: string) => void; + searchPlaceholder?: string; + /** When true: tapping an item immediately confirms and closes (single-select UX). */ + singleSelect?: boolean; +}; + +export default function FilterSelectionModal({ + visible, + onDismiss, + title, + items, + isLoading, + selectedValues, + onSelectionChange, + searchQuery, + onSearchChange, + searchPlaceholder = 'Search…', + singleSelect = false, +}: Props) { + const theme = useAppTheme(); + const toggle = useCallback( + (value: string) => { + if (singleSelect) { + onSelectionChange([value]); + onDismiss(); + return; + } + if (selectedValues.includes(value)) { + onSelectionChange(selectedValues.filter((v) => v !== value)); + } else { + onSelectionChange([...selectedValues, value]); + } + }, + [singleSelect, onSelectionChange, onDismiss, selectedValues], + ); + + const addNew = useCallback(() => toggle(searchQuery.trim()), [toggle, searchQuery]); + const handleClearAll = useCallback(() => { + onSelectionChange([]); + if (singleSelect) { + onDismiss(); + } + }, [onSelectionChange, singleSelect, onDismiss]); + + // 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]; + + // For singleSelect, allow creating a new value from the typed search query. + const canAddNew = + singleSelect && + searchQuery.trim().length > 0 && + !visibleItems.some((v) => v.toLowerCase() === searchQuery.trim().toLowerCase()); + + return ( + + + {/* Swallow presses so tapping inside the dialog doesn't dismiss it. */} + e.stopPropagation()} style={styles.dialogWrapper}> + + + {title} + + + {isLoading ? ( + + + + ) : visibleItems.length === 0 && !canAddNew ? ( + No results + ) : ( + + + {canAddNew ? ( + // NOTE: dropped Paper's leading "+" icon — Chip's icon prop + // renders inside the same Text node as the label, which would + // break exact getByText(searchQuery) matches in callers/tests. + + {searchQuery.trim()} + + ) : null} + {visibleItems.map((item) => ( + + ))} + + + )} + + {selectedValues.length > 0 ? ( + + {singleSelect ? 'Clear' : 'Clear all'} + + ) : null} + {!singleSelect ? ( + + Done + + ) : null} + {singleSelect ? ( + + Cancel + + ) : null} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.md, + }, + dialogWrapper: { + width: '100%', + maxWidth: 480, + maxHeight: '85%', + }, + dialog: { + padding: spacing.md, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: spacing.sm, + }, + search: { + marginBottom: spacing.md, + borderWidth: 1, + borderRadius: radius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + }, + loading: { + paddingVertical: spacing.lg, + alignItems: 'center', + }, + empty: { + opacity: 0.5, + paddingBottom: spacing.sm, + }, + scroll: { + maxHeight: 320, + }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + paddingBottom: spacing.sm, + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.xs, + marginTop: spacing.sm, + }, +}); diff --git a/app/src/components/base/HeaderBackButton.tsx b/app/src/components/base/HeaderBackButton.tsx new file mode 100644 index 000000000..240fda173 --- /dev/null +++ b/app/src/components/base/HeaderBackButton.tsx @@ -0,0 +1,26 @@ +import type { NativeStackHeaderBackProps } from 'expo-router'; +import { Pressable } from 'react-native'; +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..86ec0ccad --- /dev/null +++ b/app/src/components/base/HeaderRightPill.tsx @@ -0,0 +1,57 @@ +import { useRouter } from 'expo-router'; +import { useCallback } from 'react'; +import { Pressable } from 'react-native'; +import { useAuth } from '@/context/auth'; +import { useAppTheme } from '@/theme'; +import { needsUsernameOnboarding } from '@/utils/router/onboarding'; +import { createHeaderRightPillStyles } from '@/utils/router/styles'; +import { Icon } from './Icon'; +import { Text } from './Text'; + +function truncateUsername(username: string) { + return username.length > 16 ? `${username.slice(0, 14)}…` : username; +} + +export function HeaderRightPill() { + const { user } = useAuth(); + const router = useRouter(); + const theme = useAppTheme(); + const { pill, primaryText } = createHeaderRightPillStyles(theme); + const needsOnboarding = user ? needsUsernameOnboarding(user) : false; + + const goToAccount = useCallback(() => { + router.push(needsOnboarding ? '/onboarding' : '/account'); + }, [router, needsOnboarding]); + const goToLogin = useCallback(() => router.push('/login'), [router]); + + if (user) { + const username = needsOnboarding ? 'Complete profile' : truncateUsername(user.username ?? ''); + return ( + + + + {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..d8e78b0a3 --- /dev/null +++ b/app/src/components/base/Icon.tsx @@ -0,0 +1,121 @@ +import { + ArrowDownUp, + ArrowLeft, + Calendar, + Camera, + CameraOff, + Check, + ChevronLeft, + ChevronRight, + CircleAlert, + CircleCheckBig, + CircleUserRound, + Clock, + EthernetPort, + ExternalLink, + Eye, + EyeOff, + Globe, + Image, + ImagePlus, + Images, + Info, + Link, + Lock, + type LucideIcon, + MailCheck, + Minus, + Package, + PackageX, + Pencil, + Plus, + RadioTower, + RefreshCw, + Save, + Search, + Settings, + Shapes, + Tag, + Trash2, + User, + UserX, + VideoOff, + Weight, + X, +} from 'lucide-react-native'; + +// Curated MaterialCommunityIcons -> Lucide name map. Keys match the existing +// MCI `name` strings used across the app so call sites migrate 1:1. Brand +// marks (google, github, youtube) stay off this map — Lucide ships no brand +// glyphs, so these remain on @expo/vector-icons (see assets/DESIGN.md). +const iconMap = { + 'access-point': RadioTower, + account: User, + 'account-cancel-outline': UserX, + 'account-circle': CircleUserRound, + 'account-outline': User, + 'alert-circle-outline': CircleAlert, + 'arrow-left': ArrowLeft, + calendar: Calendar, + camera: Camera, + 'camera-burst': Images, + 'camera-off': CameraOff, + 'camera-wireless': Camera, + check: Check, + 'check-bold': Check, + 'check-circle': CircleCheckBig, + 'chevron-left': ChevronLeft, + 'chevron-right': ChevronRight, + 'clock-outline': Clock, + close: X, + cog: Settings, + 'content-save': Save, + delete: Trash2, + earth: Globe, + 'email-check-outline': MailCheck, + ethernet: EthernetPort, + eye: Eye, + 'eye-off': EyeOff, + 'image-multiple': Images, + 'image-outline': Image, + 'image-plus': ImagePlus, + 'information-outline': Info, + 'link-variant': Link, + lock: Lock, + magnify: Search, + minus: Minus, + 'open-in-new': ExternalLink, + 'package-variant-closed': Package, + 'package-variant-closed-remove': PackageX, + pencil: Pencil, + plus: Plus, + refresh: RefreshCw, + shape: Shapes, + sort: ArrowDownUp, + tag: Tag, + 'tag-outline': Tag, + 'video-off': VideoOff, + 'weight-kilogram': Weight, +} 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..1bb9782cd --- /dev/null +++ b/app/src/components/base/IconButton.tsx @@ -0,0 +1,71 @@ +import { + ActivityIndicator, + Pressable, + type StyleProp, + StyleSheet, + type ViewStyle, +} from 'react-native'; +import { useAppTheme } from '@/theme'; +import { Icon, type IconName } from './Icon'; + +type IconButtonProps = { + icon: IconName; + onPress: () => void; + accessibilityLabel: string; + size?: number; + loading?: boolean; + mode?: 'default' | 'contained-tonal'; + style?: StyleProp; + testID?: string; +}; + +/** Icon-only pressable, replacing react-native-paper's IconButton. Touch target stays >=44px regardless of the glyph's visual `size`. */ +export function IconButton({ + icon, + onPress, + accessibilityLabel, + size = 24, + loading = false, + mode = 'default', + style, + testID, +}: IconButtonProps) { + const theme = useAppTheme(); + + return ( + [ + styles.button, + mode === 'contained-tonal' && { backgroundColor: theme.colors.secondaryContainer }, + pressed && !loading && styles.pressed, + style, + ]} + > + {loading ? ( + + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + button: { + minWidth: 44, + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 22, + }, + pressed: { + opacity: 0.6, + }, +}); diff --git a/frontend-app/src/components/common/ImagePlaceholder.tsx b/app/src/components/base/ImagePlaceholder.tsx similarity index 78% rename from frontend-app/src/components/common/ImagePlaceholder.tsx rename to app/src/components/base/ImagePlaceholder.tsx index 01dd26698..5d07993e8 100644 --- a/frontend-app/src/components/common/ImagePlaceholder.tsx +++ b/app/src/components/base/ImagePlaceholder.tsx @@ -1,7 +1,7 @@ import { View } from 'react-native'; -import { Icon } from 'react-native-paper'; -import { Text } from '@/components/base/Text'; import { useAppTheme } from '@/theme'; +import { Icon } from './Icon'; +import { Text } from './Text'; interface Props { width: number; @@ -33,7 +33,10 @@ export default function ImagePlaceholder({ justifyContent: 'center', }} > - + {/* Decorative — hidden from the accessibility tree, matching StaticBackground's aria-hidden treatment. */} + + + {label ? ( + 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 [visible, setVisible] = useState(false); + const show = useCallback(() => setVisible(true), []); + const hide = useCallback(() => setVisible(false), []); + const tooltipShadowStyle = { + boxShadow: `0px 2px 4px ${alpha(theme.colors.shadow, 0.25)}`, + }; + + // 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({ + iconContainer: { + padding: spacing.sm, + }, + overlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + tooltip: { + padding: 12, + paddingHorizontal: spacing.md, + borderRadius: radius.md, + maxWidth: '80%', + minWidth: 200, + elevation: 3, + }, + anchor: { + alignSelf: 'flex-start', + }, + floating: { + position: 'absolute', + top: '100%', + left: 0, + marginTop: spacing.xs, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: radius.sm, + maxWidth: 240, + zIndex: 10, + elevation: 3, + }, +}); diff --git a/app/src/components/base/LocalizedFloatInput.tsx b/app/src/components/base/LocalizedFloatInput.tsx new file mode 100644 index 000000000..bf815233c --- /dev/null +++ b/app/src/components/base/LocalizedFloatInput.tsx @@ -0,0 +1,162 @@ +import { useRef, useState } from 'react'; +import type RN from 'react-native'; +import { Platform, Pressable } from 'react-native'; +import { Text } from './Text'; +import { TextInput } from './TextInput'; + +interface LocalizedFloatInputProps { + value: number | undefined; + onChange?: (value: number | undefined) => void; + editable?: boolean; + placeholder?: string; + unit?: string; + label?: string; + min?: number; + style?: object; +} + +/** + * Gets the user's decimal separator based on their locale + */ +function getDecimalSeparator(): string { + const localeToUse = typeof navigator !== 'undefined' ? navigator.language : undefined; + const formatted = localeToUse ? (1.1).toLocaleString(localeToUse) : (1.1).toLocaleString(); + return formatted.charAt(1); // The character between 1 and 1 +} + +// The locale is fixed for the session, so resolve the separator and its validation +// pattern once at module load rather than on every keystroke. +const DECIMAL_SEPARATOR = getDecimalSeparator(); +const DECIMAL_PATTERN = new RegExp(`^\\d*[${DECIMAL_SEPARATOR.replace('.', '\\.')}]?\\d*$`); + +/** + * Converts a localized number string to standard dot-decimal format + */ +function normalizeDecimalString(text: string, decimalSeparator: string): string { + return decimalSeparator === '.' ? text : text.replace(decimalSeparator, '.'); +} + +/** + * Converts a standard number to localized display format + */ +function toLocalizedString(value: number | undefined, decimalSeparator: string): string { + if (value === undefined) return ''; + const standardString = value.toString(); + return decimalSeparator === '.' ? standardString : standardString.replace('.', decimalSeparator); +} + +export default function LocalizedFloatInput({ + value, + onChange, + editable = true, + placeholder = '> 0', + unit, + label, + min = 0, + style, +}: LocalizedFloatInputProps) { + const textInput = useRef(null); + const normalizedValue = value == null || Number.isNaN(value) ? undefined : value; + const [text, setText] = useState(() => toLocalizedString(normalizedValue, DECIMAL_SEPARATOR)); + + // Resync the field when the `value` prop changes externally (async load, refetch, + // parent reset). This can't clobber typing: the parent only gets updates on blur, + // so `normalizedValue` is stable while the user types and this stays idle. + // Render-phase reset rather than an effect — no cascading render, no stale frame. + const [syncedValue, setSyncedValue] = useState(normalizedValue); + if (normalizedValue !== syncedValue) { + setSyncedValue(normalizedValue); + setText(toLocalizedString(normalizedValue, DECIMAL_SEPARATOR)); + } + + const inputStyle = { + textAlign: Platform.OS === 'web' ? 'right' : undefined, + height: 38, + paddingHorizontal: 10, + marginVertical: 2, + borderRadius: 50, + ...style, + } as RN.TextStyle; + const webOnlyInputStyle = + Platform.OS === 'web' + ? ({ outline: 'none', fieldSizing: 'content' } as unknown as RN.TextStyle) + : undefined; + + const onPress = () => { + if (editable) { + textInput.current?.focus(); + } + }; + + const handleBlur = () => { + if (text.trim() === '') { + onChange?.(undefined); + return; + } + + const normalizedText = normalizeDecimalString(text, DECIMAL_SEPARATOR); + const numValue = parseFloat(normalizedText); + + if (!Number.isNaN(numValue) && numValue >= min) { + onChange?.(numValue); + } else { + setText(toLocalizedString(normalizedValue, DECIMAL_SEPARATOR)); + } + }; + + const handleChangeText = (s: string) => { + if (DECIMAL_PATTERN.test(s) || s === '') { + setText(s); + } + }; + + const inputContent = ( + <> + + {unit ? ( + + {unit} + + ) : null} + + ); + + if (label) { + return ( + + + {label} + + {inputContent} + + ); + } + + return {inputContent}; +} diff --git a/app/src/components/base/Menu.tsx b/app/src/components/base/Menu.tsx new file mode 100644 index 000000000..1342faffb --- /dev/null +++ b/app/src/components/base/Menu.tsx @@ -0,0 +1,129 @@ +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { Modal, Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { AppText } from '@/components/base/AppText'; +import { Icon, type IconName } from '@/components/base/Icon'; +import { radius, spacing } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { getMenuPosition, MENU_MIN_WIDTH, type MenuPosition } from './menuPosition'; + +type MenuProps = { + visible: boolean; + onDismiss: () => void; + anchor: ReactNode; + children: ReactNode; +}; + +/** + * Anchored dropdown menu, replacing react-native-paper's Menu. Renders in an + * RN-core Modal (no PortalHost involved) with a full-screen dismiss backdrop; + * position is measured from the anchor once the menu opens. + * + * 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} + + + + {/* Swallow presses so tapping an item doesn't fall through to the backdrop. */} + e.stopPropagation()} + accessibilityRole="menu" + style={[ + styles.content, + position, + theme.tokens.elevation.overlay, + { backgroundColor: theme.colors.elevation.level2 }, + ]} + > + {children} + + + + + ); +} + +function MenuItem({ + title, + trailingIcon, + onPress, +}: { + title: string; + trailingIcon?: IconName; + onPress: () => void; +}) { + const theme = useAppTheme(); + return ( + [ + styles.item, + pressed && { backgroundColor: theme.colors.surfaceVariant }, + ]} + > + + {title} + + {trailingIcon ? : null} + + ); +} + +Menu.Item = MenuItem; + +const styles = StyleSheet.create({ + content: { + position: 'absolute', + minWidth: MENU_MIN_WIDTH, + maxWidth: '92%', + // Floating surface: overlay radius + the shared overlay elevation tier + // (applied inline below, since it is theme-dependent). + borderRadius: radius.overlay, + paddingVertical: spacing.xs, + }, + item: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 44, + paddingHorizontal: spacing.md, + gap: spacing.sm, + }, + itemLabel: { + flexShrink: 1, + }, +}); diff --git a/frontend-app/src/components/base/MutedText.tsx b/app/src/components/base/MutedText.tsx similarity index 87% rename from frontend-app/src/components/base/MutedText.tsx rename to app/src/components/base/MutedText.tsx index f24b4bc65..f977d206a 100644 --- a/frontend-app/src/components/base/MutedText.tsx +++ b/app/src/components/base/MutedText.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from 'react'; -import { Text } from '@/components/base/Text'; import { useAppTheme } from '@/theme'; +import { Text } from './Text'; type MutedTextProps = ComponentProps; diff --git a/app/src/components/base/OtpInput.tsx b/app/src/components/base/OtpInput.tsx new file mode 100644 index 000000000..5eff7bdcb --- /dev/null +++ b/app/src/components/base/OtpInput.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { StyleSheet, TextInput, View } from 'react-native'; +import { type AppTheme, memoizeByTheme, useAppTheme } from '@/theme'; +import { AppText } from './AppText'; +import { Text } from './Text'; + +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 ( + + {value[index] ?? ''} + + ); + })} + + + + ); +} + +const createStyles = memoizeByTheme((theme: AppTheme) => + StyleSheet.create({ + wrap: { + position: 'relative', + alignSelf: 'center', + width: '100%', + maxWidth: 320, + gap: 4, + }, + row: { + flexDirection: 'row', + gap: 8, + }, + cell: { + flex: 1, + height: 56, + borderRadius: 12, + borderWidth: 1.5, + borderColor: theme.tokens.border.subtle, + backgroundColor: theme.tokens.surface.sunken, + alignItems: 'center', + justifyContent: 'center', + }, + cellFilled: { + borderColor: theme.tokens.border.strong, + }, + cellFocused: { + borderColor: theme.colors.primary, + backgroundColor: theme.tokens.surface.accent, + }, + cellError: { + borderColor: theme.tokens.status.danger, + }, + digit: { + fontSize: 24, + fontWeight: '600', + }, + hiddenInput: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: 0, + color: 'transparent', + }, + }), +); diff --git a/app/src/components/base/OverlaySurface.tsx b/app/src/components/base/OverlaySurface.tsx new file mode 100644 index 000000000..5097513f9 --- /dev/null +++ b/app/src/components/base/OverlaySurface.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import { StyleSheet, View, type ViewStyle } from 'react-native'; +import { radius } from '@/constants'; +import { useAppTheme } from '@/theme'; + +type OverlaySurfaceProps = { + children?: ReactNode; + style?: ViewStyle | ViewStyle[]; + /** + * '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, tone = 'scrim' }: OverlaySurfaceProps) { + const theme = useAppTheme(); + const backgroundColor = tone === 'surface' ? theme.colors.surface : theme.tokens.overlay[tone]; + return {children}; +} + +const styles = StyleSheet.create({ + base: { + // Floating chrome (tooltips, toasts, dialog surfaces) — overlay radius. + borderRadius: radius.overlay, + }, +}); 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/frontend-app/src/components/common/SVGCube.tsx b/app/src/components/base/SVGCube.tsx similarity index 92% rename from frontend-app/src/components/common/SVGCube.tsx rename to app/src/components/base/SVGCube.tsx index 59fc657ef..c270e909c 100644 --- a/frontend-app/src/components/common/SVGCube.tsx +++ b/app/src/components/base/SVGCube.tsx @@ -12,10 +12,6 @@ export default function Cube({ width = 1, height = 1, depth = 1 }: CubeProps) { // Hooks const theme = useAppTheme(); - if (Number.isNaN(width)) width = 1; - if (Number.isNaN(height)) height = 1; - if (Number.isNaN(depth)) depth = 1; - // Calculate SVG dimensions to fit the cube const highest = Math.max(width, depth, height); const factor = 90 / highest; diff --git a/app/src/components/base/Searchbar.tsx b/app/src/components/base/Searchbar.tsx new file mode 100644 index 000000000..7c0c7ea9c --- /dev/null +++ b/app/src/components/base/Searchbar.tsx @@ -0,0 +1,83 @@ +import { + ActivityIndicator, + Pressable, + type StyleProp, + StyleSheet, + View, + type ViewStyle, +} from 'react-native'; +import { Input } from '@/components/base/ui/input'; +import { spacing } from '@/constants'; +import { useAppTheme } from '@/theme'; +import { Icon } from './Icon'; + +type SearchbarProps = { + value: string; + onChangeText: (text: string) => void; + placeholder?: string; + loading?: boolean; + style?: StyleProp; +}; + +/** Search field with a leading magnifier and a trailing clear/loading affordance, replacing react-native-paper's Searchbar. */ +export function Searchbar({ + value, + onChangeText, + placeholder, + loading = false, + style, +}: SearchbarProps) { + const theme = useAppTheme(); + + return ( + + + + + + {loading ? ( + + ) : value ? ( + onChangeText('')} + accessibilityRole="button" + accessibilityLabel="Clear search" + // 20px glyph + 12px hitSlop/side = 44px tap target (a11y floor). + hitSlop={12} + style={styles.trailing} + > + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + }, + leadingIcon: { + position: 'absolute', + left: spacing.sm + 4, + zIndex: 1, + }, + trailing: { + position: 'absolute', + right: spacing.sm + 4, + }, +}); diff --git a/app/src/components/base/Section.tsx b/app/src/components/base/Section.tsx new file mode 100644 index 000000000..4d3c92141 --- /dev/null +++ b/app/src/components/base/Section.tsx @@ -0,0 +1,99 @@ +import { type ReactNode, useContext, useEffect, useRef, useState } from 'react'; +import { View } from 'react-native'; +import { cn } from '@/utils/cn'; +import { AppButton } from './AppButton'; +import { AppText } from './AppText'; +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]); + + if (!isVisible) return null; + + const showAddRow = isEmpty && editMode && !expandedWhileEmpty; + + return ( + nav?.registerSection(sectionKey, event.nativeEvent.layout.y)} + className={cn('rounded-lg bg-card border border-border px-4 py-3', className)} + > + {showAddRow ? ( + setExpandedWhileEmpty(true)}> + {addLabel ?? `Add ${title.toLowerCase()}`} + + ) : ( + <> + + {title} + {titleSuffix ? ( + + {titleSuffix} + + ) : null} + {tooltip ? : null} + + {children} + + )} + + ); +} diff --git a/app/src/components/base/SectionNav.tsx b/app/src/components/base/SectionNav.tsx new file mode 100644 index 000000000..4dc6e183f --- /dev/null +++ b/app/src/components/base/SectionNav.tsx @@ -0,0 +1,46 @@ +import { Platform, Pressable, ScrollView, View } from 'react-native'; +import { cn } from '@/utils/cn'; +import { AppText } from './AppText'; +import type { SectionKey } from './SectionNavContext'; + +type SectionNavProps = { + sections: { key: SectionKey; label: string }[]; + activeKey: SectionKey; + onPress: (key: SectionKey) => void; + orientation: 'chips' | 'outline'; +}; + +/** Section jump-nav: horizontal chips on phone, vertical outline on wide web. */ +export function SectionNav({ sections, activeKey, onPress, orientation }: SectionNavProps) { + const items = sections.map((section) => { + const active = section.key === activeKey; + return ( + onPress(section.key)} + accessibilityRole="button" + accessibilityLabel={active ? `${section.label}, current section` : section.label} + className={cn( + 'min-h-11 justify-center rounded-full px-4 py-2', + active ? 'bg-primary/10' : 'opacity-70', + Platform.select({ + web: 'cursor-pointer outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring', + }), + )} + > + + {section.label} + + + ); + }); + + if (orientation === 'outline') { + return {items}; + } + return ( + + {items} + + ); +} 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..42802aa29 --- /dev/null +++ b/app/src/components/base/SectionNavLayout.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react'; +import { View } from 'react-native'; +import { SectionNav } from './SectionNav'; +import type { SectionKey } from './SectionNavContext'; + +/** + * 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/frontend-app/src/components/base/Skeleton.tsx b/app/src/components/base/Skeleton.tsx similarity index 100% rename from frontend-app/src/components/base/Skeleton.tsx rename to app/src/components/base/Skeleton.tsx diff --git a/app/src/components/base/SpecFacts.tsx b/app/src/components/base/SpecFacts.tsx new file mode 100644 index 000000000..22a64cae2 --- /dev/null +++ b/app/src/components/base/SpecFacts.tsx @@ -0,0 +1,21 @@ +import { View } from 'react-native'; +import { AppText } from './AppText'; + +export type SpecFact = { label: string; value: string }; + +/** Mono "manila tag" facts row for the spec-sheet header. Hides itself when empty. */ +export function SpecFacts({ facts }: { facts: SpecFact[] }) { + if (facts.length === 0) return null; + return ( + + {facts.map((fact) => ( + + + {fact.label} + + {fact.value} + + ))} + + ); +} 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/frontend-app/src/components/common/StatusBadge.tsx b/app/src/components/base/StatusBadge.tsx similarity index 77% rename from frontend-app/src/components/common/StatusBadge.tsx rename to app/src/components/base/StatusBadge.tsx index 197aec58b..a4103dae4 100644 --- a/frontend-app/src/components/common/StatusBadge.tsx +++ b/app/src/components/base/StatusBadge.tsx @@ -1,6 +1,6 @@ import { StyleSheet, View } from 'react-native'; -import { Text } from '@/components/base/Text'; -import { getStatusTone, useAppTheme } from '@/theme'; +import { getStatusTone } from '@/theme'; +import { Text } from './Text'; type StatusBadgeProps = { label: string; @@ -8,9 +8,8 @@ type StatusBadgeProps = { }; export function StatusBadge({ label, color }: StatusBadgeProps) { - const theme = useAppTheme(); return ( - + {label} ); diff --git a/app/src/components/base/StatusPill.tsx b/app/src/components/base/StatusPill.tsx new file mode 100644 index 000000000..b0b4f34a1 --- /dev/null +++ b/app/src/components/base/StatusPill.tsx @@ -0,0 +1,65 @@ +import { StyleSheet, View } from 'react-native'; +import { AppText } from '@/components/base/AppText'; +import { radius } from '@/constants'; +import { getStatusTone, useAppTheme } from '@/theme'; +import type { AppTokens } from '@/theme/types'; + +export type StatusTone = keyof AppTokens['status']; + +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: { + height: 22, + justifyContent: 'center', + paddingHorizontal: 8, + // Inline status chip — control radius, not a pill (DESIGN.md reserves the + // `full` radius for avatars/true pills). + borderRadius: radius.control, + }, + label: { + fontSize: 11, + lineHeight: 14, + }, + bold: { + fontWeight: '700', + }, +}); diff --git a/app/src/components/base/Text.tsx b/app/src/components/base/Text.tsx new file mode 100644 index 000000000..2656e6f2d --- /dev/null +++ b/app/src/components/base/Text.tsx @@ -0,0 +1,18 @@ +import { Text as NativeText, StyleSheet, type TextProps } from 'react-native'; +import { type AppTheme, memoizeByTheme, useAppTheme } from '@/theme'; + +const getStyles = memoizeByTheme((theme: AppTheme) => + StyleSheet.create({ + text: { color: theme.colors.onSurface }, + }), +); + +export const Text = ({ style, children, ...props }: TextProps) => { + const styles = getStyles(useAppTheme()); + + return ( + + {children} + + ); +}; diff --git a/app/src/components/base/TextInput.tsx b/app/src/components/base/TextInput.tsx new file mode 100644 index 000000000..5a5da1fb6 --- /dev/null +++ b/app/src/components/base/TextInput.tsx @@ -0,0 +1,44 @@ +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; + ref?: React.Ref; +} + +export function TextInput({ + style, + children, + errorOnEmpty = false, + customValidation, + 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..481367a66 --- /dev/null +++ b/app/src/components/base/TopNav.tsx @@ -0,0 +1,88 @@ +import { usePathname, useRouter } from 'expo-router'; +import { Platform, Pressable, View } from 'react-native'; +import { AUTH_HERO_PATHS } from '@/constants'; +import { useRpiIntegration } from '@/features/cameras/rpi/useRpiIntegration'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { PRIMARY_DESTINATIONS } 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]); + +/** + * 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 { enabled: rpiEnabled } = useRpiIntegration(); + + if (!(Platform.OS === 'web' && isLg) || NO_CHROME_PATHS.has(pathname)) return null; + + const destinations = PRIMARY_DESTINATIONS.filter( + (destination) => destination.key !== 'cameras' || rpiEnabled, + ); + + return ( + + router.push('/products')} + accessibilityRole="button" + accessibilityLabel="Relab, go to products" + className={cn( + 'min-h-11 justify-center', + Platform.select({ + web: 'cursor-pointer outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring', + }), + )} + > + + + + {destinations.map((destination) => { + const active = + pathname === destination.href || pathname.startsWith(`${destination.href}/`); + return ( + router.push(destination.href)} + accessibilityRole="button" + accessibilityLabel={active ? `${destination.label}, current page` : destination.label} + className={cn( + 'min-h-11 justify-center rounded-full px-4 py-2', + active ? 'bg-primary/10' : 'opacity-70', + Platform.select({ + web: 'cursor-pointer outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring', + }), + )} + > + + {destination.label} + + + ); + })} + + + + + + ); +} diff --git a/frontend-app/src/components/common/ZoomableImage.tsx b/app/src/components/base/ZoomableImage.tsx similarity index 93% rename from frontend-app/src/components/common/ZoomableImage.tsx rename to app/src/components/base/ZoomableImage.tsx index 08bdfce3e..1583fb65a 100644 --- a/frontend-app/src/components/common/ZoomableImage.tsx +++ b/app/src/components/base/ZoomableImage.tsx @@ -14,7 +14,6 @@ interface Props { onSwipe?: (direction: -1 | 1) => void; } -// spell-checker: ignore Zoomable export default function ZoomableImage({ uri, onScaleChange, setIsZoomed, onSwipe }: Props) { const [isZoomedInternal, setIsZoomedInternal] = useState(false); @@ -75,9 +74,11 @@ export default function ZoomableImage({ uri, onScaleChange, setIsZoomed, onSwipe translateX.set(savedTranslateX.get() + e.translationX); translateY.set(savedTranslateY.get() + e.translationY); }) - .onEnd(() => { - const horizontal = translateX.get(); - const vertical = translateY.get(); + .onEnd((e) => { + // This gesture's own movement, not the accumulated offset — otherwise panning a + // zoomed image far enough to see its edges reads as a navigation swipe. + const horizontal = e.translationX; + const vertical = e.translationY; const swipeThreshold = SCREEN_WIDTH * 0.15; if ( Math.abs(horizontal) > Math.abs(vertical) && 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..d886d8285 --- /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). +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('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__/AppText.test.tsx b/app/src/components/base/__tests__/AppText.test.tsx new file mode 100644 index 000000000..0c9f5278a --- /dev/null +++ b/app/src/components/base/__tests__/AppText.test.tsx @@ -0,0 +1,52 @@ +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); +}); 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..7d3762ba3 --- /dev/null +++ b/app/src/components/base/__tests__/Card.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { Card } from '@/components/base/Card'; +import DetailCard from '@/components/base/DetailCard'; + +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.) +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('DetailCard inherits the flat card surface (carries no shadow)', () => { + render( + + Detail + , + ); + expect(screen.getByText('Detail')).toBeOnTheScreen(); + expect(screen.root.props.className).not.toMatch(SHADOW_CLASS_PATTERN); +}); + +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..e1ee634ec --- /dev/null +++ b/app/src/components/base/__tests__/Chip.test.tsx @@ -0,0 +1,77 @@ +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 { useEffectiveColorScheme } from '@/context/themeMode'; +import { setupUser } from '@/test-utils/index'; +import { renderWithProviders } from '@/test-utils/render'; +import { getAppTheme } from '@/theme'; + +jest.mock('@/context/themeMode', () => ({ + useEffectiveColorScheme: jest.fn(() => 'light'), +})); + +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 error container style when error prop is set', () => { + renderWithProviders(Error Chip); + expect(screen.getByText('Error Chip')).toHaveStyle({ + backgroundColor: getAppTheme('light').colors.errorContainer, + color: getAppTheme('light').colors.onErrorContainer, + }); + }); + + it('applies primary style when error prop is not set', () => { + renderWithProviders(Normal Chip); + expect(screen.getByText('Normal Chip')).toHaveStyle({ + backgroundColor: getAppTheme('light').colors.primary, + 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')).toHaveStyle({ + backgroundColor: getAppTheme('dark').colors.primary, + 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(); + }); +}); 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..adadd7452 --- /dev/null +++ b/app/src/components/base/__tests__/DialogProvider.test.tsx @@ -0,0 +1,312 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, screen, waitFor } 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 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(); + + 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('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(); + }); + }); +}); 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..726a2326a --- /dev/null +++ b/app/src/components/base/__tests__/Fab.test.tsx @@ -0,0 +1,91 @@ +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 { 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(); + const style = StyleSheet.flatten(screen.getByRole('button').props.style); + expect(style.minWidth).toBeGreaterThanOrEqual(44); + expect(style.minHeight).toBeGreaterThanOrEqual(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 style = StyleSheet.flatten(screen.getByRole('button').props.style); + const overlay = getAppTheme('light').tokens.elevation.overlay; + expect(style.borderRadius).toBe(radius.overlay); + expect(style.shadowRadius).toBe(overlay.shadowRadius); + expect(style.shadowOpacity).toBe(overlay.shadowOpacity); + expect(style.elevation).toBe(overlay.elevation); + }); + + 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..a1b257037 --- /dev/null +++ b/app/src/components/base/__tests__/FilterSelectionModal.test.tsx @@ -0,0 +1,157 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import FilterSelectionModal from '@/components/base/FilterSelectionModal'; +import { renderWithProviders, setupUser } from '@/test-utils/index'; + +describe('FilterSelectionModal', () => { + const user = setupUser(); + it('shows a loading indicator while items are being fetched', () => { + renderWithProviders( + , + { withDialog: true }, + ); + + expect(screen.getByRole('progressbar')).toBeOnTheScreen(); + }); + + it('shows an empty state when there are no results', () => { + renderWithProviders( + , + { withDialog: true }, + ); + + expect(screen.getByText('No results')).toBeOnTheScreen(); + }); + + it('toggles selections in multi-select mode', async () => { + const onSelectionChange = jest.fn(); + + renderWithProviders( + , + { withDialog: true }, + ); + + await user.press(screen.getByText('beta')); + + expect(onSelectionChange).toHaveBeenCalledWith(['alpha', 'beta']); + }); + + it('shows add-new chip instead of "No results" in singleSelect mode 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. + renderWithProviders( + , + { withDialog: true }, + ); + + expect(screen.queryByText('No results')).toBeNull(); + expect(screen.getByText('BrandNew')).toBeOnTheScreen(); + }); + + it('calls onSelectionChange and onDismiss when add-new chip is pressed with no existing items', async () => { + const onSelectionChange = jest.fn(); + const onDismiss = jest.fn(); + + renderWithProviders( + , + { withDialog: true }, + ); + + await user.press(screen.getByText('BrandNew')); + + expect(onSelectionChange).toHaveBeenCalledWith(['BrandNew']); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('does not show add-new chip in multi-select mode even when search yields no matches', () => { + renderWithProviders( + , + { withDialog: true }, + ); + + expect(screen.getByText('No results')).toBeOnTheScreen(); + }); + + it('creates a new value in single-select mode and closes', async () => { + const onSelectionChange = jest.fn(); + const onDismiss = jest.fn(); + + renderWithProviders( + , + { withDialog: true }, + ); + + await user.press(screen.getByText('gamma')); + + expect(onSelectionChange).toHaveBeenCalledWith(['gamma']); + expect(onDismiss).toHaveBeenCalled(); + expect(screen.getByText('Cancel')).toBeOnTheScreen(); + }); +}); 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..c65e64780 --- /dev/null +++ b/app/src/components/base/__tests__/Icon.test.tsx @@ -0,0 +1,35 @@ +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(); +}); 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..c80f52ba0 --- /dev/null +++ b/app/src/components/base/__tests__/IconButton.test.tsx @@ -0,0 +1,37 @@ +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'; + +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(); + const style = StyleSheet.flatten(screen.getByRole('button').props.style); + expect(style.minWidth).toBeGreaterThanOrEqual(44); + expect(style.minHeight).toBeGreaterThanOrEqual(44); + }); +}); 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__/LocalizedFloatInput.test.tsx b/app/src/components/base/__tests__/LocalizedFloatInput.test.tsx new file mode 100644 index 000000000..db09c7671 --- /dev/null +++ b/app/src/components/base/__tests__/LocalizedFloatInput.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import LocalizedFloatInput from '@/components/base/LocalizedFloatInput'; + +describe('LocalizedFloatInput', () => { + it('renders with placeholder', () => { + render(); + expect(screen.getByPlaceholderText('Enter value')).toBeOnTheScreen(); + }); + + it('renders current value as text', () => { + render(); + expect(screen.getByDisplayValue('3.14')).toBeOnTheScreen(); + }); + + it('renders with unit text when provided', () => { + render(); + expect(screen.getByText('kg')).toBeOnTheScreen(); + }); + + it('renders label text when provided', () => { + render(); + expect(screen.getByText('Weight')).toBeOnTheScreen(); + }); + + it('calls onChange with undefined on blur when input is empty', async () => { + const onChange = jest.fn(); + render(); + const input = screen.getByDisplayValue('5'); + fireEvent.changeText(input, ''); + fireEvent(input, 'blur'); + expect(onChange).toHaveBeenCalledWith(undefined); + }); + + it('calls onChange with parsed number on valid blur', async () => { + const onChange = jest.fn(); + render(); + const input = screen.getByPlaceholderText('> 0'); + fireEvent.changeText(input, '42.5'); + fireEvent(input, 'blur'); + expect(onChange).toHaveBeenCalledWith(42.5); + }); + + it('reverts to previous value when entered value is below min', () => { + const onChange = jest.fn(); + render(); + const input = screen.getByDisplayValue('10'); + // Use fireEvent.changeText for atomic replacement — userEvent.type + // fires intermediate onChange calls that trigger the revert logic + fireEvent.changeText(input, '3'); + fireEvent(input, 'blur'); + // value 3 < min 5, so onChange is NOT called and text reverts + expect(onChange).not.toHaveBeenCalled(); + }); + + it('ignores non-numeric characters during text change', async () => { + render(); + const input = screen.getByPlaceholderText('> 0'); + fireEvent.changeText(input, 'abc'); + // "abc" doesn't match the decimal regex, so text state stays empty + expect(screen.queryByDisplayValue('abc')).toBeNull(); + }); + + it('accepts valid decimal text during typing', async () => { + render(); + const input = screen.getByPlaceholderText('> 0'); + fireEvent.changeText(input, '12.3'); + expect(screen.getByDisplayValue('12.3')).toBeOnTheScreen(); + }); + + it('input is not editable when editable=false', () => { + render(); + expect(screen.getByDisplayValue('5').props.editable).toBe(false); + }); + + it('renders empty when value is NaN', () => { + render(); + // NaN is normalized to undefined, so the field shows the placeholder not a value + expect(screen.getByPlaceholderText('> 0')).toBeOnTheScreen(); + expect(screen.queryByDisplayValue('NaN')).toBeNull(); + }); +}); 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__/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..6cac5bc16 --- /dev/null +++ b/app/src/components/base/__tests__/Section.test.tsx @@ -0,0 +1,101 @@ +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(); + fireEvent.press(screen.getByText('Add circularity notes')); + 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__/SectionNav.test.tsx b/app/src/components/base/__tests__/SectionNav.test.tsx new file mode 100644 index 000000000..22b6dc5bc --- /dev/null +++ b/app/src/components/base/__tests__/SectionNav.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { SectionNav } from '@/components/base/SectionNav'; +import { mockPlatform, restorePlatform } from '@/test-utils/index'; + +const sections = [ + { key: 'overview', label: 'Overview' }, + { key: 'components', label: 'Components' }, +] as const; + +afterEach(() => { + restorePlatform(); +}); + +test('fires onPress with the section key', () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByText('Components')); + expect(onPress).toHaveBeenCalledWith('components'); +}); + +test('marks the active item for accessibility', () => { + render( + , + ); + 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( + , + ); + 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__/SpecFacts.test.tsx b/app/src/components/base/__tests__/SpecFacts.test.tsx new file mode 100644 index 000000000..0d4212fe3 --- /dev/null +++ b/app/src/components/base/__tests__/SpecFacts.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react-native'; +import { SpecFacts } from '@/components/base/SpecFacts'; + +test('renders label/value pairs', () => { + render( + , + ); + expect(screen.getByText('Components')).toBeOnTheScreen(); + expect(screen.getByText('1.2 kg')).toBeOnTheScreen(); +}); + +test('renders nothing for empty facts', () => { + render(); + expect(screen.toJSON()).toBeNull(); +}); 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..db09bdf33 --- /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(StyleSheet.flatten(screen.getByText('LIVE').props.style).fontWeight).toBe('700'); +}); + +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(StyleSheet.flatten(screen.getByText('Warm').props.style).fontWeight).not.toBe('700'); +}); diff --git a/app/src/components/base/__tests__/Text.test.tsx b/app/src/components/base/__tests__/Text.test.tsx new file mode 100644 index 000000000..c25c501e4 --- /dev/null +++ b/app/src/components/base/__tests__/Text.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { screen } from '@testing-library/react-native'; +import { Text } from '@/components/base/Text'; +import { useEffectiveColorScheme } from '@/context/themeMode'; +import { renderWithProviders } from '@/test-utils/index'; +import { getAppTheme } from '@/theme'; + +jest.mock('@/context/themeMode', () => ({ + useEffectiveColorScheme: jest.fn(() => 'light'), +})); + +describe('Text', () => { + afterEach(() => { + jest.mocked(useEffectiveColorScheme).mockReturnValue('light'); + }); + + it('renders children text', () => { + renderWithProviders(Hello); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); + + it('applies light-mode text color in light mode', () => { + jest.mocked(useEffectiveColorScheme).mockReturnValue('light'); + renderWithProviders(Light text); + expect(screen.getByTestId('txt')).toHaveStyle({ color: getAppTheme('light').colors.onSurface }); + }); + + it('applies dark-mode text color in dark mode', () => { + jest.mocked(useEffectiveColorScheme).mockReturnValue('dark'); + renderWithProviders(Dark text); + expect(screen.getByTestId('txt')).toHaveStyle({ color: getAppTheme('dark').colors.onSurface }); + }); + + it('merges a custom style prop without overriding theme color', () => { + jest.mocked(useEffectiveColorScheme).mockReturnValue('light'); + renderWithProviders( + + Styled + , + ); + const el = screen.getByTestId('txt'); + expect(el).toHaveStyle({ fontWeight: 'bold' }); + expect(el).toHaveStyle({ color: getAppTheme('light').colors.onSurface }); + }); +}); 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..d00c86887 --- /dev/null +++ b/app/src/components/base/__tests__/TextInput.test.tsx @@ -0,0 +1,79 @@ +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 error style (background + text color) when errorOnEmpty is set and value is empty', () => { + renderWithProviders(); + const input = screen.getByTestId('test-input'); + expect(input).toHaveStyle({ backgroundColor: getAppTheme('light').colors.errorContainer }); + expect(input).toHaveStyle({ color: getAppTheme('light').colors.onErrorContainer }); + }); + + it('applies error style when customValidation returns false', () => { + const failValidation = (val: string) => val.includes('valid'); + renderWithProviders( + , + ); + const input = screen.getByTestId('validation-input'); + expect(input).toHaveStyle({ backgroundColor: getAppTheme('light').colors.errorContainer }); + expect(input).toHaveStyle({ color: getAppTheme('light').colors.onErrorContainer }); + }); + + 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({ + backgroundColor: getAppTheme('light').colors.errorContainer, + }); + 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..0ce8db8e7 --- /dev/null +++ b/app/src/components/base/__tests__/TopNav.test.tsx @@ -0,0 +1,113 @@ +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(); + +beforeEach(() => { + jest.clearAllMocks(); + mockPlatform('web'); + (useRouter as jest.Mock).mockReturnValue({ push }); + (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(push).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__/ZoomableImage.test.tsx b/app/src/components/base/__tests__/ZoomableImage.test.tsx new file mode 100644 index 000000000..3ff0ab394 --- /dev/null +++ b/app/src/components/base/__tests__/ZoomableImage.test.tsx @@ -0,0 +1,212 @@ +import { act, render, screen } from '@testing-library/react-native'; +import { Gesture } from 'react-native-gesture-handler'; +import ZoomableImage from '@/components/base/ZoomableImage'; + +describe('ZoomableImage', () => { + const testUri = 'https://example.com/image.jpg'; + type PinchGestureType = ReturnType; + type PanGestureType = ReturnType; + type TapGestureType = ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly with uri', () => { + render(); + const image = screen.getByTestId('expo-image'); + expect(image).toBeOnTheScreen(); + expect(image.props.source).toEqual({ uri: testUri }); + }); + + it('executes pinch update callback', () => { + const mockPinch = { + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onStart: jest.fn().mockReturnThis(), + }; + const onScaleChange = jest.fn(); + const setIsZoomed = jest.fn(); + jest.spyOn(Gesture, 'Pinch').mockReturnValue(mockPinch as unknown as PinchGestureType); + + render(); + + // Call the callback captured by onUpdate + const onUpdate = mockPinch.onUpdate.mock.calls[0][0]; + act(() => { + onUpdate({ scale: 2 }); + }); + + expect(onScaleChange).toHaveBeenLastCalledWith(2); + expect(setIsZoomed).toHaveBeenLastCalledWith(true); + }); + + it('resets a light pinch back to the default zoom state', () => { + const mockPinch = { + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onStart: jest.fn().mockReturnThis(), + }; + const onScaleChange = jest.fn(); + const setIsZoomed = jest.fn(); + jest.spyOn(Gesture, 'Pinch').mockReturnValue(mockPinch as unknown as PinchGestureType); + + render(); + + const onUpdate = mockPinch.onUpdate.mock.calls[0][0]; + const onEnd = mockPinch.onEnd.mock.calls[0][0]; + + act(() => { + onUpdate({ scale: 0.8 }); + onEnd(); + }); + + expect(onScaleChange).toHaveBeenLastCalledWith(1); + expect(setIsZoomed).toHaveBeenLastCalledWith(false); + }); + + it('executes pan update callback', () => { + const mockPan = { + enabled: jest.fn().mockReturnThis(), + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onStart: jest.fn().mockReturnThis(), + }; + jest.spyOn(Gesture, 'Pan').mockReturnValue(mockPan as unknown as PanGestureType); + + render(); + + const onUpdate = mockPan.onUpdate.mock.calls[0][0]; + const onEnd = mockPan.onEnd.mock.calls[0][0]; + act(() => { + onUpdate({ translationX: 10, translationY: 20 }); + onEnd({ translationX: 10, translationY: 20 }); + }); + }); + + it('emits a swipe callback when a zoomed image is swiped horizontally', () => { + const mockPinch = { + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onStart: jest.fn().mockReturnThis(), + }; + const mockPan = { + enabled: jest.fn().mockReturnThis(), + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onStart: jest.fn().mockReturnThis(), + }; + const onSwipe = jest.fn(); + const setIsZoomed = jest.fn(); + + jest.spyOn(Gesture, 'Pinch').mockReturnValue(mockPinch as unknown as PinchGestureType); + jest.spyOn(Gesture, 'Pan').mockReturnValue(mockPan as unknown as PanGestureType); + + render(); + + const pinchUpdate = mockPinch.onUpdate.mock.calls[0][0]; + const pinchEnd = mockPinch.onEnd.mock.calls[0][0]; + const panUpdate = mockPan.onUpdate.mock.calls[0][0]; + const panEnd = mockPan.onEnd.mock.calls[0][0]; + + act(() => { + pinchUpdate({ scale: 2 }); + pinchEnd(); + panUpdate({ translationX: 120, translationY: 10 }); + panEnd({ translationX: 120, translationY: 10 }); + }); + + expect(onSwipe).toHaveBeenLastCalledWith(-1); + expect(setIsZoomed).toHaveBeenLastCalledWith(false); + }); + + it('does not swipe when a zoomed image is panned across several small drags', () => { + const mockPinch = { + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onStart: jest.fn().mockReturnThis(), + }; + const mockPan = { + enabled: jest.fn().mockReturnThis(), + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onStart: jest.fn().mockReturnThis(), + }; + const onSwipe = jest.fn(); + + jest.spyOn(Gesture, 'Pinch').mockReturnValue(mockPinch as unknown as PinchGestureType); + jest.spyOn(Gesture, 'Pan').mockReturnValue(mockPan as unknown as PanGestureType); + + render(); + + const pinchUpdate = mockPinch.onUpdate.mock.calls[0][0]; + const pinchEnd = mockPinch.onEnd.mock.calls[0][0]; + const panUpdate = mockPan.onUpdate.mock.calls[0][0]; + const panEnd = mockPan.onEnd.mock.calls[0][0]; + + // Each drag stays under the swipe threshold but they accumulate far past it. + // Comparing the accumulated offset rather than the per-gesture delta fires onSwipe here. + act(() => { + pinchUpdate({ scale: 2 }); + pinchEnd(); + for (let i = 0; i < 4; i++) { + panUpdate({ translationX: 40, translationY: 0 }); + panEnd({ translationX: 40, translationY: 0 }); + } + }); + + expect(onSwipe).not.toHaveBeenCalled(); + }); + + it('executes double tap end callback', () => { + const mockTap = { + numberOfTaps: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + }; + const onScaleChange = jest.fn(); + const setIsZoomed = jest.fn(); + jest.spyOn(Gesture, 'Tap').mockReturnValue(mockTap as unknown as TapGestureType); + + render(); + + const onEnd = mockTap.onEnd.mock.calls[0][0]; + act(() => { + onEnd(); + }); + + expect(onScaleChange).toHaveBeenLastCalledWith(2); + expect(setIsZoomed).toHaveBeenLastCalledWith(true); + }); + + it('resets a zoomed image when double tapped again', () => { + const mockPinch = { + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + onStart: jest.fn().mockReturnThis(), + }; + const mockTap = { + numberOfTaps: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + }; + const onScaleChange = jest.fn(); + const setIsZoomed = jest.fn(); + + jest.spyOn(Gesture, 'Pinch').mockReturnValue(mockPinch as unknown as PinchGestureType); + jest.spyOn(Gesture, 'Tap').mockReturnValue(mockTap as unknown as TapGestureType); + + render(); + + const pinchUpdate = mockPinch.onUpdate.mock.calls[0][0]; + const pinchEnd = mockPinch.onEnd.mock.calls[0][0]; + const onEnd = mockTap.onEnd.mock.calls[0][0]; + + act(() => { + pinchUpdate({ scale: 2 }); + pinchEnd(); + onEnd(); + }); + + expect(onScaleChange).toHaveBeenLastCalledWith(1); + expect(setIsZoomed).toHaveBeenLastCalledWith(false); + }); +}); 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/frontend-app/src/components/common/dialogContext.ts b/app/src/components/base/dialogContext.ts similarity index 100% rename from frontend-app/src/components/common/dialogContext.ts rename to app/src/components/base/dialogContext.ts diff --git a/app/src/components/base/dialogStyles.ts b/app/src/components/base/dialogStyles.ts new file mode 100644 index 000000000..48181bcbf --- /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. */ +export const dialogTitleStyle: TextStyle = { + fontSize: 18, + 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..f573ad1c3 --- /dev/null +++ b/app/src/components/base/ui/badge.tsx @@ -0,0 +1,67 @@ +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({ + web: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>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..c113291ce --- /dev/null +++ b/app/src/components/base/ui/button.tsx @@ -0,0 +1,115 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { Platform, Pressable } from 'react-native'; +import { TextClassContext } from '@/components/base/ui/text'; +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: "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + }), + ), + { + variants: { + variant: { + default: cn( + 'bg-primary active:bg-primary/90', + Platform.select({ web: 'hover:bg-primary/90' }), + ), + destructive: cn( + 'bg-destructive active:bg-destructive/90 dark:bg-destructive/60', + Platform.select({ + web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40', + }), + ), + 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 — the replacement for Paper's contained-tonal: + // a low-emphasis-but-still-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/dialog.tsx b/app/src/components/base/ui/dialog.tsx new file mode 100644 index 000000000..66f406225 --- /dev/null +++ b/app/src/components/base/ui/dialog.tsx @@ -0,0 +1,158 @@ +import * as DialogPrimitive from '@rn-primitives/dialog'; +import { X } from 'lucide-react-native'; +import * as React from 'react'; +import { type GestureResponderEvent, Platform, Text, View, type ViewProps } from 'react-native'; +import { FadeIn, FadeOut, ReduceMotion } from 'react-native-reanimated'; +import { FullWindowOverlay as RNFullWindowOverlay } from 'react-native-screens'; +import { Icon } from '@/components/base/ui/icon'; +import { NativeOnlyAnimatedView } from '@/components/base/ui/native-only-animated-view'; +import { cn } from '@/utils/cn'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const FullWindowOverlay = Platform.OS === 'ios' ? RNFullWindowOverlay : React.Fragment; + +function DialogOverlay({ + className, + children, + onPress, + ...props +}: Omit, 'asChild'> & { + children?: React.ReactNode; +}) { + const { onOpenChange } = DialogPrimitive.useRootContext(); + + function onOverlayPress(event: GestureResponderEvent) { + onPress?.(event); + if (event.target === event.currentTarget && !event.isDefaultPrevented()) { + onOpenChange(false); + } + } + + return ( + + *]:cursor-auto', + }), + className, + )} + {...props} + onPress={Platform.select({ web: onOverlayPress, native: onPress })} + asChild={Platform.OS !== 'web'} + > + + + <>{children} + + + + + ); +} +function DialogContent({ + className, + portalHost, + children, + ...props +}: React.ComponentProps & { + portalHost?: string; +}) { + return ( + + + + <>{children} + + + Close + + + + + ); +} + +function DialogHeader({ className, ...props }: ViewProps) { + return ( + + ); +} + +function DialogFooter({ className, ...props }: ViewProps) { + return ( + + ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/app/src/components/base/ui/dropdown-menu.tsx b/app/src/components/base/ui/dropdown-menu.tsx new file mode 100644 index 000000000..4a695d577 --- /dev/null +++ b/app/src/components/base/ui/dropdown-menu.tsx @@ -0,0 +1,301 @@ +import * as DropdownMenuPrimitive from '@rn-primitives/dropdown-menu'; +import { Check, ChevronDown, ChevronRight, ChevronUp } from 'lucide-react-native'; +import * as React from 'react'; +import { Platform, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; +import { FadeIn, ReduceMotion } from 'react-native-reanimated'; +import { FullWindowOverlay as RNFullWindowOverlay } from 'react-native-screens'; +import { Icon } from '@/components/base/ui/icon'; +import { NativeOnlyAnimatedView } from '@/components/base/ui/native-only-animated-view'; +import { TextClassContext } from '@/components/base/ui/text'; +import { cn } from '@/utils/cn'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +function DropdownMenuSubTrigger({ + className, + inset, + children, + iconClassName, + ...props +}: React.ComponentProps & { + children?: React.ReactNode; + iconClassName?: string; + inset?: boolean; +}) { + const { open } = DropdownMenuPrimitive.useSubContext(); + const icon = Platform.OS === 'web' ? ChevronRight : open ? ChevronUp : ChevronDown; + return ( + + + <>{children} + + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +const FullWindowOverlay = Platform.OS === 'ios' ? RNFullWindowOverlay : React.Fragment; + +function DropdownMenuContent({ + className, + overlayClassName, + overlayStyle, + portalHost, + ...props +}: React.ComponentProps & { + overlayStyle?: StyleProp; + overlayClassName?: string; + portalHost?: string; +}) { + return ( + + + + + + + + + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant, + ...props +}: React.ComponentProps & { + className?: string; + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + ...props +}: React.ComponentProps & { + children?: React.ReactNode; +}) { + return ( + + + + + + + + <>{children} + + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps & { + children?: React.ReactNode; +}) { + return ( + + + + + + + + <>{children} + + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + className?: string; + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/app/src/components/base/ui/icon.tsx b/app/src/components/base/ui/icon.tsx new file mode 100644 index 000000000..b51b4ff61 --- /dev/null +++ b/app/src/components/base/ui/icon.tsx @@ -0,0 +1,63 @@ +import type { LucideIcon, LucideProps } from 'lucide-react-native'; +import { styled } from 'nativewind'; +import * as React from 'react'; +import { TextClassContext } from '@/components/base/ui/text'; +import { cn } from '@/utils/cn'; + +type IconProps = LucideProps & { + as: LucideIcon; +} & React.RefAttributes; + +function IconImpl({ as: IconComponent, ...props }: IconProps) { + return ; +} + +// nativewind 5 (react-native-css) replaced cssInterop's in-place registration with +// `styled`, which wraps the component instead of mutating it. Same className -> style +// -> size-prop mapping as the original RNR template, adapted to the new call shape. +const StyledIconImpl = styled(IconImpl, { + className: { + target: 'style', + // @ts-expect-error nativewind 5's stricter dot-path inference can't resolve + // LucideProps['style'] as a mappable object here; the mapping is functionally + // identical to the original cssInterop config and correct at runtime. + nativeStyleToProp: { + height: 'size', + width: 'size', + }, + }, +}); + +/** + * A wrapper component for Lucide icons with Nativewind `className` support via `cssInterop`. + * + * This component allows you to render any Lucide icon while applying utility classes + * using `nativewind`. It avoids the need to wrap or configure each icon individually. + * + * @component + * @example + * ```tsx + * import { ArrowRight } from 'lucide-react-native'; + * import { Icon } from '@/registry/components/ui/icon'; + * + * + * ``` + * + * @param {LucideIcon} as - The Lucide icon component to render. + * @param {string} className - Utility classes to style the icon using Nativewind. + * @param {number} size - Icon size (defaults to 14). + * @param {...LucideProps} ...props - Additional Lucide icon props passed to the "as" icon. + */ +function Icon({ as: IconComponent, className, size = 14, ...props }: IconProps) { + const textClass = React.useContext(TextClassContext); + return ( + + ); +} + +export { Icon }; diff --git a/app/src/components/base/ui/input.tsx b/app/src/components/base/ui/input.tsx new file mode 100644 index 000000000..e83624954 --- /dev/null +++ b/app/src/components/base/ui/input.tsx @@ -0,0 +1,32 @@ +import { Platform, TextInput } from 'react-native'; +import { cn } from '@/utils/cn'; + +function Input({ + className, + ...props +}: React.ComponentProps & React.RefAttributes) { + return ( + + ); +} + +export { Input }; diff --git a/app/src/components/base/ui/native-only-animated-view.tsx b/app/src/components/base/ui/native-only-animated-view.tsx new file mode 100644 index 000000000..a754712f4 --- /dev/null +++ b/app/src/components/base/ui/native-only-animated-view.tsx @@ -0,0 +1,32 @@ +import { Platform, Pressable } from 'react-native'; +import Animated from 'react-native-reanimated'; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +/** + * This component is used to wrap animated views that should only be animated on native. + * @param props - The props for the animated view. + * @returns The animated view if the platform is native, otherwise the children. + * @example + * + * I am only animated on native + * + */ +function NativeOnlyAnimatedView( + props: + | (React.ComponentProps & + React.RefAttributes & { as?: 'View' }) + | (React.ComponentProps & + React.RefAttributes & { as: 'Pressable' }), +) { + if (Platform.OS === 'web') { + return <>{props.children as React.ReactNode}; + } else { + if (props.as === 'Pressable') { + return ; + } + return ; + } +} + +export { NativeOnlyAnimatedView }; 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..afb3d6b6d --- /dev/null +++ b/app/src/components/base/ui/switch.tsx @@ -0,0 +1,34 @@ +import * as SwitchPrimitives from '@rn-primitives/switch'; +import { Platform } from 'react-native'; +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..1bf117231 --- /dev/null +++ b/app/src/components/base/ui/text.tsx @@ -0,0 +1,89 @@ +import { Slot } from '@rn-primitives/slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { Platform, Text as RNText, type Role } from 'react-native'; +import { cn } from '@/utils/cn'; + +const textVariants = cva( + cn( + 'text-foreground text-base', + Platform.select({ + web: 'select-text', + }), + ), + { + variants: { + variant: { + default: '', + h1: cn( + 'text-center text-4xl font-extrabold tracking-tight', + Platform.select({ web: 'scroll-m-20 text-balance' }), + ), + h2: cn( + 'border-border border-b pb-2 text-3xl font-semibold tracking-tight', + Platform.select({ web: 'scroll-m-20 first:mt-0' }), + ), + h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })), + h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })), + p: 'mt-3 leading-7 sm:mt-6', + blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6', + code: cn( + 'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold', + ), + lead: 'text-muted-foreground text-xl', + large: 'text-lg font-semibold', + small: 'text-sm font-medium leading-none', + muted: 'text-muted-foreground text-sm', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +type TextVariantProps = VariantProps; + +type TextVariant = NonNullable; + +const ROLE: Partial> = { + h1: 'heading', + h2: 'heading', + h3: 'heading', + h4: 'heading', + blockquote: Platform.select({ web: 'blockquote' as Role }), + code: Platform.select({ web: 'code' as Role }), +}; + +const ARIA_LEVEL: Partial> = { + h1: '1', + h2: '2', + h3: '3', + h4: '4', +}; + +const TextClassContext = React.createContext(undefined); + +function Text({ + className, + asChild = false, + variant = 'default', + ...props +}: React.ComponentProps & + React.RefAttributes & + TextVariantProps & { + 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..f0902e2ac --- /dev/null +++ b/app/src/components/base/ui/toggle-group.tsx @@ -0,0 +1,102 @@ +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 { Icon } from '@/components/base/ui/icon'; +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} + + + ); +} + +function ToggleGroupIcon({ className, ...props }: React.ComponentProps) { + const textClass = React.useContext(TextClassContext); + return ; +} + +export { ToggleGroup, ToggleGroupIcon, 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..5c09975c2 --- /dev/null +++ b/app/src/components/base/ui/toggle.tsx @@ -0,0 +1,74 @@ +import * as TogglePrimitive from '@rn-primitives/toggle'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { Platform } from 'react-native'; +import { Icon } from '@/components/base/ui/icon'; +import { TextClassContext } from '@/components/base/ui/text'; +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: 'hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 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] focus-visible:ring-[3px] disabled:pointer-events-none [&_svg]:pointer-events-none', + }), + ), + { + variants: { + variant: { + default: 'bg-transparent', + outline: cn( + 'border-input active:bg-accent border bg-transparent shadow-sm shadow-black/5', + 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 ( + + + + ); +} + +function ToggleIcon({ className, ...props }: React.ComponentProps) { + const textClass = React.useContext(TextClassContext); + return ; +} + +export { Toggle, ToggleIcon, toggleVariants }; diff --git a/app/src/components/base/ui/tooltip.tsx b/app/src/components/base/ui/tooltip.tsx new file mode 100644 index 000000000..9adf9b185 --- /dev/null +++ b/app/src/components/base/ui/tooltip.tsx @@ -0,0 +1,72 @@ +import * as TooltipPrimitive from '@rn-primitives/tooltip'; +import * as React from 'react'; +import { Platform, StyleSheet } from 'react-native'; +import { FadeInDown, FadeInUp, FadeOut, ReduceMotion } from 'react-native-reanimated'; +import { FullWindowOverlay as RNFullWindowOverlay } from 'react-native-screens'; +import { NativeOnlyAnimatedView } from '@/components/base/ui/native-only-animated-view'; +import { TextClassContext } from '@/components/base/ui/text'; +import { cn } from '@/utils/cn'; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const FullWindowOverlay = Platform.OS === 'ios' ? RNFullWindowOverlay : React.Fragment; + +function TooltipContent({ + className, + sideOffset = 4, + portalHost, + side = 'top', + ...props +}: React.ComponentProps & { + portalHost?: string; +}) { + return ( + + + + + + + + + + + + ); +} + +export { Tooltip, TooltipContent, TooltipTrigger }; diff --git a/app/src/components/cameras/ActiveStreamBanner.tsx b/app/src/components/cameras/ActiveStreamBanner.tsx new file mode 100644 index 000000000..0aa534ed6 --- /dev/null +++ b/app/src/components/cameras/ActiveStreamBanner.tsx @@ -0,0 +1,101 @@ +import { useCallback, useState } from 'react'; +import { Platform, Pressable, StyleSheet, View } from 'react-native'; +import { Text } from '@/components/base/Text'; +import { radius } from '@/constants'; +import { useStreamSession } from '@/context/streamSession'; +import { useElapsed } from '@/hooks/useElapsed'; +import { useAppTheme } from '@/theme'; +import { getFloatingPosition } from '@/utils/platformLayout'; +import { StreamingSheet } from './StreamingSheet'; + +// Clears the native tab bar; on web the banner floats just above the viewport edge. +const BOTTOM_INSET = Platform.OS === 'web' ? 16 : 88; + +export function ActiveStreamBanner() { + const theme = useAppTheme(); + const { activeStream } = useStreamSession(); + const elapsed = useElapsed(activeStream?.startedAt ?? null); + const [sheetVisible, setSheetVisible] = useState(false); + const openSheet = useCallback(() => setSheetVisible(true), []); + const closeSheet = useCallback(() => setSheetVisible(false), []); + + // 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({ + container: { + position: getFloatingPosition(), + left: 16, + right: 16, + alignItems: 'center', + pointerEvents: 'box-none', + }, + banner: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: radius.card, + // subtle red glow via shadow + ...(Platform.OS === 'web' + ? {} + : { + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.5, + shadowRadius: 8, + }), + elevation: 8, + }, + liveDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + label: { + flex: 1, + fontSize: 13, + fontWeight: '600', + }, + elapsed: { + fontSize: 12, + fontVariant: ['tabular-nums'], + }, +}); diff --git a/app/src/components/cameras/CameraCard.tsx b/app/src/components/cameras/CameraCard.tsx new file mode 100644 index 000000000..8ed89669b --- /dev/null +++ b/app/src/components/cameras/CameraCard.tsx @@ -0,0 +1,188 @@ +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 { StatusBadge } from '@/components/base/StatusBadge'; +import { STATUS_LABEL } from '@/components/cameras/detail/styles'; +import { radius } from '@/constants'; +import type { EffectiveCameraConnection } from '@/features/cameras/useEffectiveCameraConnection'; +import type { CameraReadWithStatus } from '@/services/api/rpiCamera'; +import { getStatusColor, useAppTheme } from '@/theme'; +import { TelemetryBadge } from './TelemetryBadge'; + +/** + * 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; + const hasThumbnail = isOnline && Boolean(thumbnailUrl) && failedThumbnailUrl !== thumbnailUrl; + const handleThumbnailError = useCallback( + () => setFailedThumbnailUrl(thumbnailUrl), + [thumbnailUrl], + ); + + return ( + + {/* Thumbnail (online only) or placeholder */} + + {hasThumbnail ? ( + + ) : ( + + {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({ + card: { + width: '100%', + maxWidth: 420, + alignSelf: 'center', + borderRadius: radius.card, + }, + cardOffline: { + opacity: 0.6, + }, + thumbnailFrame: { + width: '100%', + aspectRatio: 16 / 10, + overflow: 'hidden', + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + }, + thumbnail: { + width: '100%', + height: '100%', + }, + thumbnailPlaceholder: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + }, + placeholderIcon: { + opacity: 0.4, + }, + thumbnailCaption: {}, + // Base Card has no built-in content padding (unlike Paper's Card.Content, + // which defaulted to paddingHorizontal: 16) — added explicitly here. + cardContent: { + paddingVertical: 12, + paddingHorizontal: 16, + }, + cardBody: { + gap: 4, + }, + cardChips: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 6, + marginTop: 6, + }, + lastSeenText: { + 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..2c3a14d61 --- /dev/null +++ b/app/src/components/cameras/CameraPickerDialog.tsx @@ -0,0 +1,181 @@ +import { useRouter } from 'expo-router'; +import { useCallback, useMemo } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { AppText } from '@/components/base/AppText'; +import { Icon } from '@/components/base/Icon'; +import { MutedText } from '@/components/base/MutedText'; +import { radius } from '@/constants'; +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'; + +interface CameraPickerDialogProps { + visible: boolean; + onDismiss: () => void; + /** Called with the selected camera (only online cameras are selectable). */ + onSelect: (camera: CameraReadWithStatus) => void; + title?: string; +} + +/** + * 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', +}: CameraPickerDialogProps) { + const theme = useAppTheme(); + const router = useRouter(); + const { data: cameras, isLoading } = useCamerasQuery(true, { enabled: visible }); + + const handleManage = useCallback(() => { + onDismiss(); + router.push('/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 + + )} + + ); +} + +const styles = StyleSheet.create({ + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + content: { + gap: 8, + }, + loading: { + padding: 16, + }, + emptyState: { + padding: 16, + alignItems: 'center', + gap: 8, + }, + emptyText: { + textAlign: 'center', + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + gap: 4, + marginTop: 16, + }, + spacer: { + flex: 1, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + padding: 12, + borderRadius: radius.card, + borderWidth: 1, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, + rowTitle: { + flex: 1, + }, +}); diff --git a/app/src/components/cameras/CameraStreamPicker.tsx b/app/src/components/cameras/CameraStreamPicker.tsx new file mode 100644 index 000000000..90aa92c26 --- /dev/null +++ b/app/src/components/cameras/CameraStreamPicker.tsx @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; +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; +} + +export function CameraStreamPicker({ + productId, + productName, + visible, + onDismiss, +}: 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..7b3c2f38a --- /dev/null +++ b/app/src/components/cameras/GoLiveDialog.tsx @@ -0,0 +1,127 @@ +import { useCallback } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppDialog } from '@/components/base/AppDialog'; +import { AppText } from '@/components/base/AppText'; +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 { radius } from '@/constants'; +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; +}; + +export function GoLiveDialog({ + visible, + cameraName, + title, + privacy, + loading, + onDismiss, + onChangeTitle, + onChangePrivacy, + onStart, + secondaryLabel, + onSecondary, + showSpacer = false, +}: 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 + + + + ); +} + +const styles = StyleSheet.create({ + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + content: { + gap: 12, + }, + input: { + borderWidth: 1, + borderRadius: radius.control, + paddingHorizontal: 12, + paddingVertical: 10, + }, + label: { + marginTop: 4, + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 4, + marginTop: 16, + }, + spacer: { + flex: 1, + }, +}); diff --git a/app/src/components/cameras/LivePreview.tsx b/app/src/components/cameras/LivePreview.tsx new file mode 100644 index 000000000..97cf38b35 --- /dev/null +++ b/app/src/components/cameras/LivePreview.tsx @@ -0,0 +1,89 @@ +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 { createLivePreviewStyles } from '@/components/cameras/live-preview/styles'; +import type { CameraConnectionInfo } from '@/features/cameras/local-connection/useLocalConnection'; +import { useCameraLivePreview } from '@/features/cameras/rpi/hooks'; +import type { CameraRead } from '@/services/api/rpiCamera'; +import { useAppTheme } from '@/theme'; + +/** + * LL-HLS live preview for a single camera. + * + * Web: renders a `` {isLongDescription && ( setIsExpanded((current) => !current)} + onPress={toggleExpanded} accessibilityRole="button" accessibilityLabel={expanded ? 'Show less of description' : 'Show more of description'} > @@ -63,10 +69,10 @@ export default function ProductDescription({ product, editMode, onChangeDescript return ( onChangeDescription?.(draftText)} + onBlur={handleBlur} editable={editMode} multiline numberOfLines={undefined} diff --git a/app/src/components/product/ProductDetailsSkeleton.tsx b/app/src/components/product/ProductDetailsSkeleton.tsx new file mode 100644 index 000000000..2e8169d5e --- /dev/null +++ b/app/src/components/product/ProductDetailsSkeleton.tsx @@ -0,0 +1,85 @@ +import { ScrollView, StyleSheet, View } from 'react-native'; +import DetailCard from '@/components/base/DetailCard'; +import { Skeleton } from '@/components/base/Skeleton'; +import { radius, spacing } from '@/constants'; +import { useAppTheme } from '@/theme'; + +/** + * Loading placeholder mirroring the detail layout (Content.tsx): a full-bleed + * gallery, the SpecHeader identity block (name + a couple of facts), then a few + * titled section cards. + */ +export default function ProductDetailsSkeleton() { + const theme = useAppTheme(); + const bg = theme.colors.surfaceVariant; + + return ( + + {/* Full-bleed image gallery */} + + + {/* SpecHeader: name + a couple of fact lines */} + + + + + + + {/* A few titled section cards */} + {[1, 2, 3].map((n) => ( + + + + + + + + + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 15, + paddingBottom: 20, + }, + gallery: { + width: '100%', + aspectRatio: 16 / 9, + }, + specHeader: { + paddingHorizontal: spacing.md, + paddingVertical: 12, + gap: 8, + }, + name: { + height: 28, + borderRadius: radius.sm, + width: '70%', + }, + fact: { + height: 14, + borderRadius: radius.sm, + width: '55%', + }, + section: { + paddingHorizontal: spacing.md, + gap: 10, + }, + sectionTitle: { + height: 18, + borderRadius: radius.sm, + width: '35%', + }, + cardContent: { + gap: 12, + }, + line: { + height: 16, + borderRadius: radius.sm, + width: '100%', + }, +}); diff --git a/frontend-app/src/components/product/ProductImageGallery.tsx b/app/src/components/product/ProductImageGallery.tsx similarity index 86% rename from frontend-app/src/components/product/ProductImageGallery.tsx rename to app/src/components/product/ProductImageGallery.tsx index be6d064ac..8e8d3427f 100644 --- a/frontend-app/src/components/product/ProductImageGallery.tsx +++ b/app/src/components/product/ProductImageGallery.tsx @@ -1,13 +1,12 @@ -// spell-checker: ignore Zoomable - -import { View } from 'react-native'; +import { useCallback } from 'react'; +import { StyleSheet, View } from 'react-native'; import { ProductImageCameraDialogs } from '@/components/product/gallery/ProductImageCameraDialogs'; import { ProductImageEmptyEditState } from '@/components/product/gallery/ProductImageEmptyEditState'; import { ProductImageGalleryContent } from '@/components/product/gallery/ProductImageGalleryContent'; import { ProductImageLightbox } from '@/components/product/gallery/ProductImageLightbox'; import { ProductImagePlaceholder } from '@/components/product/gallery/ProductImagePlaceholder'; import { ProductImageThumbnails } from '@/components/product/gallery/ProductImageThumbnails'; -import { useProductImageGallery } from '@/hooks/products/useProductImageGallery'; +import { useProductImageGallery } from '@/features/products/useProductImageGallery'; import type { Product } from '@/types/Product'; interface Props { @@ -24,19 +23,23 @@ export default function ProductImageGallery({ product, editMode, onImagesChange }); const handleTakePhoto = async () => actions.takePhoto(); const handlePickImage = async () => actions.pickImage(); + const handleDeleteImage = useCallback( + () => actions.deleteImage(viewer.selectedIndex), + [actions, viewer.selectedIndex], + ); if (media.imageCount === 0 && !editMode) { return ; } return ( - + {media.imageCount > 0 ? ( actions.deleteImage(viewer.selectedIndex)} + onDeleteImage={handleDeleteImage} /> ) : editMode ? ( ); } + +const styles = StyleSheet.create({ + container: { marginBottom: 16 }, +}); diff --git a/app/src/components/product/ProductNameHeader.tsx b/app/src/components/product/ProductNameHeader.tsx new file mode 100644 index 000000000..bbc3c350a --- /dev/null +++ b/app/src/components/product/ProductNameHeader.tsx @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react'; +import { type ColorValue, View } from 'react-native'; +import { AppText } from '@/components/base/AppText'; +import { TextInput } from '@/components/base/TextInput'; +import { radius } from '@/constants'; +import { truncateHeaderLabel } from '@/features/products/truncateHeaderLabel'; +import { PRODUCT_NAME_MAX_LENGTH, productSchema } from '@/services/api/validation/productSchema'; +import type { AppTheme } from '@/theme'; + +/** + * Header title for the product detail screen. In view mode it renders the + * name as plain text (truncated to fit the header). In edit mode it becomes a + * single-line editable input so the header itself is the name field — no + * duplicate "Product name" row in the body of the form. + */ +export function ProductNameHeader({ + name, + editMode, + theme, + onProductNameChange, +}: { + name: string | undefined; + editMode: boolean; + theme: AppTheme; + onProductNameChange?: (newName: string) => void; +}) { + const [draft, setDraft] = useState(null); + const value = draft ?? name ?? ''; + + const handleBlur = useCallback(() => { + const trimmedValue = value.trim(); + if (trimmedValue !== name) { + onProductNameChange?.(trimmedValue); + } + }, [value, name, onProductNameChange]); + + if (!editMode) { + return ( + + {truncateHeaderLabel(name, 36)} + + ); + } + + const trimmed = value.trim(); + const isInvalid = !productSchema.shape.name.safeParse(trimmed).success; + + return ( + + + + ); +} diff --git a/app/src/components/product/ProductVideo.tsx b/app/src/components/product/ProductVideo.tsx new file mode 100644 index 000000000..0f55ffc1c --- /dev/null +++ b/app/src/components/product/ProductVideo.tsx @@ -0,0 +1,386 @@ +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useCallback, useState } from 'react'; +import { Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { AppButton } from '@/components/base/AppButton'; +import { AppText } from '@/components/base/AppText'; +import DetailSectionHeader from '@/components/base/DetailSectionHeader'; +import { useDialog } from '@/components/base/dialogContext'; +import { Icon } from '@/components/base/Icon'; +import { TextInput } from '@/components/base/TextInput'; +import { StreamingContent } from '@/components/cameras/StreamingContent'; +import { useProductVideo } from '@/features/products/useProductVideo'; +import { useAppTheme } from '@/theme'; +import type { Product } from '@/types/Product'; +import { isHttpUrl } from '@/utils/urlSafety'; +import { VideoEmbed } from './ProductVideoEmbed'; + +interface Video { + id?: number; + url: string; + title: string; + description: string; +} + +interface Props { + product: Product; + editMode: boolean; + onVideoChange?: (videos: Video[]) => void; + onGoLivePress: () => void; +} + +export default function ProductVideo({ product, editMode, onVideoChange, onGoLivePress }: Props) { + const { + rpiEnabled, + youtubeEnabled, + isGoogleLinked, + activeStream, + streamingThisProduct, + streamingOtherProduct, + ownedByMe, + goToProfile, + goToActiveStreamProduct, + } = useProductVideo(product); + // product comes from a live form watch, so edits round-trip through onVideoChange. + const videos = product.videos ?? []; + const [expanded, setExpanded] = useState(false); + const toggleExpanded = useCallback(() => setExpanded((value) => !value), []); + const dialog = useDialog(); + const theme = useAppTheme(); + const linkColor = theme.tokens.text.link; + const textColor = theme.colors.onBackground; + + const handleVideoChange = ( + idx: number, + field: 'url' | 'title' | 'description', + value: string, + ) => { + onVideoChange?.(videos.map((v, i) => (i === idx ? { ...v, [field]: value } : v))); + }; + + const handleRemove = (idx: number) => { + onVideoChange?.(videos.filter((_, i) => i !== idx)); + }; + + const handleAdd = () => { + dialog.input({ + title: 'Add video', + placeholder: 'Video URL', + helperText: 'Paste a video URL (YouTube)', + buttons: [ + { text: 'Cancel' }, + { + text: 'Add', + disabled: (value) => !(value?.trim() && isHttpUrl(value)), + onPress: (url) => { + if (!(url && isHttpUrl(url))) return; + onVideoChange?.([...videos, { url: url.trim(), title: '', description: '' }]); + }, + }, + ], + }); + }; + + const handleGoLivePress = () => { + if (streamingOtherProduct && activeStream) { + dialog.alert({ + title: 'Already streaming', + message: `You're currently live on "${activeStream.productName}". Stop that stream before starting a new one.`, + buttons: [{ text: 'OK' }, { text: 'Go to stream', onPress: goToActiveStreamProduct }], + }); + return; + } + onGoLivePress(); + }; + + const showGoLiveCta = ownedByMe && rpiEnabled && !streamingThisProduct; + const hasVideos = videos.length > 0; + const showExpandToggle = !editMode && hasVideos; + const showVideoRows = editMode || streamingThisProduct || expanded; + const showEmptyState = !( + editMode || + expanded || + streamingThisProduct || + showGoLiveCta || + hasVideos + ); + + return ( + + + } + /> + + {streamingThisProduct && activeStream ? ( + + ) : null} + + {showGoLiveCta ? ( + + ) : null} + + {showVideoRows ? ( + + ) : null} + + {showEmptyState ? : null} + + ); +} + +function VideoHeaderAction({ + editMode, + showExpandToggle, + isExpanded, + videoCount, + linkColor, + onAdd, + onToggleExpanded, +}: { + editMode: boolean; + showExpandToggle: boolean; + isExpanded: boolean; + videoCount: number; + linkColor: string; + onAdd: () => void; + onToggleExpanded: () => void; +}) { + if (editMode) { + return ( + + Add video + + ); + } + + if (!showExpandToggle) { + return null; + } + + return ( + + {isExpanded ? 'Hide' : `Show (${videoCount})`} + + ); +} + +function VideoList({ + videos, + editMode, + textColor, + linkColor, + onVideoChange, + onRemove, +}: { + videos: Video[]; + editMode: boolean; + textColor: string; + linkColor: string; + onVideoChange: (idx: number, field: 'url' | 'title' | 'description', value: string) => void; + onRemove: (idx: number) => void; +}) { + return videos.map((video, idx) => ( + + )); +} + +function VideoRow({ + video, + idx, + editMode, + textColor, + linkColor, + onVideoChange, + onRemove, +}: { + video: Video; + idx: number; + editMode: boolean; + textColor: string; + linkColor: string; + onVideoChange: (idx: number, field: 'url' | 'title' | 'description', value: string) => void; + onRemove: (idx: number) => void; +}) { + const handleTitleChange = useCallback( + (value: string) => onVideoChange(idx, 'title', value), + [onVideoChange, idx], + ); + const handleUrlChange = useCallback( + (value: string) => onVideoChange(idx, 'url', value), + [onVideoChange, idx], + ); + const handleDescriptionChange = useCallback( + (value: string) => onVideoChange(idx, 'description', value), + [onVideoChange, idx], + ); + const handleRemove = useCallback(() => onRemove(idx), [onRemove, idx]); + + return ( + + + + {editMode ? ( + + ) : ( + + )} + {editMode || video.description ? ( + + ) : null} + + {editMode ? ( + + + + ) : null} + + ); +} + +function GoLiveCTA({ + youtubeEnabled, + isGoogleLinked, + onGoLivePress, + onNavigateToProfile, +}: { + youtubeEnabled: boolean; + isGoogleLinked: boolean; + onGoLivePress: () => void; + onNavigateToProfile: () => void; +}) { + const dialog = useDialog(); + const theme = useAppTheme(); + const ready = isGoogleLinked && youtubeEnabled; + + const handlePress = useCallback(() => { + if (!ready) { + const message = !isGoogleLinked + ? 'Link your Google account in your profile to enable live streaming.' + : 'Enable YouTube Live in your profile integrations to start streaming.'; + dialog.alert({ + title: 'Set up YouTube Live', + message, + buttons: [{ text: 'Cancel' }, { text: 'Go to profile', onPress: onNavigateToProfile }], + }); + return; + } + onGoLivePress(); + }, [ready, isGoogleLinked, dialog, onNavigateToProfile, onGoLivePress]); + + return ( + + + Go Live + + ); +} + +function EmptyVideoState({ mutedColor }: { mutedColor: string }) { + return ( + + This product has no associated videos. + + ); +} + +const styles = StyleSheet.create({ + headerAction: { + marginTop: 4, + }, + videoRow: { + marginBottom: 16, + flexDirection: 'row', + alignItems: 'center', + }, + videoFields: { + flex: 1, + }, + titleInput: { + paddingHorizontal: 14, + fontSize: 20, + fontWeight: 'bold', + lineHeight: 16, + }, + bodyInput: { + paddingHorizontal: 14, + fontSize: 16, + lineHeight: 26, + }, + descriptionInput: { + paddingHorizontal: 14, + fontSize: 16, + lineHeight: 16, + }, + deleteButton: { + padding: 14, + justifyContent: 'center', + alignItems: 'center', + }, + emptyState: { + opacity: 0.7, + marginBottom: 8, + }, +}); diff --git a/app/src/components/product/ProductVideoEmbed.tsx b/app/src/components/product/ProductVideoEmbed.tsx new file mode 100644 index 000000000..323a9b1d5 --- /dev/null +++ b/app/src/components/product/ProductVideoEmbed.tsx @@ -0,0 +1,89 @@ +import { useCallback, useState } from 'react'; +import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { radius } from '@/constants'; +import { extractYouTubeVideoId } from '@/services/api/validation/productSchema'; +import { openExternalUrl } from '@/services/externalLinks'; + +const embedContainerStyle = { + maxWidth: 480, + aspectRatio: 16 / 9, + width: '100%' as const, + alignSelf: 'center' as const, + marginHorizontal: 14, + marginVertical: 8, + borderRadius: radius.card, + overflow: 'hidden' as const, +}; + +export function VideoEmbed({ url, linkColor }: { url: string; linkColor: string }) { + const [loaded, setLoaded] = useState(false); + const videoId = extractYouTubeVideoId(url); + const handleOpenUrl = useCallback(async () => openExternalUrl(url), [url]); + const handleLoad = useCallback(() => setLoaded(true), []); + if (!videoId) { + return ( + + {url} + + ); + } + const embedUri = `https://www.youtube-nocookie.com/embed/${videoId}`; + if (!loaded) { + return ( + + + Load video + + + Open video + + + ); + } + if (Platform.OS === 'web') { + return ( + +