think
16px
820px

Mobile e2e — two harnesses, no phone required

Both drive the REAL app against a REAL backend (throwaway dind stack + the dev
license). Neither replaces a human pass on physical hardware; both catch the bugs a
simulator would.

Backend (shared by both)

docker run -d --name e2e-pg -e POSTGRES_USER=obscura -e POSTGRES_PASSWORD=obscura \
  -e POSTGRES_DB=obscura -p 55433:5432 pgvector/pgvector:pg16
docker run -d --name e2e-minio -e MINIO_ROOT_USER=obscura \
  -e MINIO_ROOT_PASSWORD=obscura-dev-secret -p 39102:9000 minio/minio server /data
docker run -d --name e2e-gotenberg -p 33000:3000 gotenberg/gotenberg:8
node e2e/extract-stub.cjs &        # :38001 — text extraction AND the raster endpoints
go build -o /tmp/obscura-server ./go/cmd/obscura-server
OBSCURA_ENV=development OBSCURA_ALLOW_DEV_LOGIN=true METRICS_ADDR=:8093 \
  DATABASE_URL='postgres://obscura:obscura@dind:55433/obscura?sslmode=disable' \
  S3_ENDPOINT=dind:39102 S3_ACCESS_KEY=obscura S3_SECRET_KEY=obscura-dev-secret \
  PUSH_DEV_LOG=true PROTECTION_ENGINE=noop \
  ESIGN_PROVIDER=mock METERAI_PROVIDER=mock STAMP_PROVIDER=mock \
  GOTENBERG_URL=http://dind:33000 \
  EXTRACT_PROVIDER=sidecar EXTRACT_SIDECAR_URL=http://localhost:38001 \
  APP_BASE_URL=http://localhost:8095 \
  LICENSE_FILE=deploy/secrets/obscura.license.json /tmp/obscura-server &
node e2e/seed.cjs   # prints {email, title, docId} for the flows

Every one of those variables is load-bearing, and a missing one fails as a UI timeout
rather than a config error
— which reads like a regression in the app. What each buys,
and which suite dies without it:

missing symptom suite
ESIGN_PROVIDER=mock no external tier ⇒ the tier chips never render, so "Signature type" never appears run-r17-inbox-sign
GOTENBERG_URL POST /workflow/tasks/{id}/number 500s (dial tcp :33000 refused) — numbering renders a PDF run-r20-registrar-duties
EXTRACT_PROVIDER=sidecar + the stub no text ⇒ no AI analysis, and the placement editors fall back to the blank byte path run-upload-ai, all placement probes
APP_BASE_URL every WebAuthn begin answers auth.webauthn.disabled run-r19-passkey-stepup
a readable LICENSE_FILE license file unreadable ⇒ every module 403s seed.cjs itself

⚠️ deploy/secrets/obscura.license.json may be owned by uid 65532 (written by a
container) and unreadable to you — the .bak-* siblings next to it usually are readable.

Two suites need conditions this stack does not reproduce, and fail on their last
assertion rather than on the app: run-r7-features wants semantic hits (document_chunks
stays empty here, so /search/semantic 404s) and run-r13-features wants the OTP leg of
the stamp ceremony (the mock provider seals in one step and answers 201, so no OTP input
is ever shown).

1. Web-export harness (fast, Playwright)

The app exported with expo export --platform web + a same-origin static/API proxy,
driven by Playwright. Covers navigation, auth, inbox, acting, the sign ceremony —
everything except native modules (SecureStore falls back to localStorage; the PDF
page render is absent).

pnpm exec expo export --platform web --output-dir /tmp/mobile-web
node e2e/web-serve.cjs &            # :8095, proxies /api → :8093
node e2e/web-e2e.cjs                # full journey + screenshots + server-side asserts

2. Device harness (real Android, redroid + Maestro)

A real Android 13 container (redroid — needs the host's binder_linux module loaded:
modprobe binder_linux devices=binder,hwbinder,vndbinder; no KVM required) running
the actual release APK, driven by Maestro over adb. This exercises the NATIVE app:
SecureStore, gestures, react-native-pdf, Hermes.

docker run -d --privileged --name redroid -p 5555:5555 \
  redroid/redroid:13.0.0-latest androidboot.redroid_gpu_mode=guest
adb connect <docker-host>:5555
adb reverse tcp:8093 tcp:8093       # device localhost:8093 → backend

# Cleartext is OPT-IN since the 2026-08-08 scan: release Android builds no longer permit
# http:// at all, so a rig pointed at an http backend must ask for it explicitly.
OBSCURA_ALLOW_CLEARTEXT=true pnpm exec expo prebuild --platform android --no-install
# android/gradle.properties: reactNativeArchitectures=x86_64 (redroid) — keep all four for phones
(cd android && ANDROID_HOME=~/android/sdk ./gradlew assembleRelease)
adb install -r android/app/build/outputs/apk/release/app-release.apk

EMAIL=... TITLE=... maestro test e2e/device-flow.yaml   # values from seed.cjs output

Release builds sign with the debug keystore (Expo template default) — fine for e2e,
never for distribution. The android/ directory is generated (CNG) and gitignored;
prebuild recreates it from app.json.

3. Pure-guard unit checks (no backend, no browser)

Some defences are functions, and one of them — redirectSystemPath, the native intent
bridge — does not run on the web export at all, so driving the browser proves nothing
about it. Those are checked directly:

node --experimental-strip-types e2e/intake-unit.mjs

Covers the deep-link route allowlist (including that obscura://ai-chat?q= cannot make
the app run an archive question), the intent-intake validation (our own SecureStore /
AsyncStorage / pin cache are never uploadable), and the secure-preview page-count clamp.
The modules it imports (src/app/+native-intent.ts, src/lib/intake.ts,
src/lib/preview-pages.ts) are deliberately free of React Native imports so node can
strip their types and run them as-is — keep them that way.