Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/mobile/.maestro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,74 @@ steps (or seed an authenticated session) for a cold-start CI run.

Message text and reaction emoji are asserted by their visible text.

## Offline message backfill (2-simulator)

`offline-catch-up/` is the 2-client rig for the message-backfill acceptance
(`docs/message-backfill.md`): **kill Bob → Alice sends N → relaunch Bob → all N
render**. It exists because that scenario is impossible to fake on one device —
the whole point is messages Bob's socket never received live, so local hydration
can't stand in for them.

Maestro drives one app instance per run, so the flow is three sequenced
sub-runs across two sims, wired by `offline-catch-up/run.sh`:

| Phase | Sim | Flow | What it proves |
| ----- | ----- | -------------------------- | -------------------------------------------------------------------------- |
| 1 | Bob | `bob-go-offline.yaml` | sign in, open chat, `stopApp` — dead process = dead WS = offline |
| 2 | Alice | `alice-send.yaml` | send N, wait until `latest-sent-message` confirms (= persisted to Neon) |
| 3 | Bob | `bob-relaunch-assert.yaml` | relaunch, assert all N visible — they can _only_ have arrived via backfill |

Phase 1's kill holds for phase 2's whole duration, so Alice's fanout to Bob's
`UserInbox` finds zero sockets and is dropped — phase 3 passing is proof the
`bf`/`bfd` path delivered them, not the live path.

**Strict ordering** of the merged thread is asserted by the vitest units
(`packages/chat/src/store.test.ts` → `ingestBackfill … sorted by serverSerial`);
the Maestro layer asserts **delivery**. That split keeps the flaky UI layer off
the deterministic invariants (dedupe / serial-order / cursor-monotonicity /
membership-gate are all unit-covered across `packages/{chat,chat-db,db-edge}`).

### Running

```sh
export MAESTRO_APP_ID=io.sessions.app # app.json ios/android identifier
export BOB_SIM=<udid> ALICE_SIM=<udid> # xcrun simctl list devices
apps/mobile/.maestro/offline-catch-up/run.sh
```

### One-time chat establishment (precondition — cannot be seeded)

The flows assume a **direct alice↔bob chat is the top row on both devices**.
Unlike `chat-smoke`, this can't come from `packages/database/prisma/seed.ts`:
that seed builds only the social graph, and an **E2EE chat is not seedable** —
the device key private halves live in each sim's secure store and the MLS group
is provisioned on-device when the chat is created. Establish it once through the
apps (order matters — Bob must publish keys before Alice can add him):

1. **Bob sim:** launch, sign in as `bob@example.com` → device keys + MLS
KeyPackages auto-publish to the server (`lib/chat/mls-auto-publish.ts`).
2. **Alice sim:** launch, sign in as `alice@example.com` → **New Chat** →
Direct → search `bob` → tap the result. `createNewChat` provisions the MLS
group (adds Bob via his KeyPackage) and opens the thread.
3. **Bob sim:** open the new chat once (consumes the MLS Welcome) so both sides
are live members. Now `run.sh` is repeatable.

### Why not a headless peer script

A Node "Alice" that re-implements the libsodium/MLS seal to inject
Bob-decryptable ciphertext was rejected: it duplicates the crypto pipe, needs
out-of-band key coordination, and demonstrates none of the real
device-lifecycle path. Letting the two real apps encrypt is both less fragile
and the honest end-to-end exercise.

## Follow-ups (tracked, not built)

1. **2-client journey (rig).** Drive user A in Maestro while a second actor
(user B) produces real traffic, then assert A's UI reflects it:
B sends → A shows the message + typing pulse; A reacts → pill; B reads →
A's tick flips to the primary double-check. Requires B to be a real MLS
group member (so its frames decrypt on A). See the cost note in the PR/plan.
(The `offline-catch-up/` rig above is the first instance of this pattern.)

