Skip to content

Commit 24e183b

Browse files
authored
Merge pull request #1 from dappros/tf/maestro-tests
Scaffold Maestro E2E flows + CI workflow for iOS SDKPlayground
2 parents f8a3108 + 25c13f1 commit 24e183b

27 files changed

Lines changed: 986 additions & 0 deletions

.github/workflows/maestro.yml

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
name: Maestro E2E (iOS)
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
release:
9+
types: [published]
10+
workflow_dispatch:
11+
12+
# Required repo secrets (Settings → Secrets and variables → Actions):
13+
#
14+
# MAESTRO_TEST_EMAIL alice's email on chat-qa
15+
# MAESTRO_TEST_PASSWORD alice's password
16+
# MAESTRO_TEST_USER_JWT alice's client-flow JWT (02-login-jwt)
17+
# MAESTRO_TEST_ROOM_JID a MUC room JID alice + bob both belong to
18+
# MAESTRO_TEST_BOB_JWT bob's user-session JWT (sendAsBob.js — 05)
19+
# MAESTRO_TEST_APPB_* second-app credentials for 10-switch-app
20+
# ETHORA_API_BASE_URL e.g. https://api.chat-qa.ethora.com/v1
21+
# ETHORA_APP_ID 24-char hex
22+
# ETHORA_APP_TOKEN JWT app token
23+
#
24+
# All credentials must point at chat-qa, never prod.
25+
26+
jobs:
27+
maestro:
28+
name: Maestro on iOS Simulator (${{ matrix.os-version }})
29+
runs-on: macos-latest
30+
strategy:
31+
fail-fast: false
32+
matrix:
33+
# PR runs hit one iOS version; release tags hit the matrix.
34+
os-version: ${{ github.event_name == 'release' && fromJson('["17.5", "18.0"]') || fromJson('["18.0"]') }}
35+
device: ['iPhone 15']
36+
steps:
37+
- uses: actions/checkout@v4
38+
39+
- name: Select Xcode
40+
run: sudo xcode-select -s /Applications/Xcode_15.4.app
41+
42+
- name: Install xcodegen + Maestro
43+
run: |
44+
brew install xcodegen
45+
curl -fsSL https://get.maestro.mobile.dev | bash
46+
47+
- name: Generate Xcode project
48+
run: ./generate_xcodeproj.sh
49+
50+
- name: Build SDKPlayground
51+
run: |
52+
xcodebuild -project SDKPlayground.xcodeproj \
53+
-scheme SDKPlayground \
54+
-destination 'platform=iOS Simulator,name=${{ matrix.device }},OS=${{ matrix.os-version }}' \
55+
-configuration Debug \
56+
-derivedDataPath build/ \
57+
CODE_SIGNING_ALLOWED=NO \
58+
build
59+
60+
- name: Boot simulator + install app
61+
run: |
62+
xcrun simctl shutdown all || true
63+
DEVICE_ID=$(xcrun simctl create maestro-runner \
64+
"com.apple.CoreSimulator.SimDeviceType.iPhone-15" \
65+
"com.apple.CoreSimulator.SimRuntime.iOS-${{ matrix.os-version }}" || \
66+
xcrun simctl list devices "${{ matrix.device }}" | grep -E "${{ matrix.device }} \(" | head -1 | awk -F'[()]' '{print $2}')
67+
echo "DEVICE_ID=$DEVICE_ID" >> "$GITHUB_ENV"
68+
xcrun simctl boot "$DEVICE_ID"
69+
xcrun simctl install "$DEVICE_ID" \
70+
build/Build/Products/Debug-iphonesimulator/SDKPlayground.app
71+
72+
- name: Seed test image into Photos library (for flow 06)
73+
run: |
74+
xcrun simctl addmedia "$DEVICE_ID" .maestro/assets/test-image.png
75+
76+
- name: Write SDKPlayground default config from secrets
77+
# The iOS playground reads its defaults from PlaygroundSession's
78+
# @AppStorage. CI sets them by writing the corresponding
79+
# UserDefaults entries via simctl. Adjust the keys below if the
80+
# app's PlaygroundSession persistence schema changes.
81+
run: |
82+
xcrun simctl spawn "$DEVICE_ID" defaults write com.ethora.SDKPlayground baseURLString "${{ secrets.ETHORA_API_BASE_URL }}"
83+
xcrun simctl spawn "$DEVICE_ID" defaults write com.ethora.SDKPlayground appId "${{ secrets.ETHORA_APP_ID }}"
84+
xcrun simctl spawn "$DEVICE_ID" defaults write com.ethora.SDKPlayground appToken "${{ secrets.ETHORA_APP_TOKEN }}"
85+
86+
- name: Run flows on simulator
87+
run: |
88+
# Flows 01-07 + 09-20, skipping 08 (push, needs simctl push first).
89+
~/.maestro/bin/maestro test \
90+
.maestro/flows/01-login-email.yaml \
91+
.maestro/flows/02-login-jwt.yaml \
92+
.maestro/flows/03-list-rooms.yaml \
93+
.maestro/flows/04-send-text.yaml \
94+
.maestro/flows/05-receive-text.yaml \
95+
.maestro/flows/06-attach-file.yaml \
96+
.maestro/flows/07-reconnect-airplane.yaml \
97+
.maestro/flows/09-logout-relogin.yaml \
98+
.maestro/flows/10-switch-app.yaml \
99+
.maestro/flows/11-login-wrong-password.yaml \
100+
.maestro/flows/13-message-edit.yaml \
101+
.maestro/flows/14-message-delete.yaml \
102+
.maestro/flows/15-message-reaction.yaml \
103+
.maestro/flows/16-create-room.yaml \
104+
.maestro/flows/17-search-rooms.yaml \
105+
.maestro/flows/18-multi-message-rapid.yaml \
106+
.maestro/flows/19-room-info.yaml \
107+
.maestro/flows/20-offline-pending-resend.yaml
108+
109+
# Flow 08: deliver synthetic push, then run the deep-link
110+
# assertion flow.
111+
bash .maestro/scripts/sendPushIntent.sh "${MAESTRO_TEST_ROOM_JID}"
112+
~/.maestro/bin/maestro test .maestro/flows/08-push-deep-link.yaml
113+
env:
114+
MAESTRO_TEST_EMAIL: ${{ secrets.MAESTRO_TEST_EMAIL }}
115+
MAESTRO_TEST_PASSWORD: ${{ secrets.MAESTRO_TEST_PASSWORD }}
116+
MAESTRO_TEST_USER_JWT: ${{ secrets.MAESTRO_TEST_USER_JWT }}
117+
MAESTRO_TEST_ROOM_JID: ${{ secrets.MAESTRO_TEST_ROOM_JID }}
118+
MAESTRO_TEST_APPB_BASE_URL: ${{ secrets.MAESTRO_TEST_APPB_BASE_URL }}
119+
MAESTRO_TEST_APPB_APP_ID: ${{ secrets.MAESTRO_TEST_APPB_APP_ID }}
120+
MAESTRO_TEST_APPB_APP_TOKEN: ${{ secrets.MAESTRO_TEST_APPB_APP_TOKEN }}
121+
MAESTRO_TEST_APPB_EMAIL: ${{ secrets.MAESTRO_TEST_APPB_EMAIL }}
122+
MAESTRO_TEST_APPB_PASSWORD: ${{ secrets.MAESTRO_TEST_APPB_PASSWORD }}
123+
MAESTRO_API_BASE_URL: ${{ secrets.ETHORA_API_BASE_URL }}
124+
MAESTRO_APP_TOKEN: ${{ secrets.ETHORA_APP_TOKEN }}
125+
MAESTRO_TEST_BOB_JWT: ${{ secrets.MAESTRO_TEST_BOB_JWT }}
126+
127+
- name: Upload Maestro recordings on failure
128+
if: failure()
129+
uses: actions/upload-artifact@v4
130+
with:
131+
name: maestro-recordings-ios-${{ matrix.os-version }}
132+
path: ~/.maestro/tests/**
133+
retention-days: 14

.maestro/README.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Maestro flows for `ethora-sample-swift`
2+
3+
End-to-end smoke tests that drive the SDKPlayground app on an iOS
4+
Simulator (or a real device) against an Ethora server. Layer 2 of
5+
the SDK testing strategy — see [`ethora-sdk-swift` README → Testing](https://github.com/dappros/ethora-sdk-swift/blob/main/README.md#testing)
6+
for the split with hermetic XCTest unit tests.
7+
8+
## Cross-platform parity
9+
10+
The flow YAMLs here mirror the Android sample's flows
11+
(`ethora-sample-android/.maestro/flows/`) one-for-one, with the
12+
same numbering and intent. They resolve UI nodes by accessibility
13+
identifier — the same string IDs Compose's `testTag(...)` uses on
14+
Android — so a single flow exercises the same intent on either
15+
platform.
16+
17+
iOS-specific differences from the Android equivalents:
18+
- `appId: com.ethora.SDKPlayground` (Android uses `com.ethora`)
19+
- Tab labels are `"Setup"`, `"Chat"`, `"Logs"` in title-case
20+
(Android uses `"SETUP"`, `"CHAT"`, `"LOGS"` in the segmented
21+
control)
22+
- The system search bar (iOS `.searchable`) renders differently
23+
from Android's RoomListView search; flow `17-search-rooms`
24+
resolves it via SwiftUI's default search affordance, not via
25+
the `rooms_search_input` ID
26+
27+
Otherwise the YAMLs are byte-for-byte aligned where they can be.
28+
29+
## Repo layout
30+
31+
```
32+
.maestro/
33+
├── README.md (you are here)
34+
├── config.yaml project-level Maestro config
35+
├── assets/ binary fixtures (test images etc.)
36+
│ └── test-image.png 8×8 PNG used by 06-attach-file
37+
├── fixtures/ shared test data (do not commit real credentials)
38+
│ └── test-users.yaml
39+
├── scripts/ helpers invoked by flows or by CI before flows
40+
│ ├── sendAsBob.js Maestro JS helper — POSTs a message as bob
41+
│ │ via REST, used by 05-receive-text
42+
│ └── sendPushIntent.sh
43+
│ adb-equivalent for iOS via xcrun simctl
44+
│ push, invoked by CI BEFORE 08-push-deep-link
45+
└── flows/
46+
├── 01-login-email.yaml
47+
├── 02-login-jwt.yaml
48+
├── 03-list-rooms.yaml
49+
├── 04-send-text.yaml
50+
├── 05-receive-text.yaml uses scripts/sendAsBob.js
51+
├── 06-attach-file.yaml uses assets/test-image.png seeded
52+
│ into the simulator's Photos library
53+
│ by CI
54+
├── 07-reconnect-airplane.yaml drives reconnect via the Setup
55+
│ tab's Disconnect button (no shell
56+
│ dependency)
57+
├── 08-push-deep-link.yaml CI runs sendPushIntent.sh first
58+
├── 09-logout-relogin.yaml
59+
├── 10-switch-app.yaml
60+
├── 11-login-wrong-password.yaml
61+
├── 13-message-edit.yaml
62+
├── 14-message-delete.yaml
63+
├── 15-message-reaction.yaml
64+
├── 16-create-room.yaml
65+
├── 17-search-rooms.yaml
66+
├── 18-multi-message-rapid.yaml
67+
├── 19-room-info.yaml
68+
└── 20-offline-pending-resend.yaml
69+
```
70+
71+
(Flow 12 reserved for typing-indicator — needs a `sendAsBob`-style
72+
helper for XMPP composing-state.)
73+
74+
## Running locally
75+
76+
1. Install Maestro: `brew install maestro` (or `curl -fsSL https://get.maestro.mobile.dev | bash`).
77+
2. Boot a Simulator and install the app:
78+
79+
```bash
80+
./generate_xcodeproj.sh # if SDKPlayground.xcodeproj is stale
81+
xcodebuild -project SDKPlayground.xcodeproj \
82+
-scheme SDKPlayground \
83+
-destination 'platform=iOS Simulator,name=iPhone 15' \
84+
-configuration Debug \
85+
-derivedDataPath build/
86+
xcrun simctl install booted \
87+
build/Build/Products/Debug-iphonesimulator/SDKPlayground.app
88+
```
89+
90+
3. Populate `SDKPlayground/PlaygroundSession.swift`'s defaults via
91+
`npx @ethora/setup` against your QA app — same flow as Android.
92+
4. Run a single flow:
93+
94+
```bash
95+
maestro test .maestro/flows/01-login-email.yaml
96+
```
97+
98+
Or all flows:
99+
100+
```bash
101+
maestro test .maestro/flows
102+
```
103+
104+
## Running in CI
105+
106+
`.github/workflows/maestro.yml` runs the suite on every push, PR,
107+
and SDK release tag on a macOS runner with an iOS Simulator.
108+
109+
## Coverage table
110+
111+
What each flow proves end-to-end against `chat-qa.ethora.com`.
112+
Identical to the Android sample's coverage table — the same 19
113+
flows, the same regression classes, same expected assertions.
114+
115+
| # | Flow | Asserts | Catches |
116+
|---|------|---------|---------|
117+
| 01 | `login-email` | Email + password → connected | Wrong app token, broken `/users/login-with-email` shape |
118+
| 02 | `login-jwt` | Custom JWT → `/users/client` accepts | `TOKEN_WRONG_TYPE`, missing JWTLoginConfig wiring |
119+
| 03 | `list-rooms` | After login, room list renders | `GET /chats/my` regression, unread-count desync |
120+
| 04 | `send-text` | Send round-trips and renders the bubble | XMPP BIND-result match, ConnectionStore stuck CONNECTING |
121+
| 05 | `receive-text` | Bob's REST-sent message arrives via XMPP | MAM subscription missing, XMPPClient duplication |
122+
| 06 | `attach-file` | Pick from gallery → upload → image bubble | Upload 401 (wrong auth), MIME rejection |
123+
| 07 | `reconnect-airplane` | Disconnect → Connect → history survives | XMPP client not torn down, banner stuck |
124+
| 08 | `push-deep-link` | Synthetic notification → right room | Intent extras lost, room JID URL-decoded wrong |
125+
| 09 | `logout-relogin` | Full logout → re-login same user → state isolated | Persisted state leaking across sessions |
126+
| 10 | `switch-app` | App A → App B in-process | Store not flushed, XMPP client persisting wrong-app JID |
127+
| 11 | `login-wrong-password` | 401 surfaces as error, form remains editable | Error suppressed, retry loop |
128+
| 13 | `message-edit` | Long-press → Edit → bubble updates | edit prop not flowing, optimistic-update reconciliation |
129+
| 14 | `message-delete` | Long-press → Delete → bubble gone or tombstoned | Delete RPC silently failing, MAM still returning deleted |
130+
| 15 | `message-reaction` | Long-press → React → emoji + count visible | Reaction not stored, picker missing presets |
131+
| 16 | `create-room` | "+" → Create dialog → new room visible + writable | Create-room RPC silent failure, JID collision |
132+
| 17 | `search-rooms` | RoomListView SearchBar narrows + restores list | Predicate not case-insensitive, list not re-rendering |
133+
| 18 | `multi-message-rapid` | 5 back-to-back sends all visible in order | Out-of-order ack reorder, optimistic UI dropping bubbles |
134+
| 19 | `room-info` | Room info → participants + leave control | `GET /chats/:jid/details` regression |
135+
| 20 | `offline-pending-resend` | Disconnect → send → reconnect → message lands | Send dropped silently, sendFailed never clearing |
136+
137+
## Why some helpers live outside the flow YAML
138+
139+
Maestro's JS runtime can drive HTTP (`http.post(...)`) but can't
140+
shell out — anything that needs `xcrun simctl` (synthetic push
141+
intents, pushing files into the Photos library, network simulation)
142+
is invoked from the CI workflow before/after the flow runs. The
143+
flow then asserts on the resulting state.
144+
145+
## Authoring a new flow
146+
147+
- Use accessibility-identifier anchors (`id: "chat_input"`) over
148+
text matching where possible — labels move under localization /
149+
copy edits, IDs don't.
150+
- Keep each flow under ~30 lines. If you need more, split it.
151+
- Pull credentials from `fixtures/` rather than inlining them.
152+
- Always end with at least one `assertVisible` / `assertNotVisible`
153+
so a flow that silently no-ops fails loudly.
154+
155+
When a regression slips through Layer 1 unit tests, add a flow for
156+
it in the same PR as the fix.

.maestro/assets/test-image.png

74 Bytes
Loading

.maestro/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Maestro project config — applies to every flow under .maestro/flows.
2+
flows:
3+
- flows/*.yaml

.maestro/fixtures/test-users.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Shared test-user fixtures referenced by individual flows.
2+
#
3+
# IMPORTANT: do not commit real production credentials here. Test
4+
# users below should exist on chat-qa.ethora.com only — created via
5+
# `@ethora/setup` against a QA app, with throwaway passwords.
6+
#
7+
# CI overrides these via repository secrets (MAESTRO_TEST_EMAIL /
8+
# MAESTRO_TEST_PASSWORD) so the live values never live in source.
9+
#
10+
# Mirrors `ethora-sample-android/.maestro/fixtures/test-users.yaml`.
11+
12+
ALICE_EMAIL: alice@ethora.com
13+
ALICE_PASSWORD: TestPass123
14+
15+
BOB_EMAIL: bob@ethora.com
16+
BOB_PASSWORD: TestPass123

.maestro/flows/01-login-email.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Email + password login against the configured server.
2+
# Catches: wrong app token (TOKEN_WRONG_TYPE), wrong base URL, broken
3+
# /users/login-with-email response shape, expired refresh tokens.
4+
appId: com.ethora.SDKPlayground
5+
env:
6+
EMAIL: ${MAESTRO_TEST_EMAIL:-alice@ethora.com}
7+
PASSWORD: ${MAESTRO_TEST_PASSWORD:-TestPass123}
8+
---
9+
- launchApp:
10+
clearState: true
11+
- assertVisible: "Setup"
12+
- tapOn: "Setup"
13+
- tapOn:
14+
text: "Email"
15+
- inputText: ${EMAIL}
16+
- tapOn:
17+
text: "Password"
18+
- inputText: ${PASSWORD}
19+
- tapOn: "Connect"
20+
- extendedWaitUntil:
21+
visible: "Connected"
22+
timeout: 15000
23+
- assertNotVisible: "Login failed"

.maestro/flows/02-login-jwt.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# JWT (custom) auth mode — host app provides a pre-signed JWT and the
2+
# SDK posts it to /users/client. Distinct from email login because it
3+
# exercises JWTLoginConfig + the bring-your-own-auth path.
4+
appId: com.ethora.SDKPlayground
5+
env:
6+
USER_JWT: ${MAESTRO_TEST_USER_JWT}
7+
---
8+
- launchApp:
9+
clearState: true
10+
- runFlow:
11+
when:
12+
true: ${USER_JWT == null || USER_JWT == ""}
13+
commands:
14+
- assertVisible: "(skip — no MAESTRO_TEST_USER_JWT set in env)"
15+
- tapOn: "Setup"
16+
- tapOn: "JWT"
17+
- tapOn:
18+
text: "JWT token"
19+
- inputText: ${USER_JWT}
20+
- tapOn: "Connect"
21+
- extendedWaitUntil:
22+
visible: "Connected"
23+
timeout: 15000

.maestro/flows/03-list-rooms.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# After login, the Chat tab shows the user's room list with unread
2+
# counts. Catches: GET /chats/my regression, room ordering changes,
3+
# unread-count desync between API and XMPP MAM.
4+
appId: com.ethora.SDKPlayground
5+
---
6+
- runFlow: 01-login-email.yaml
7+
- tapOn: "Chat"
8+
- extendedWaitUntil:
9+
visible:
10+
id: "rooms_list"
11+
timeout: 10000

.maestro/flows/04-send-text.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Send a text message from alice. Verifies the full XMPP send path:
2+
# resource bind → MUC presence → message stanza → archive ack.
3+
# Catches: BIND-result match regression, send button gated on wrong
4+
# status, optimistic UI dropping bubbles on first ack.
5+
appId: com.ethora.SDKPlayground
6+
env:
7+
TEST_MESSAGE: "maestro hello ${EPOCH}"
8+
---
9+
- runFlow: 03-list-rooms.yaml
10+
- tapOnFirst:
11+
id: "room_row"
12+
- tapOn:
13+
id: "chat_input"
14+
- inputText: ${TEST_MESSAGE}
15+
- tapOn:
16+
id: "chat_send_button"
17+
- extendedWaitUntil:
18+
visible: ${TEST_MESSAGE}
19+
timeout: 5000

0 commit comments

Comments
 (0)