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
54 changes: 54 additions & 0 deletions apps/mobile/.maestro/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Maestro E2E — chat "alive" features (M8)

Flows for the typing-indicator / read-receipt / reaction work.

## What runs today

`chat-smoke.yaml` — a **single-client** smoke. One device can only observe the
**optimistic / local** surface of these features:

| Asserted (single client) | Not assertable (needs a 2nd actor) |
| ---------------------------------------------------- | ------------------------------------------------ |
| Sending a message renders my bubble | A peer's **typing** indicator (self is excluded) |
| Long-press → quick-react tray → my **reaction pill** | **Read** double-tick (needs a peer to read) |
| Composer input drives the typing emitter | Reaction **fan-in** from another user |

The cross-user half is deliberately out of scope here — it needs the 2-client
rig below. The deterministic core of all three features is already covered by
the vitest units in `packages/chat/src/*.test.ts`.

## Running

```sh
# install: https://maestro.mobile.dev
export MAESTRO_APP_ID=c # match app.json → ios/android identifier
maestro test apps/mobile/.maestro/chat-smoke.yaml
```

Preconditions: a **built app** on a booted simulator/emulator, a **signed-in**
session, and **≥1 chat** on the conversations list (seed via
`packages/database/prisma/seed.ts`). The flow does not sign in — add sign-in
steps (or seed an authenticated session) for a cold-start CI run.

## E2E anchors added for these flows

- `chat-row` — Glacier `ListRow` (conversations list)
- `composer-input`, `composer-send` — Glacier `Composer`

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

## 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.

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
when the binary is absent). Fire an interleaved same-sender
message+reaction and assert both decrypt — guards the OpenMLS default
`SenderRatchetConfiguration` out-of-order tolerance (=5) that
`reactions-ride-send` relies on (`chat-mls-core/src/engine.rs:165` & `:267`
leave it unset). A future config change lowering it must fail this test.
84 changes: 84 additions & 0 deletions apps/mobile/.maestro/chat-smoke.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Single-client smoke — M8 "alive chat" (typing / read receipts / reactions).
#
# A single device can only assert the OPTIMISTIC / local surface:
# • sending a message renders my bubble
# • reacting via the long-press tray renders my reaction pill
# Typing indicators and read-receipt double-ticks are CROSS-USER and cannot be
# observed with one client — see ./README.md (2-client rig is a follow-up).
#
# Preconditions: app installed on a booted sim, DB seeded (alice + password123),
# and at least one chat on alice's conversations list. The flow signs in itself
# (see the runFlow block below). Set MAESTRO_APP_ID (e.g. io.sessions.app). Run:
# maestro --device <sim-udid> test apps/mobile/.maestro/chat-smoke.yaml
appId: ${MAESTRO_APP_ID}
---
- launchApp

# Cold-start sign-in. launchApp relaunches the process and the Better Auth
# session does not rehydrate, so we land on the auth gate. Gated on the login
# screen being visible — a no-op if a session ever does persist.
# Creds come from the DB seed (packages/database/prisma/seed.ts): SEED_PASSWORD.
- runFlow:
when:
visible: "Login"
commands:
- tapOn: "Email Address"
- inputText: "alice@example.com"
- tapOn: "Password"
- inputText: "password123"
# Password field has enterKeyHint="go" + onSubmitEditing → submit via the
# keyboard return key. Avoids hideKeyboard (unsupported on the RN input)
# and a Login-button tap that the keyboard can overlap.
- pressKey: Enter

- extendedWaitUntil:
visible:
id: "chat-row"
timeout: 20000
# Open the first conversation (testID on the Glacier ListRow).
- tapOn:
id: "chat-row"
index: 0

# --- Send a message ---
- tapOn:
id: "composer-input"
- inputText: "Maestro smoke"
- tapOn:
id: "composer-send"
# Optimistic (status "sending") bubble renders NO footer (chat-bubble.tsx), so
# its accessibility label is exactly "Maestro smoke" and this text match works.
# This asserts the text we typed actually rendered.
- assertVisible: "Maestro smoke"

# On ACK the store swaps the message .id (clientMsgId → serverSerial,
# store.ts confirmOptimisticMessage), which flips the FlashList row key and
# REMOUNTS the bubble. The confirmed bubble now shows a footer (timestamp +
# receipt), and because the row Pressable is one iOS a11y container its label
# folds into "Maestro smoke, 2:11 PM, " — so a bare text match no longer hits.
# Anchor on the stable testID instead. The app tags the newest message I sent
# ONLY once it's CONFIRMED (serverSerial present), so this wait blocks through
# the whole ack window — not just until the optimistic bubble paints. That
# matters because the quick-react tray is gated on serverSerial and the actions
# sheet snapshots the message at long-press time: long-pressing a still-optimistic
# bubble opens a tray-less sheet that never recovers. Waiting for the confirmed
# anchor guarantees the long-press lands on a reactable, settled row.
- waitForAnimationToEnd:
timeout: 4000
- extendedWaitUntil:
visible:
id: "latest-sent-message"
timeout: 10000

# --- React to the message (long-press → quick-react tray → pill) ---
- longPressOn:
id: "latest-sent-message"
# Quick-react tray sits in the actions sheet; each emoji is its own a11y node,
# so an exact emoji match works. NOTE: "❤️" here must be U+2764 U+FE0F (heart +
# variation selector) to match the rendered node — a bare U+2764 won't.
- assertVisible: "❤️"
- tapOn: "❤️"
# The reaction pill folds UNDER the bubble, inside the same row Pressable, so it
# merges into the row's a11y label ("Maestro smoke … ❤️ …"). Match a substring
# regex rather than the bare emoji, which would anchor-fail against the label.
- assertVisible: ".*❤️.*"
31 changes: 22 additions & 9 deletions apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function ChatRow({ chat, myId, onPress }: ChatRowProps) {

return (
<ListRow
testID="chat-row"
name={title}
preview={preview}
timestamp={last ? formatTime(last.createdAt) : ""}
Expand Down Expand Up @@ -131,15 +132,27 @@ export default function ChatListScreen() {
justifyContent="space-between"
>
<HeadlineMd>Mortstack</HeadlineMd>
<Button
variant="ghost"
borderWidth={0}
width={44}
height={44}
p={0}
accessibilityLabel="Search chats"
icon={<Feather name="search" size={20} color={ovc} />}
/>
<XStack gap="$xs" alignItems="center">
<Button
variant="ghost"
borderWidth={0}
width={44}
height={44}
p={0}
accessibilityLabel="Settings"
onPress={() => router.push("/settings" as never)}
icon={<Feather name="settings" size={20} color={ovc} />}
/>
<Button
variant="ghost"
borderWidth={0}
width={44}
height={44}
p={0}
accessibilityLabel="Search chats"
icon={<Feather name="search" size={20} color={ovc} />}
/>
</XStack>
</XStack>
</YStack>

Expand Down
Loading
Loading