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.
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.
docker compose for PostgreSQL and LocalStack.curl -fsSL "https://get.maestro.mobile.dev" | bash, then confirm maestro --version. Maestro needs a Java 17+ runtime.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.
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.
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).
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.
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. |
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:
MOCK_SCENARIO=pending — stop the worker first. Expected: Upload pending…, Analysis is pending. You can leave this screen or enter the meal manually., Check for the estimate still pending, Type it in instead available. After Back and the Today tab, a job card reads Photo analysis pending with Check on this photo and Cancel this photo.MOCK_SCENARIO=success (default) — worker running. The flow taps Check for the estimate until Estimate — review before saving appears (up to POLL_ATTEMPTS × ~3 s). Expected: This is an estimate. Review and edit the items before saving., item synthetic bean bowl, Confirm reviewed meal and Type it in instead present. The flow leaves without confirming; Today shows Photo estimate is provisional and Review photo estimate. With -e CONFIRM_ESTIMATE=true it also reviews and confirms, ending with Photo estimate · Confirmed on Today.MOCK_SCENARIO=failure — worker running; no database edits. The photo intake must be sent with a clientOperationId that ends in -fail (case-insensitive); in the app, turn on “Make the next photo estimate fail” under Me → About and help → Practice switches (local builds), which adds the suffix to the next attempt and then switches itself off. The API then gives the analysis job a provider request id ending in fail, the deterministic mock answers that job with a retryable provider failure, and the job ends FAILED / provider_failed. The flow polls for up to POLL_ATTEMPTS × ~3 s, asserts the Photo estimate did not work notice, Try this photo again, Type it in instead, taps Try this photo again, polls until the estimate appears (retry drops the marker, so the second attempt succeeds), then taps Type it in instead and checks the manual editor opened with synthetic bean bowl pre-filled.
The mobile app owns how the suffix is added (for example a developer-menu toggle or a Maestro
-e FAILURE_DEMO=true variable that suffixes the generated operation id); until it exists you can
drive the same path from the host:
A='Authorization: Bearer local.synthetic-token'; J='Content-Type: application/json'
IMG=apps/mobile/.maestro/fixtures/synthetic-plate.png # any small PNG works
SUM=$(openssl dgst -sha256 -binary "$IMG" | base64); SIZE=$(stat -c %s "$IMG")
read W H < <(python3 -c "import struct,sys;d=open('$IMG','rb').read(24);print(*struct.unpack('>II',d[16:24]))")
AUTH=$(curl -s -H "$A" -H "$J" -X POST localhost:3001/photo/uploads -d "{\"byteSize\":$SIZE,\"checksumSha256\":\"$SUM\",\"clientOperationId\":\"demo-photo-1-fail\",\"containsFace\":false,\"height\":$H,\"localDate\":\"$(date -u +%F)\",\"mimeType\":\"image/png\",\"slot\":\"lunch\",\"timeZone\":\"UTC\",\"width\":$W}")
UPLOAD=$(echo "$AUTH" | python3 -c 'import json,sys;print(json.load(sys.stdin)["uploadId"])')
URL=$(echo "$AUTH" | python3 -c 'import json,sys;print(json.load(sys.stdin)["uploadUrl"])')
curl -s -X PUT -H 'content-type: image/png' -H "x-amz-checksum-sha256: $SUM" --data-binary "@$IMG" "$URL"
JOB=$(curl -s -H "$A" -H "$J" -X POST localhost:3001/photo/uploads/$UPLOAD/complete -d '{"idempotencyKey":"complete-demo-1"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["analysisJobId"])')
sleep 5; curl -s -H "$A" localhost:3001/photo/jobs/$JOB # expect status failed, errorCode provider_failed
curl -s -H "$A" -H "$J" -X POST localhost:3001/photo/jobs/$JOB/retry -d '{"idempotencyKey":"retry-demo-1"}'
sleep 5; curl -s -H "$A" localhost:3001/photo/jobs/$JOB # expect status succeeded with a mealId
Any clientOperationId that does not end in -fail (for example demo-fail-photo) behaves
normally. The switch lives in packages/provider-contracts/src/mocks/index.ts
(isDeterministicFailureRequest) and apps/api/src/meal-analysis/failure-switch.ts; it cannot
reach a real provider because real adapters are never configured alongside the mock.
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:
NETWORK_TOGGLE=airplane (default) uses Maestro setAirplaneMode, which is Android only; on iOS the step is skipped and the flow will not see any pending state.NETWORK_TOGGLE=manual works on both platforms: the flow sleeps OFFLINE_WINDOW_S seconds (default 20) twice. Stop the API (Ctrl+C in its terminal) during the first window and restart it during the second. The app classifies network errors as retryable, so entries remain Waiting to save. Because no connectivity event fires, the flow opens History and taps Try saving again until the buttons are gone.runScript runs on the Maestro host and needs API_BASE_URL reachable from there (http://localhost:3001 by default), independent of the device’s connectivity. If the host and device disagree on the calendar date, pass -e MEAL_LOCAL_DATE=YYYY-MM-DD.| 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. |
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.
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>.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.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.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.