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
133 changes: 133 additions & 0 deletions .github/workflows/maestro.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: Maestro E2E (iOS)

on:
push:
branches: [main]
pull_request:
branches: [main]
release:
types: [published]
workflow_dispatch:

# Required repo secrets (Settings → Secrets and variables → Actions):
#
# MAESTRO_TEST_EMAIL alice's email on chat-qa
# MAESTRO_TEST_PASSWORD alice's password
# MAESTRO_TEST_USER_JWT alice's client-flow JWT (02-login-jwt)
# MAESTRO_TEST_ROOM_JID a MUC room JID alice + bob both belong to
# MAESTRO_TEST_BOB_JWT bob's user-session JWT (sendAsBob.js — 05)
# MAESTRO_TEST_APPB_* second-app credentials for 10-switch-app
# ETHORA_API_BASE_URL e.g. https://api.chat-qa.ethora.com/v1
# ETHORA_APP_ID 24-char hex
# ETHORA_APP_TOKEN JWT app token
#
# All credentials must point at chat-qa, never prod.

jobs:
maestro:
name: Maestro on iOS Simulator (${{ matrix.os-version }})
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
# PR runs hit one iOS version; release tags hit the matrix.
os-version: ${{ github.event_name == 'release' && fromJson('["17.5", "18.0"]') || fromJson('["18.0"]') }}
device: ['iPhone 15']
steps:
- uses: actions/checkout@v4

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app

- name: Install xcodegen + Maestro
run: |
brew install xcodegen
curl -fsSL https://get.maestro.mobile.dev | bash

- name: Generate Xcode project
run: ./generate_xcodeproj.sh

- name: Build SDKPlayground
run: |
xcodebuild -project SDKPlayground.xcodeproj \
-scheme SDKPlayground \
-destination 'platform=iOS Simulator,name=${{ matrix.device }},OS=${{ matrix.os-version }}' \
-configuration Debug \
-derivedDataPath build/ \
CODE_SIGNING_ALLOWED=NO \
build

- name: Boot simulator + install app
run: |
xcrun simctl shutdown all || true
DEVICE_ID=$(xcrun simctl create maestro-runner \
"com.apple.CoreSimulator.SimDeviceType.iPhone-15" \
"com.apple.CoreSimulator.SimRuntime.iOS-${{ matrix.os-version }}" || \
xcrun simctl list devices "${{ matrix.device }}" | grep -E "${{ matrix.device }} \(" | head -1 | awk -F'[()]' '{print $2}')
echo "DEVICE_ID=$DEVICE_ID" >> "$GITHUB_ENV"
xcrun simctl boot "$DEVICE_ID"
xcrun simctl install "$DEVICE_ID" \
build/Build/Products/Debug-iphonesimulator/SDKPlayground.app

- name: Seed test image into Photos library (for flow 06)
run: |
xcrun simctl addmedia "$DEVICE_ID" .maestro/assets/test-image.png

- name: Write SDKPlayground default config from secrets
# The iOS playground reads its defaults from PlaygroundSession's
# @AppStorage. CI sets them by writing the corresponding
# UserDefaults entries via simctl. Adjust the keys below if the
# app's PlaygroundSession persistence schema changes.
run: |
xcrun simctl spawn "$DEVICE_ID" defaults write com.ethora.SDKPlayground baseURLString "${{ secrets.ETHORA_API_BASE_URL }}"
xcrun simctl spawn "$DEVICE_ID" defaults write com.ethora.SDKPlayground appId "${{ secrets.ETHORA_APP_ID }}"
xcrun simctl spawn "$DEVICE_ID" defaults write com.ethora.SDKPlayground appToken "${{ secrets.ETHORA_APP_TOKEN }}"

- name: Run flows on simulator
run: |
# Flows 01-07 + 09-20, skipping 08 (push, needs simctl push first).
~/.maestro/bin/maestro test \
.maestro/flows/01-login-email.yaml \
.maestro/flows/02-login-jwt.yaml \
.maestro/flows/03-list-rooms.yaml \
.maestro/flows/04-send-text.yaml \
.maestro/flows/05-receive-text.yaml \
.maestro/flows/06-attach-file.yaml \
.maestro/flows/07-reconnect-airplane.yaml \
.maestro/flows/09-logout-relogin.yaml \
.maestro/flows/10-switch-app.yaml \
.maestro/flows/11-login-wrong-password.yaml \
.maestro/flows/13-message-edit.yaml \
.maestro/flows/14-message-delete.yaml \
.maestro/flows/15-message-reaction.yaml \
.maestro/flows/16-create-room.yaml \
.maestro/flows/17-search-rooms.yaml \
.maestro/flows/18-multi-message-rapid.yaml \
.maestro/flows/19-room-info.yaml \
.maestro/flows/20-offline-pending-resend.yaml