2. **MLS out-of-order regression** (binary-gated). Lives in `chat-mls-core`
(needs `node/index.node` via `pnpm run build:node`; the suite auto-skips
Expand Down
28 changes: 28 additions & 0 deletions apps/mobile/.maestro/offline-catch-up/_sign-in.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Reusable sign-in subflow. Invoked via `runFlow: { file: _sign-in.yaml, env: … }`
# from each phase flow so the credential steps live in one place.
#
# Cold-start note (mirrors chat-smoke.yaml): launchApp relaunches the process and
# the Better Auth session does NOT rehydrate, so every phase lands on the auth
# gate. Gated on "Login" being visible → a no-op if a session ever does persist.
# Creds come from the DB seed (packages/database/prisma/seed.ts: SEED_PASSWORD).
appId: ${MAESTRO_APP_ID}
---
- runFlow:
when:
visible: "Login"
commands:
- tapOn: "Email Address"
- inputText: ${EMAIL}
- tapOn: "Password"
- inputText: ${PASSWORD}
# enterKeyHint="go" + onSubmitEditing → submit via the return key. Avoids
# hideKeyboard (unsupported on the RN input) and a Login-button tap the
# keyboard can overlap.
- pressKey: Enter

# Block until the conversations list paints — the shared anchor every phase
# needs before it can open the chat.
- extendedWaitUntil:
visible:
id: "chat-row"
timeout: 20000
66 changes: 66 additions & 0 deletions apps/mobile/.maestro/offline-catch-up/alice-send.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Phase 2 (Alice's device) — send N messages while Bob is offline.
#
# Runs on the ALICE simulator, between Bob's phase 1 (go offline) and phase 3
# (relaunch + assert). Each send is confirmed via the `latest-sent-message`
# anchor before moving on: that testID is applied ONLY once the store has the
# server serial (store.ts confirmOptimisticMessage), i.e. the row is persisted
# in Neon. Waiting on it guarantees the messages are on the server — and thus
# backfillable — before phase 3 relaunches Bob.
#
# Texts are fixed + numerically ordered so phase 3 can assert each one arrived.
# Strict serial ORDER of the merged thread is locked by the vitest units
# (packages/chat/src/store.test.ts → "ingestBackfill … sorted by serverSerial");
# this flow asserts DELIVERY (that offline messages are caught up at all).
appId: ${MAESTRO_APP_ID}
env:
ALICE_EMAIL: alice@example.com
ALICE_PASSWORD: password123
MSG_1: "catch-up 1 of 3"
MSG_2: "catch-up 2 of 3"
MSG_3: "catch-up 3 of 3"
---
- launchApp
- runFlow:
file: _sign-in.yaml
env:
EMAIL: ${ALICE_EMAIL}
PASSWORD: ${ALICE_PASSWORD}

# Open the shared chat with Bob (top row).
- tapOn:
id: "chat-row"
index: 0

# ── Send message 1 ──
- tapOn:
id: "composer-input"
- inputText: ${MSG_1}
- tapOn:
id: "composer-send"
- assertVisible: ${MSG_1}

# ── Send message 2 ──
- tapOn:
id: "composer-input"
- inputText: ${MSG_2}
- tapOn:
id: "composer-send"
- assertVisible: ${MSG_2}

# ── Send message 3 ──
- tapOn:
id: "composer-input"
- inputText: ${MSG_3}
- tapOn:
id: "composer-send"
- assertVisible: ${MSG_3}

# Block until the newest send is CONFIRMED (serial assigned → persisted to Neon).
# Serials are monotonic and DO-assigned in send order, so the last one being
# persisted implies msg1 + msg2 already are. Now Bob can backfill all three.
- extendedWaitUntil:
visible:
id: "latest-sent-message"
timeout: 10000
- waitForAnimationToEnd:
timeout: 4000
34 changes: 34 additions & 0 deletions apps/mobile/.maestro/offline-catch-up/bob-go-offline.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Phase 1 (Bob's device) — establish a baseline, then go offline.
#
# Runs on the BOB simulator. Signs Bob in, opens the shared alice↔bob chat to
# prove the thread is reachable and to record what he has seen live, then kills
# the app. A killed process = a dead WebSocket = Bob is offline: any message
# Alice sends now is persisted to Neon but its live fanout finds zero sockets on
# Bob's UserInbox and is dropped — the exact gap message-backfill fixes.
#
# Precondition: a direct chat between alice + bob already exists and is Bob's
# top row (see README → "One-time chat establishment"). E2EE chats can't be
# seeded — the MLS group is provisioned on-device at chat-create time.
appId: ${MAESTRO_APP_ID}
env:
BOB_EMAIL: bob@example.com
BOB_PASSWORD: password123
---
- launchApp
- runFlow:
file: _sign-in.yaml
env:
EMAIL: ${BOB_EMAIL}
PASSWORD: ${BOB_PASSWORD}

# Open the shared chat (top row after recent activity) so any already-delivered
# history is hydrated — establishes the "before" state the catch-up adds onto.
- tapOn:
id: "chat-row"
index: 0
- waitForAnimationToEnd:
timeout: 4000

# Go offline. stopApp terminates the process, tearing down the WS. Bob stays
# down while phase 2 (alice-send) runs; phase 3 relaunches him.
- stopApp
46 changes: 46 additions & 0 deletions apps/mobile/.maestro/offline-catch-up/bob-relaunch-assert.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Phase 3 (Bob's device) — relaunch and assert the missed messages caught up.
#
# Runs on the BOB simulator after alice-send. Bob's local chat.sqlite never saw
# these messages (his socket was dead when Alice sent), so they can ONLY appear
# via backfill: on WS open + after bootstrap the provider issues one `bf` with
# Bob's per-chat cursor (force=true on the first pass this launch), the Worker
# reads them membership-gated from Neon, and the store merge-sorts the page in.
#
# NB: no clearState — we WANT Bob's real local store (which lacks these rows) so
# the assertion proves catch-up, not local hydration. If these texts render here
# yet were dropped on the live path in phase 2, backfill is what delivered them.
appId: ${MAESTRO_APP_ID}
env:
BOB_EMAIL: bob@example.com
BOB_PASSWORD: password123
MSG_1: "catch-up 1 of 3"
MSG_2: "catch-up 2 of 3"
MSG_3: "catch-up 3 of 3"
---
- launchApp
- runFlow:
file: _sign-in.yaml
env:
EMAIL: ${BOB_EMAIL}
PASSWORD: ${BOB_PASSWORD}

# Open the shared chat. Opening is not what triggers backfill — WS-open +
# bootstrap already fired it — but it renders the thread so we can assert.
- tapOn:
id: "chat-row"
index: 0

# Backfill is a round-trip (bf → Neon read → bfd → decrypt → merge-sort), so the
# messages arrive shortly AFTER the thread paints. extendedWaitUntil polls each
# through that window rather than asserting on the first frame. Incoming bubbles
# have no self-anchor testID (that tags my own confirmed sends), so match on the
# received text — which is exactly the catch-up payload under test.
- extendedWaitUntil:
visible: ${MSG_1}
timeout: 15000
- extendedWaitUntil:
visible: ${MSG_2}
timeout: 15000
- extendedWaitUntil:
visible: ${MSG_3}
timeout: 15000
48 changes: 48 additions & 0 deletions apps/mobile/.maestro/offline-catch-up/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Two-simulator orchestrator for the offline message-backfill acceptance
# (docs/message-backfill.md). Maestro drives ONE app instance per invocation, so
# a genuine "peer sends while I'm offline" scenario needs two sims and three
# sequenced sub-runs — this script is that sequencer:
#
# 1. BOB → bob-go-offline.yaml (sign in, open chat, kill app = offline)
# 2. ALICE → alice-send.yaml (send N, wait until persisted to Neon)
# 3. BOB → bob-relaunch-assert.yaml (relaunch, assert all N caught up)
#
# Because the WS is dead for step 1's whole duration, Alice's step-2 messages are
# persisted but their live fanout to Bob is dropped — so step 3 passing is proof
# the backfill path (not local hydration) delivered them.
#
# Usage:
# export MAESTRO_APP_ID=io.sessions.app # app.json → ios/android id
# export BOB_SIM=<bob-sim-udid> # `xcrun simctl list devices`
# export ALICE_SIM=<alice-sim-udid>
# ./run.sh
#
# Precondition: a direct alice↔bob chat already exists as the top row on BOTH
# devices (see README → "One-time chat establishment"). E2EE chats can't be
# seeded; the MLS group is provisioned on-device at chat-create time.
set -euo pipefail

: "${MAESTRO_APP_ID:?set MAESTRO_APP_ID (matches app.json ios/android identifier)}"
: "${BOB_SIM:?set BOB_SIM to the Bob simulator UDID}"
: "${ALICE_SIM:?set ALICE_SIM to the Alice simulator UDID}"

if ! command -v maestro >/dev/null 2>&1; then
echo "maestro not found — install from https://maestro.mobile.dev" >&2
exit 127
fi

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

banner() { printf '\n\033[1m▶ %s\033[0m\n' "$1"; }

banner "Phase 1/3 — Bob goes offline (device $BOB_SIM)"
maestro --device "$BOB_SIM" test "$HERE/bob-go-offline.yaml"

banner "Phase 2/3 — Alice sends while Bob is offline (device $ALICE_SIM)"
maestro --device "$ALICE_SIM" test "$HERE/alice-send.yaml"

banner "Phase 3/3 — Bob relaunches and catches up (device $BOB_SIM)"
maestro --device "$BOB_SIM" test "$HERE/bob-relaunch-assert.yaml"

banner "✅ offline catch-up verified: all messages sent while Bob was offline rendered after relaunch"
3 changes: 2 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#000000"
},
"package": "io.sessions.app"
"package": "io.sessions.app",
"googleServicesFile": "./google-services.json"
},
"plugins": [
"expo-router",
Expand Down
46 changes: 46 additions & 0 deletions apps/mobile/google-services.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"project_info": {
"project_number": "126132110239",
"project_id": "sessions-dev-96fd0",
"storage_bucket": "sessions-dev-96fd0.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:126132110239:android:fda8152291630d9a8033aa",
"android_client_info": {
"package_name": "io.sessions.app"
}
},
"oauth_client": [
{
"client_id": "126132110239-179kfjdtq3b9u5p62sve6bm9agtcnpa7.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDlQv42C_Ht3opaEvNp6yPSqGDhr34ljGA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "126132110239-179kfjdtq3b9u5p62sve6bm9agtcnpa7.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "126132110239-cepnoht7hnhnq3ollbphkf2laccjs32t.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "io.sessions.app"
}
}
]
}
}
}
],
"configuration_version": "1"
}
Loading
Loading