platepilot

M1 local demo and acceptance flows

This runbook walks through the M1 adult-logging slice end to end on one machine: local services, migrations, API, photo worker, the Expo dev build, and the three Maestro acceptance flows from Task 9 of the M1 plan. It also lists the exact mock inputs that produce each photo and synchronization state, and what should be on screen at each step.

What the flows prove

Flow file (apps/mobile/.maestro/) Plan items Proves
onboarding-and-manual-log.yaml 1, 6 An adult completes onboarding, logs a manual meal and water, and the Today totals match the persisted entries (also visible after refresh).
photo-analysis-recovery.yaml 2, 3 A photo job is queued through the deterministic mock, the estimate stays provisional until reviewed, and a failure exposes retry and manual entry.
offline-sync-conflict.yaml 4, 5 Offline water and meal-edit commands replay after connectivity returns; a version conflict stays visible on Today and History until resolved.

Shared pieces live in apps/mobile/.maestro/shared/ (onboarding, manual meal, system photo picker, and the host-side script that creates the conflict). apps/mobile/.maestro/fixtures/synthetic-plate.png is a 256×256 solid-colour PNG generated with the Python standard library; it contains no real photo.

Selectors are keyed on button labels and accessibilityLabel values (AccessibleAction sets accessibilityLabel to its label), on screen copy, and on the bottom navigation tabs Today, Log, History. Nothing depends on layout, colours, or component order except one below: relative selector noted in the offline flow.

Prerequisites

Step 1: services, environment, migrations

From the repository root:

cp .env.example .env
cp apps/api/.env.example apps/api/.env
docker compose up -d --wait
corepack pnpm --filter @platepilot/api exec prisma migrate deploy
corepack pnpm prisma:generate

docker compose up -d --wait waits for both health checks. The LocalStack init script (infra/localstack/init/10-foundation.sh) creates the versioned photo buckets and the platepilot-meal-analysis queue. MOCK_PROVIDERS=true in the example env selects every deterministic mock, including identity: the mobile app authenticates with the fixed token local.synthetic-token, which the DeterministicIdentityProvider maps to one synthetic adult subject.

Start from an empty diary for a clean demo. The flows create meals for today, and onboarding-and-manual-log.yaml asserts exact totals on an otherwise empty day. To reset between full runs:

docker compose down -v
docker compose up -d --wait
corepack pnpm --filter @platepilot/api exec prisma migrate deploy

Uninstall the app from the device as well, or rely on the flows’ launchApp: clearState: true; re-onboarding the same subject returns the existing server profile, so the server diary persists until the database is reset.

Step 2: API and photo worker

Two terminals from the repository root:

corepack pnpm --filter @platepilot/api dev        # HTTP API on http://localhost:3001
corepack pnpm --filter @platepilot/api worker     # photo outbox dispatcher + job processor

The worker polls every 250 ms. corepack pnpm --filter @platepilot/api exec tsx src/worker.ts --once runs a single pass, which is convenient when stepping through the photo scenarios by hand. Check the API with curl -s http://localhost:3001/health.

Step 3: Expo dev build

EXPO_PUBLIC_API_BASE_URL is read at bundle time (apps/mobile/src/features/app-modules.ts), so set it when you start the build. The device must reach the API on the host:

# Android emulator: 10.0.2.2 is the host's loopback
EXPO_PUBLIC_API_BASE_URL=http://10.0.2.2:3001 corepack pnpm --filter @platepilot/mobile android

# iOS simulator: localhost is the host
EXPO_PUBLIC_API_BASE_URL=http://localhost:3001 corepack pnpm --filter @platepilot/mobile ios

This installs com.platepilot.mobile and starts Metro. Keep Metro running while Maestro drives the app. The first launch shows the welcome screen (I am 18 or older).

Step 4: run the Maestro flows

corepack pnpm e2e:maestro                                   # all three, in config.yaml order
maestro test apps/mobile/.maestro/onboarding-and-manual-log.yaml
maestro test -e MOCK_SCENARIO=pending apps/mobile/.maestro/photo-analysis-recovery.yaml
maestro test -e NETWORK_TOGGLE=manual apps/mobile/.maestro/offline-sync-conflict.yaml

Every knob is a flow env value with a default at the top of each file; override with -e KEY=value. Use maestro studio to inspect the live accessibility tree if a selector needs adjusting for your OS version.

Flow 1: onboarding and manual log

Default inputs: profile label Demo adult, one item Bean bowl with 520 kcal and 31 g protein (all other values 0), water preset 350 ml.