# Flow 08: deliver synthetic push, then run the deep-link
# assertion flow.
bash .maestro/scripts/sendPushIntent.sh "${MAESTRO_TEST_ROOM_JID}"
~/.maestro/bin/maestro test .maestro/flows/08-push-deep-link.yaml
env:
MAESTRO_TEST_EMAIL: ${{ secrets.MAESTRO_TEST_EMAIL }}
MAESTRO_TEST_PASSWORD: ${{ secrets.MAESTRO_TEST_PASSWORD }}
MAESTRO_TEST_USER_JWT: ${{ secrets.MAESTRO_TEST_USER_JWT }}
MAESTRO_TEST_ROOM_JID: ${{ secrets.MAESTRO_TEST_ROOM_JID }}
MAESTRO_TEST_APPB_BASE_URL: ${{ secrets.MAESTRO_TEST_APPB_BASE_URL }}
MAESTRO_TEST_APPB_APP_ID: ${{ secrets.MAESTRO_TEST_APPB_APP_ID }}
MAESTRO_TEST_APPB_APP_TOKEN: ${{ secrets.MAESTRO_TEST_APPB_APP_TOKEN }}
MAESTRO_TEST_APPB_EMAIL: ${{ secrets.MAESTRO_TEST_APPB_EMAIL }}
MAESTRO_TEST_APPB_PASSWORD: ${{ secrets.MAESTRO_TEST_APPB_PASSWORD }}
MAESTRO_API_BASE_URL: ${{ secrets.ETHORA_API_BASE_URL }}
MAESTRO_APP_TOKEN: ${{ secrets.ETHORA_APP_TOKEN }}
MAESTRO_TEST_BOB_JWT: ${{ secrets.MAESTRO_TEST_BOB_JWT }}

