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.