Step Expected screen or state
Launch with cleared state Welcome to PlatePilot, I am 18 or older.
Adult affirmation, wellness Wellness and privacy, then I understand — continue.
Preferences Profile label filled, Use metric units, Complete adult setup. Today opens (identified by the Add a meal button).
Empty day Nothing has been logged for this day… and Everything is saved.
Manual meal Add a meal → Type it in → item name, calories, protein → Save meal. Today shows the meal card Bean bowl, Manual entry · Confirmed, Synchronized….
Water Add water → preset 350 ml → Save water. Today shows 350 ml water.
Totals Metric cells read Calories: 520 kcal, Protein: 31 g, Water: 350 ml (standard density), then Everything is saved.
History tab Refresh day reloads the day from the server: Bean bowl — lunch and 350 ml water remain. Today tab shows the same totals.

Flow 2: photo analysis and recovery

The flow pushes the fixture to the device library with addMedia, chooses it through the system picker, uploads it, and then branches on MOCK_SCENARIO. The picker and crop steps are outside PlatePilot and are the only OS-specific part; see shared/pick-first-library-photo.yaml for the env overrides.

Mock behaviour that the branches rely on (read from packages/provider-contracts/src/mocks/index.ts, apps/api/src/meal-analysis/meal-analysis.service.ts, and apps/api/src/photo/photo-job-processor.ts):

Input Where it is evaluated Result
Any valid JPEG/PNG upload (64–12000 px per side, ≤ 10 MB) photo.service.ts on POST /photo/uploads/:id/complete Sanitized copy stored, job row QUEUED, outbox message written. The app shows Upload pending.
Worker not running — Job stays QUEUED; Check for the estimate keeps returning pending.
Worker running, providerJobId empty DeterministicMealAnalysisProvider.submit then getStatus Signed id job_mock1…; getStatus returns succeeded, confidence 0.91, labels synthetic bean bowl, brown rice, greens, 520 kcal. 0.91 ≥ 0.65 → provisional.
Intake clientOperationId ending in -fail (local switch) photo.service.ts sets a req_…fail provider request id; DeterministicMealAnalysisProvider returns a signed job_mockfail… whose getStatus is failed (synthetic_failure) Processor markFailed → job FAILED with provider_failed. The app shows the Photo estimate did not work notice with Try this photo again and Type it in instead. Only exists with MOCK_PROVIDERS=true.
Worker running, providerJobId set to an unsigned value getStatus → job_not_found → processor markFailed Same outcome as above; kept for completeness, no longer needed for the demo.
POST /photo/jobs/:id/retry on a failed job prepareRetry in prisma-photo.repository.ts Clears providerJobId and errorCode, drops the fail marker from the provider request id, re-queues. The next worker pass submits a fresh signed id and succeeds, so the failure is one-shot.
LocalStack stopped while a job is queued Processor catches PhotoStorageUnavailableError retryable: the job stays pending and is retried; nothing is marked failed.
Provider result whose nutrition values sum to 0 MealAnalysisService.getAnalysis invalid_provider_result → job FAILED. Not reachable with the shipped mock (its nutrition is non-zero); listed for completeness.
PNG whose first decoded pixel is magenta (255, 0, 255) DeterministicImageSafetyInspector on upload complete photo_face_detected (HTTP 400) → app step face_blocked. Not reachable from the mobile app: it re-encodes every picked image to JPEG and the marker does not survive JPEG (measured 255, 0, 254). See follow-ups.

Run each scenario like this:

Flow 3: offline commands and version conflict

Default inputs: anchor meal Oat porridge (300 kcal, 10 g protein), offline water 250 ml, local edit Oat porridge with banana, server edit Oat porridge with berries.

Step Expected screen or state
Anchor meal online Meal card Synchronized… with an Edit meal button.
Offline (airplane or manual) See platform notes below.
Offline water 250 ml water card with Waiting to save….
Offline meal edit Edit meal (below the anchor title) → item name replaced → Update meal. Today shows Oat porridge with banana and 2 entries need attention….
Host-side server change (runScript) shared/bump-server-meal-version.js GETs /diary/days/<date>, finds the meal by item name, PUTs it with baseVersion = current version and the server name. HTTP 200.
Reconnect Water replays → Synchronized…. The meal edit replays with a stale baseVersion → HTTP 409 → card Needs your choice…, Keep my version, Use the saved version, and This entry changed in two places. Choose which version to keep before editing it.
History tab, Refresh day, Today tab Needs your choice… and both buttons stay visible on every screen; the refresh does not clear it.
CONFLICT_RESOLUTION=use_server Oat porridge with berries appears, conflict notice and buttons disappear, Everything is saved.
CONFLICT_RESOLUTION=keep_mine Oat porridge with banana is re-sent on the new base version → Synchronized…, conflict gone, Everything is saved.