- name: Upload Maestro recordings on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-recordings-ios-${{ matrix.os-version }}
path: ~/.maestro/tests/**
retention-days: 14
156 changes: 156 additions & 0 deletions .maestro/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Maestro flows for `ethora-sample-swift`

End-to-end smoke tests that drive the SDKPlayground app on an iOS
Simulator (or a real device) against an Ethora server. Layer 2 of
the SDK testing strategy — see [`ethora-sdk-swift` README → Testing](https://github.com/dappros/ethora-sdk-swift/blob/main/README.md#testing)
for the split with hermetic XCTest unit tests.

## Cross-platform parity

The flow YAMLs here mirror the Android sample's flows
(`ethora-sample-android/.maestro/flows/`) one-for-one, with the
same numbering and intent. They resolve UI nodes by accessibility
identifier — the same string IDs Compose's `testTag(...)` uses on
Android — so a single flow exercises the same intent on either
platform.

iOS-specific differences from the Android equivalents:
- `appId: com.ethora.SDKPlayground` (Android uses `com.ethora`)
- Tab labels are `"Setup"`, `"Chat"`, `"Logs"` in title-case
(Android uses `"SETUP"`, `"CHAT"`, `"LOGS"` in the segmented
control)
- The system search bar (iOS `.searchable`) renders differently
from Android's RoomListView search; flow `17-search-rooms`
resolves it via SwiftUI's default search affordance, not via
the `rooms_search_input` ID

Otherwise the YAMLs are byte-for-byte aligned where they can be.

## Repo layout

```
.maestro/
├── README.md (you are here)
├── config.yaml project-level Maestro config
├── assets/ binary fixtures (test images etc.)
│ └── test-image.png 8×8 PNG used by 06-attach-file
├── fixtures/ shared test data (do not commit real credentials)
│ └── test-users.yaml
├── scripts/ helpers invoked by flows or by CI before flows
│ ├── sendAsBob.js Maestro JS helper — POSTs a message as bob
│ │ via REST, used by 05-receive-text
│ └── sendPushIntent.sh
│ adb-equivalent for iOS via xcrun simctl
│ push, invoked by CI BEFORE 08-push-deep-link
└── flows/
├── 01-login-email.yaml
├── 02-login-jwt.yaml
├── 03-list-rooms.yaml
├── 04-send-text.yaml
├── 05-receive-text.yaml uses scripts/sendAsBob.js
├── 06-attach-file.yaml uses assets/test-image.png seeded
│ into the simulator's Photos library
│ by CI
├── 07-reconnect-airplane.yaml drives reconnect via the Setup
│ tab's Disconnect button (no shell
│ dependency)
├── 08-push-deep-link.yaml CI runs sendPushIntent.sh first
├── 09-logout-relogin.yaml
├── 10-switch-app.yaml
├── 11-login-wrong-password.yaml
├── 13-message-edit.yaml
├── 14-message-delete.yaml
├── 15-message-reaction.yaml
├── 16-create-room.yaml
├── 17-search-rooms.yaml
├── 18-multi-message-rapid.yaml
├── 19-room-info.yaml
└── 20-offline-pending-resend.yaml
```

(Flow 12 reserved for typing-indicator — needs a `sendAsBob`-style
helper for XMPP composing-state.)

## Running locally

1. Install Maestro: `brew install maestro` (or `curl -fsSL https://get.maestro.mobile.dev | bash`).
2. Boot a Simulator and install the app:

```bash
./generate_xcodeproj.sh # if SDKPlayground.xcodeproj is stale
xcodebuild -project SDKPlayground.xcodeproj \
-scheme SDKPlayground \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-configuration Debug \
-derivedDataPath build/
xcrun simctl install booted \
build/Build/Products/Debug-iphonesimulator/SDKPlayground.app
```

3. Populate `SDKPlayground/PlaygroundSession.swift`'s defaults via
`npx @ethora/setup` against your QA app — same flow as Android.
4. Run a single flow:

```bash
maestro test .maestro/flows/01-login-email.yaml
```

Or all flows:

```bash
maestro test .maestro/flows
```

## Running in CI

`.github/workflows/maestro.yml` runs the suite on every push, PR,
and SDK release tag on a macOS runner with an iOS Simulator.

## Coverage table

What each flow proves end-to-end against `chat-qa.ethora.com`.
Identical to the Android sample's coverage table — the same 19
flows, the same regression classes, same expected assertions.

| # | Flow | Asserts | Catches |
|---|------|---------|---------|
| 01 | `login-email` | Email + password → connected | Wrong app token, broken `/users/login-with-email` shape |
| 02 | `login-jwt` | Custom JWT → `/users/client` accepts | `TOKEN_WRONG_TYPE`, missing JWTLoginConfig wiring |
| 03 | `list-rooms` | After login, room list renders | `GET /chats/my` regression, unread-count desync |
| 04 | `send-text` | Send round-trips and renders the bubble | XMPP BIND-result match, ConnectionStore stuck CONNECTING |
| 05 | `receive-text` | Bob's REST-sent message arrives via XMPP | MAM subscription missing, XMPPClient duplication |
| 06 | `attach-file` | Pick from gallery → upload → image bubble | Upload 401 (wrong auth), MIME rejection |
| 07 | `reconnect-airplane` | Disconnect → Connect → history survives | XMPP client not torn down, banner stuck |
| 08 | `push-deep-link` | Synthetic notification → right room | Intent extras lost, room JID URL-decoded wrong |
| 09 | `logout-relogin` | Full logout → re-login same user → state isolated | Persisted state leaking across sessions |
| 10 | `switch-app` | App A → App B in-process | Store not flushed, XMPP client persisting wrong-app JID |
| 11 | `login-wrong-password` | 401 surfaces as error, form remains editable | Error suppressed, retry loop |
| 13 | `message-edit` | Long-press → Edit → bubble updates | edit prop not flowing, optimistic-update reconciliation |
| 14 | `message-delete` | Long-press → Delete → bubble gone or tombstoned | Delete RPC silently failing, MAM still returning deleted |
| 15 | `message-reaction` | Long-press → React → emoji + count visible | Reaction not stored, picker missing presets |
| 16 | `create-room` | "+" → Create dialog → new room visible + writable | Create-room RPC silent failure, JID collision |
| 17 | `search-rooms` | RoomListView SearchBar narrows + restores list | Predicate not case-insensitive, list not re-rendering |
| 18 | `multi-message-rapid` | 5 back-to-back sends all visible in order | Out-of-order ack reorder, optimistic UI dropping bubbles |
| 19 | `room-info` | Room info → participants + leave control | `GET /chats/:jid/details` regression |
| 20 | `offline-pending-resend` | Disconnect → send → reconnect → message lands | Send dropped silently, sendFailed never clearing |

## Why some helpers live outside the flow YAML

Maestro's JS runtime can drive HTTP (`http.post(...)`) but can't
shell out — anything that needs `xcrun simctl` (synthetic push
intents, pushing files into the Photos library, network simulation)
is invoked from the CI workflow before/after the flow runs. The
flow then asserts on the resulting state.

## Authoring a new flow

- Use accessibility-identifier anchors (`id: "chat_input"`) over
text matching where possible — labels move under localization /
copy edits, IDs don't.
- Keep each flow under ~30 lines. If you need more, split it.
- Pull credentials from `fixtures/` rather than inlining them.
- Always end with at least one `assertVisible` / `assertNotVisible`
so a flow that silently no-ops fails loudly.

When a regression slips through Layer 1 unit tests, add a flow for
it in the same PR as the fix.
Binary file added .maestro/assets/test-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions .maestro/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Maestro project config — applies to every flow under .maestro/flows.
flows:
- flows/*.yaml
16 changes: 16 additions & 0 deletions .maestro/fixtures/test-users.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Shared test-user fixtures referenced by individual flows.
#
# IMPORTANT: do not commit real production credentials here. Test
# users below should exist on chat-qa.ethora.com only — created via
# `@ethora/setup` against a QA app, with throwaway passwords.
#
# CI overrides these via repository secrets (MAESTRO_TEST_EMAIL /
# MAESTRO_TEST_PASSWORD) so the live values never live in source.
#
# Mirrors `ethora-sample-android/.maestro/fixtures/test-users.yaml`.

ALICE_EMAIL: alice@ethora.com
ALICE_PASSWORD: TestPass123

BOB_EMAIL: bob@ethora.com
BOB_PASSWORD: TestPass123
23 changes: 23 additions & 0 deletions .maestro/flows/01-login-email.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Email + password login against the configured server.
# Catches: wrong app token (TOKEN_WRONG_TYPE), wrong base URL, broken
# /users/login-with-email response shape, expired refresh tokens.
appId: com.ethora.SDKPlayground
env:
EMAIL: ${MAESTRO_TEST_EMAIL:-alice@ethora.com}
PASSWORD: ${MAESTRO_TEST_PASSWORD:-TestPass123}
---
- launchApp:
clearState: true
- assertVisible: "Setup"
- tapOn: "Setup"
- tapOn:
text: "Email"
- inputText: ${EMAIL}
- tapOn:
text: "Password"
- inputText: ${PASSWORD}
- tapOn: "Connect"
- extendedWaitUntil:
visible: "Connected"
timeout: 15000
- assertNotVisible: "Login failed"
23 changes: 23 additions & 0 deletions .maestro/flows/02-login-jwt.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# JWT (custom) auth mode — host app provides a pre-signed JWT and the
# SDK posts it to /users/client. Distinct from email login because it
# exercises JWTLoginConfig + the bring-your-own-auth path.
appId: com.ethora.SDKPlayground
env:
USER_JWT: ${MAESTRO_TEST_USER_JWT}
---
- launchApp:
clearState: true
- runFlow:
when:
true: ${USER_JWT == null || USER_JWT == ""}
commands:
- assertVisible: "(skip — no MAESTRO_TEST_USER_JWT set in env)"
- tapOn: "Setup"
- tapOn: "JWT"
- tapOn:
text: "JWT token"
- inputText: ${USER_JWT}
- tapOn: "Connect"
- extendedWaitUntil:
visible: "Connected"
timeout: 15000
11 changes: 11 additions & 0 deletions .maestro/flows/03-list-rooms.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# After login, the Chat tab shows the user's room list with unread
# counts. Catches: GET /chats/my regression, room ordering changes,
# unread-count desync between API and XMPP MAM.
appId: com.ethora.SDKPlayground
---
- runFlow: 01-login-email.yaml
- tapOn: "Chat"
- extendedWaitUntil:
visible:
id: "rooms_list"
timeout: 10000
19 changes: 19 additions & 0 deletions .maestro/flows/04-send-text.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Send a text message from alice. Verifies the full XMPP send path:
# resource bind → MUC presence → message stanza → archive ack.
# Catches: BIND-result match regression, send button gated on wrong
# status, optimistic UI dropping bubbles on first ack.
appId: com.ethora.SDKPlayground
env:
TEST_MESSAGE: "maestro hello ${EPOCH}"
---
- runFlow: 03-list-rooms.yaml
- tapOnFirst:
id: "room_row"
- tapOn:
id: "chat_input"
- inputText: ${TEST_MESSAGE}
- tapOn:
id: "chat_send_button"
- extendedWaitUntil:
visible: ${TEST_MESSAGE}
timeout: 5000
Loading
Loading