Platform notes for going offline:

Troubleshooting

Symptom Likely cause Fix
Complete adult setup stays disabled or setup errors API unreachable from the device, or profile label empty Use 10.0.2.2 on the Android emulator; rebuild after changing EXPO_PUBLIC_API_BASE_URL; check curl http://localhost:3001/health.
Flow 1 total assertions fail although the meal saved The server day already had entries from an earlier run Reset the database (Step 1) or run on a fresh day.
Meal saved but never Synchronized API down, or Authentication required (401) Restart the API; make sure MOCK_PROVIDERS=true so local.synthetic-token is accepted.
Photo stays Upload pending in the success scenario Worker not running, LocalStack down, or queue URL mismatch Start the worker; docker compose ps; compare MEAL_ANALYSIS_QUEUE_URL in apps/api/.env with the LocalStack init script.
Upload and analyze fails immediately Upload/complete rejected (size, dimensions, mime) or LocalStack S3 unavailable Use the shipped fixture; check LocalStack logs; the app offers Retry upload and Type it in instead.
Picker step cannot find a thumbnail or the crop button Different Android picker or iOS version Inspect with maestro studio and override ANDROID_PICKER_THUMBNAIL_ID, ANDROID_CROP_CONFIRM, IOS_PICKER_CELL, IOS_EDIT_CONFIRM.
failure scenario never shows Try this photo again The intake clientOperationId did not end in -fail, or the worker is down Check the job with GET /photo/jobs/:id (expect provider_failed); confirm the operation id suffix; start the worker; increase POLL_ATTEMPTS.
runScript error No synchronized meal with an item named … Anchor meal not synced yet, wrong API_BASE_URL on the host, or date mismatch Wait for Synchronized… before going offline; set -e API_BASE_URL=… and -e MEAL_LOCAL_DATE=….
Conflict never appears after reconnect Airplane branch skipped on iOS, or the server PUT returned 4xx Use NETWORK_TOGGLE=manual on iOS; read the Maestro console for the script’s HTTP status.
A tap hits the wrong element Label text appears more than once (e.g. several Edit meal cards) Prefer the below: relative selector or add index:; long term, add testIDs (see follow-ups).
maestro: command not found Maestro is not a repository dependency Install it locally (Prerequisites). The root e2e:maestro script only wraps maestro test apps/mobile/.maestro.

Required follow-ups

These are not part of Task 9 and were not changed here; they would make the flows less fragile or unblock a state that cannot be reached today.

  1. testIDs on text inputs. Paper TextInputs expose the same string as label and accessibilityLabel (Profile label, Item 1 name, Water amount, Diary date). Stable testIDs on those inputs would let the flows use id: selectors. Buttons, status regions, metric tiles, and entry cards already carry ids: every AccessibleAction renders action-<kebab-case label> (for example action-save-meal, action-i-understand-continue), StatusNotice renders status-<state> (status-conflict, status-pending_sync), OutcomeNotice renders outcome-<kind>, dashboard tiles render metric-<kebab-case label> (metric-calories, metric-needs-attention), and each diary card renders entry-<local id>.
  2. Per-entry action scoping. Edit meal, Delete meal, Keep my version, Use the saved version, and Try saving again repeat once per card with the same action-* id; scope them through the enclosing entry-<id> container (Maestro childOf) or by index.
  3. Face-marker detection from the app. The magenta marker only survives a PNG round-trip, but expo-photo-media-adapter.ts always re-encodes to JPEG before upload, so the face_blocked state cannot be demonstrated end to end from the device. Either accept PNG uploads unchanged or use a marker that tolerates JPEG re-encoding.
  4. Deterministic failure switch in the mock. Done on 2026-09-20: a clientOperationId ending in -fail produces a req_…fail provider request id that DeterministicMealAnalysisProvider fails deterministically; retry clears the marker. The mobile app still needs a way to add the suffix (developer toggle or Maestro variable) so the failure scenario is fully scripted from the device.
  5. iOS offline toggle. Maestro cannot change iOS simulator connectivity; the manual API stop is the only option until a test hook exists.