From bd0a6618de11832ab7c3d4f8ae9cd11fc65a4392 Mon Sep 17 00:00:00 2001 From: Taras Filatov Date: Sun, 10 May 2026 12:25:40 +0100 Subject: [PATCH 1/2] test(maestro): scaffold iOS E2E flow layer + CI workflow + Testing docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors ethora-sample-android/.maestro/ for the SDKPlayground iOS app. Same 19 flows, same numbering, same intent — they resolve nodes by accessibility identifier (set on the SDK side via ethora-sdk-swift PR #6) so a single Maestro flow exercises the same scenario on both platforms. Files: .maestro/ config.yaml project-level Maestro config README.md Layer-2 testing intro, coverage table, cross-platform parity notes fixtures/test-users.yaml assets/test-image.png 8x8 PNG seeded into Photos library by CI scripts/sendAsBob.js same JS helper as Android — Maestro's http.post is platform-neutral scripts/sendPushIntent.sh iOS analog of Android's adb am start wrapper, uses xcrun simctl push flows/01..20-*.yaml 19 flows (12 reserved for typing-indicator) .github/workflows/maestro.yml GitHub Actions workflow on macos-latest: - selects Xcode, installs xcodegen + Maestro - generate_xcodeproj.sh + xcodebuild for SDKPlayground - boots iPhone 15 simulator + installs the .app - seeds test-image.png into Photos via simctl addmedia - writes ETHORA_* defaults via simctl spawn defaults write - runs flows 01-07 + 09-20, then sendPushIntent.sh + flow 08 - matrix on iOS [17.5, 18.0] for release tags, single 18.0 on PRs - uploads Maestro recordings on failure README.md "Testing" section with the 19-flow coverage table and a link to the matching Android sample. Cross-references the SDK README so contributors know where Layer 1 vs Layer 2 lives. iOS-specific differences from the Android equivalents documented in .maestro/README.md: - appId: com.ethora.SDKPlayground (vs com.ethora on Android) - Tab labels are title-case "Setup"/"Chat"/"Logs" (Android segmented control uses uppercase) - System search bar via SwiftUI's .searchable, swipe-down to reveal - Push delivery via xcrun simctl push (Android uses adb am start) - Test image seeded via simctl addmedia (Android uses adb push + MEDIA_SCANNER_SCAN_FILE broadcast) --- .github/workflows/maestro.yml | 133 +++++++++++++++ .maestro/README.md | 156 ++++++++++++++++++ .maestro/assets/test-image.png | Bin 0 -> 74 bytes .maestro/config.yaml | 3 + .maestro/fixtures/test-users.yaml | 16 ++ .maestro/flows/01-login-email.yaml | 23 +++ .maestro/flows/02-login-jwt.yaml | 23 +++ .maestro/flows/03-list-rooms.yaml | 11 ++ .maestro/flows/04-send-text.yaml | 19 +++ .maestro/flows/05-receive-text.yaml | 18 ++ .maestro/flows/06-attach-file.yaml | 25 +++ .maestro/flows/07-reconnect-airplane.yaml | 18 ++ .maestro/flows/08-push-deep-link.yaml | 21 +++ .maestro/flows/09-logout-relogin.yaml | 18 ++ .maestro/flows/10-switch-app.yaml | 42 +++++ .maestro/flows/11-login-wrong-password.yaml | 25 +++ .maestro/flows/13-message-edit.yaml | 36 ++++ .maestro/flows/14-message-delete.yaml | 27 +++ .maestro/flows/15-message-reaction.yaml | 28 ++++ .maestro/flows/16-create-room.yaml | 32 ++++ .maestro/flows/17-search-rooms.yaml | 25 +++ .maestro/flows/18-multi-message-rapid.yaml | 43 +++++ .maestro/flows/19-room-info.yaml | 26 +++ .maestro/flows/20-offline-pending-resend.yaml | 34 ++++ .maestro/scripts/sendAsBob.js | 63 +++++++ .maestro/scripts/sendPushIntent.sh | 47 ++++++ README.md | 57 +++++++ 27 files changed, 969 insertions(+) create mode 100644 .github/workflows/maestro.yml create mode 100644 .maestro/README.md create mode 100644 .maestro/assets/test-image.png create mode 100644 .maestro/config.yaml create mode 100644 .maestro/fixtures/test-users.yaml create mode 100644 .maestro/flows/01-login-email.yaml create mode 100644 .maestro/flows/02-login-jwt.yaml create mode 100644 .maestro/flows/03-list-rooms.yaml create mode 100644 .maestro/flows/04-send-text.yaml create mode 100644 .maestro/flows/05-receive-text.yaml create mode 100644 .maestro/flows/06-attach-file.yaml create mode 100644 .maestro/flows/07-reconnect-airplane.yaml create mode 100644 .maestro/flows/08-push-deep-link.yaml create mode 100644 .maestro/flows/09-logout-relogin.yaml create mode 100644 .maestro/flows/10-switch-app.yaml create mode 100644 .maestro/flows/11-login-wrong-password.yaml create mode 100644 .maestro/flows/13-message-edit.yaml create mode 100644 .maestro/flows/14-message-delete.yaml create mode 100644 .maestro/flows/15-message-reaction.yaml create mode 100644 .maestro/flows/16-create-room.yaml create mode 100644 .maestro/flows/17-search-rooms.yaml create mode 100644 .maestro/flows/18-multi-message-rapid.yaml create mode 100644 .maestro/flows/19-room-info.yaml create mode 100644 .maestro/flows/20-offline-pending-resend.yaml create mode 100644 .maestro/scripts/sendAsBob.js create mode 100755 .maestro/scripts/sendPushIntent.sh diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml new file mode 100644 index 0000000..caf85af --- /dev/null +++ b/.github/workflows/maestro.yml @@ -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 diff --git a/.maestro/README.md b/.maestro/README.md new file mode 100644 index 0000000..1d3621d --- /dev/null +++ b/.maestro/README.md @@ -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. diff --git a/.maestro/assets/test-image.png b/.maestro/assets/test-image.png new file mode 100644 index 0000000000000000000000000000000000000000..34ae441792b2790e4034a26b184e3980712446c6 GIT binary patch literal 74 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqf}SppAsp9}|NQ@N&&-pM#K53> Wg6a2ekDxT5B!j1`pUXO@geCwj-4ZSU literal 0 HcmV?d00001 diff --git a/.maestro/config.yaml b/.maestro/config.yaml new file mode 100644 index 0000000..42a66fe --- /dev/null +++ b/.maestro/config.yaml @@ -0,0 +1,3 @@ +# Maestro project config — applies to every flow under .maestro/flows. +flows: + - flows/*.yaml diff --git a/.maestro/fixtures/test-users.yaml b/.maestro/fixtures/test-users.yaml new file mode 100644 index 0000000..6e7134a --- /dev/null +++ b/.maestro/fixtures/test-users.yaml @@ -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 diff --git a/.maestro/flows/01-login-email.yaml b/.maestro/flows/01-login-email.yaml new file mode 100644 index 0000000..31df15f --- /dev/null +++ b/.maestro/flows/01-login-email.yaml @@ -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" diff --git a/.maestro/flows/02-login-jwt.yaml b/.maestro/flows/02-login-jwt.yaml new file mode 100644 index 0000000..f0dd8a8 --- /dev/null +++ b/.maestro/flows/02-login-jwt.yaml @@ -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 diff --git a/.maestro/flows/03-list-rooms.yaml b/.maestro/flows/03-list-rooms.yaml new file mode 100644 index 0000000..408effc --- /dev/null +++ b/.maestro/flows/03-list-rooms.yaml @@ -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 diff --git a/.maestro/flows/04-send-text.yaml b/.maestro/flows/04-send-text.yaml new file mode 100644 index 0000000..6e01166 --- /dev/null +++ b/.maestro/flows/04-send-text.yaml @@ -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 diff --git a/.maestro/flows/05-receive-text.yaml b/.maestro/flows/05-receive-text.yaml new file mode 100644 index 0000000..3786fde --- /dev/null +++ b/.maestro/flows/05-receive-text.yaml @@ -0,0 +1,18 @@ +# Two-user flow: bob sends a message via direct REST API, alice (in +# this Maestro session on iOS) sees it land in real-time via XMPP +# push. Catches: missing MAM subscription, XMPPClient duplication +# for the same JID, reconnect-after-idle dropping the muc presence. +appId: com.ethora.SDKPlayground +env: + TEST_MESSAGE: "from bob ${EPOCH}" +--- +- runFlow: 03-list-rooms.yaml +- tapOnFirst: + id: "room_row" +- runScript: + file: ../scripts/sendAsBob.js + env: + MESSAGE: ${TEST_MESSAGE} +- extendedWaitUntil: + visible: ${TEST_MESSAGE} + timeout: 10000 diff --git a/.maestro/flows/06-attach-file.yaml b/.maestro/flows/06-attach-file.yaml new file mode 100644 index 0000000..07f224f --- /dev/null +++ b/.maestro/flows/06-attach-file.yaml @@ -0,0 +1,25 @@ +# Attach + send a file. Catches: PendingMediaSendQueue regressions, +# upload 401 due to wrong auth header, MIME-type rejection. +# +# Prerequisite: the test image must be in the simulator's Photos +# library. The CI workflow seeds it before this flow runs: +# +# xcrun simctl addmedia booted .maestro/assets/test-image.png +# +# For local runs, run the same command once after booting the +# simulator. +appId: com.ethora.SDKPlayground +--- +- runFlow: 04-send-text.yaml +- tapOn: + id: "chat_attach_button" +- tapOn: + text: "Photos" + optional: true +- tapOnFirst: + id: "gallery_thumbnail" + optional: true +- extendedWaitUntil: + visible: + id: "chat_message_image" + timeout: 30000 diff --git a/.maestro/flows/07-reconnect-airplane.yaml b/.maestro/flows/07-reconnect-airplane.yaml new file mode 100644 index 0000000..aa965b9 --- /dev/null +++ b/.maestro/flows/07-reconnect-airplane.yaml @@ -0,0 +1,18 @@ +# Reconnect smoke — drives the SDK's reconnect path via the app's +# Disconnect / Connect buttons rather than toggling OS-level network +# state (the simulator's Wi-Fi can be killed via `xcrun simctl +# privacy` only on certain SDKs; the in-app path is more portable). +appId: com.ethora.SDKPlayground +--- +- runFlow: 04-send-text.yaml +- tapOn: "Setup" +- tapOn: "Disconnect" +- extendedWaitUntil: + visible: "Disconnected" + timeout: 10000 +- tapOn: "Connect" +- extendedWaitUntil: + visible: "Connected" + timeout: 30000 +- tapOn: "Chat" +- assertVisible: "maestro hello" diff --git a/.maestro/flows/08-push-deep-link.yaml b/.maestro/flows/08-push-deep-link.yaml new file mode 100644 index 0000000..1e801fd --- /dev/null +++ b/.maestro/flows/08-push-deep-link.yaml @@ -0,0 +1,21 @@ +# Push notification → deep-link to the right room. Catches: payload +# extras not extracted, room JID URL-decoded wrong, navigation fires +# before the SDK has finished bootstrap. +# +# We don't deliver via APNS/FCM here — that needs a real device with +# entitlements + server-side push. Instead the **CI workflow** +# synthesises the notification via: +# +# xcrun simctl push booted com.ethora.SDKPlayground push.json +# +# where push.json contains the same `notification_jid` payload the +# SDK reads. The flow then asserts on the resulting state. +appId: com.ethora.SDKPlayground +env: + TARGET_ROOM_JID: ${MAESTRO_TEST_ROOM_JID} +--- +- launchApp: + clearState: false +- extendedWaitUntil: + visible: ${TARGET_ROOM_JID} + timeout: 10000 diff --git a/.maestro/flows/09-logout-relogin.yaml b/.maestro/flows/09-logout-relogin.yaml new file mode 100644 index 0000000..c30a244 --- /dev/null +++ b/.maestro/flows/09-logout-relogin.yaml @@ -0,0 +1,18 @@ +# Full logout → re-login cycle. Catches: persisted state not cleared +# on logout (next user sees previous user's rooms briefly), CoreData +# / UserDefaults corruption on second-init, XMPP client leaking +# across sessions. +appId: com.ethora.SDKPlayground +--- +- runFlow: 04-send-text.yaml +- tapOn: "Setup" +- tapOn: "Disconnect" +- extendedWaitUntil: + visible: "Disconnected" + timeout: 10000 +- tapOn: "Connect" +- extendedWaitUntil: + visible: "Connected" + timeout: 15000 +- tapOn: "Chat" +- assertVisible: "maestro hello" diff --git a/.maestro/flows/10-switch-app.yaml b/.maestro/flows/10-switch-app.yaml new file mode 100644 index 0000000..d984560 --- /dev/null +++ b/.maestro/flows/10-switch-app.yaml @@ -0,0 +1,42 @@ +# Multi-tenant: switch from app A's user to app B's user (different +# baseUrl + appId + token + user) without restarting the host +# activity. Catches: store not flushed between contexts, XMPP client +# not torn down (mod_ethora rejects with "wrong app name"), +# Setup-tab default-state not bumped. +appId: com.ethora.SDKPlayground +env: + APPB_BASE_URL: ${MAESTRO_TEST_APPB_BASE_URL} + APPB_APP_ID: ${MAESTRO_TEST_APPB_APP_ID} + APPB_APP_TOKEN: ${MAESTRO_TEST_APPB_APP_TOKEN} + APPB_EMAIL: ${MAESTRO_TEST_APPB_EMAIL} + APPB_PASSWORD: ${MAESTRO_TEST_APPB_PASSWORD} +--- +- runFlow: 01-login-email.yaml +- tapOn: "Setup" +- tapOn: "Disconnect" +- tapOn: + text: "Base URL" +- eraseText: 100 +- inputText: ${APPB_BASE_URL} +- tapOn: + text: "App ID" +- eraseText: 100 +- inputText: ${APPB_APP_ID} +- tapOn: + text: "App token" +- eraseText: 200 +- inputText: ${APPB_APP_TOKEN} +- tapOn: + text: "Email" +- eraseText: 100 +- inputText: ${APPB_EMAIL} +- tapOn: + text: "Password" +- eraseText: 100 +- inputText: ${APPB_PASSWORD} +- tapOn: "Connect" +- extendedWaitUntil: + visible: "Connected" + timeout: 15000 +- tapOn: "Chat" +- assertNotVisible: "maestro hello" diff --git a/.maestro/flows/11-login-wrong-password.yaml b/.maestro/flows/11-login-wrong-password.yaml new file mode 100644 index 0000000..d4585cc --- /dev/null +++ b/.maestro/flows/11-login-wrong-password.yaml @@ -0,0 +1,25 @@ +# Negative login path — wrong password produces a visible error, +# the form stays editable, no spurious "Connected" appears. +# Catches: error response not surfaced to UI, retry loop on 401, +# accidental login under a stale cached token. +appId: com.ethora.SDKPlayground +env: + EMAIL: ${MAESTRO_TEST_EMAIL:-alice@ethora.com} + WRONG_PASSWORD: "definitely-not-the-password-${EPOCH}" +--- +- launchApp: + clearState: true +- tapOn: "Setup" +- tapOn: + text: "Email" +- inputText: ${EMAIL} +- tapOn: + text: "Password" +- inputText: ${WRONG_PASSWORD} +- tapOn: "Connect" +- extendedWaitUntil: + visible: "Login failed" + timeout: 10000 +- assertNotVisible: "Connected" +- assertVisible: + text: "Connect" diff --git a/.maestro/flows/13-message-edit.yaml b/.maestro/flows/13-message-edit.yaml new file mode 100644 index 0000000..c03a901 --- /dev/null +++ b/.maestro/flows/13-message-edit.yaml @@ -0,0 +1,36 @@ +# Edit an own message after sending. Long-press the bubble → Edit +# action → ChatInputView switches to edit mode → change text → tap +# Send → assert the bubble updates in place and an `(edited)` +# indicator appears. +appId: com.ethora.SDKPlayground +env: + ORIGINAL: "edit-me ${EPOCH}" + EDITED: "edited-${EPOCH}" +--- +- runFlow: 03-list-rooms.yaml +- tapOnFirst: + id: "room_row" +- tapOn: + id: "chat_input" +- inputText: ${ORIGINAL} +- tapOn: + id: "chat_send_button" +- extendedWaitUntil: + visible: ${ORIGINAL} + timeout: 5000 +- longPressOn: ${ORIGINAL} +- tapOn: + text: "Edit" + optional: true +- eraseText: 200 +- inputText: ${EDITED} +- tapOn: + id: "chat_send_button" +- extendedWaitUntil: + visible: ${EDITED} + timeout: 5000 +- assertNotVisible: ${ORIGINAL} +- assertVisible: + text: "edited" + substring: true + optional: true diff --git a/.maestro/flows/14-message-delete.yaml b/.maestro/flows/14-message-delete.yaml new file mode 100644 index 0000000..97a32c6 --- /dev/null +++ b/.maestro/flows/14-message-delete.yaml @@ -0,0 +1,27 @@ +# Delete an own message. Long-press the bubble → Delete action → +# assert the bubble is gone or replaced by a tombstone. +appId: com.ethora.SDKPlayground +env: + TARGET: "delete-me ${EPOCH}" +--- +- runFlow: 03-list-rooms.yaml +- tapOnFirst: + id: "room_row" +- tapOn: + id: "chat_input" +- inputText: ${TARGET} +- tapOn: + id: "chat_send_button" +- extendedWaitUntil: + visible: ${TARGET} + timeout: 5000 +- longPressOn: ${TARGET} +- tapOn: + text: "Delete" + optional: true +- tapOn: + text: "Delete" + optional: true +- extendedWaitUntil: + notVisible: ${TARGET} + timeout: 5000 diff --git a/.maestro/flows/15-message-reaction.yaml b/.maestro/flows/15-message-reaction.yaml new file mode 100644 index 0000000..96fb39b --- /dev/null +++ b/.maestro/flows/15-message-reaction.yaml @@ -0,0 +1,28 @@ +# React to a message with an emoji. Long-press → reaction picker → +# pick 👍 → assert the reaction appears on the bubble with count 1. +appId: com.ethora.SDKPlayground +env: + TARGET: "react-to-me ${EPOCH}" +--- +- runFlow: 03-list-rooms.yaml +- tapOnFirst: + id: "room_row" +- tapOn: + id: "chat_input" +- inputText: ${TARGET} +- tapOn: + id: "chat_send_button" +- extendedWaitUntil: + visible: ${TARGET} + timeout: 5000 +- longPressOn: ${TARGET} +- tapOn: + text: "React" + optional: true +- tapOn: "👍" +- extendedWaitUntil: + visible: "👍" + timeout: 5000 +- assertVisible: + text: "1" + substring: true diff --git a/.maestro/flows/16-create-room.yaml b/.maestro/flows/16-create-room.yaml new file mode 100644 index 0000000..8d603fb --- /dev/null +++ b/.maestro/flows/16-create-room.yaml @@ -0,0 +1,32 @@ +# Create a new MUC room via the toolbar "+" button. Catches: +# create-room RPC failing silently while UI shows the new tile, +# room not bootstrapping XMPP presence on first send, JID +# generation collision on a duplicate name. +appId: com.ethora.SDKPlayground +env: + ROOM_NAME: "Maestro Room ${EPOCH}" +--- +- runFlow: 03-list-rooms.yaml +- tapOn: + id: "create_room_button" +- assertVisible: + text: "New chat" + optional: true +- tapOn: + text: "Room name" + optional: true +- inputText: ${ROOM_NAME} +- tapOn: + text: "Create" +- extendedWaitUntil: + visible: ${ROOM_NAME} + timeout: 10000 +- tapOn: ${ROOM_NAME} +- tapOn: + id: "chat_input" +- inputText: "first message in new room" +- tapOn: + id: "chat_send_button" +- extendedWaitUntil: + visible: "first message in new room" + timeout: 5000 diff --git a/.maestro/flows/17-search-rooms.yaml b/.maestro/flows/17-search-rooms.yaml new file mode 100644 index 0000000..c424abb --- /dev/null +++ b/.maestro/flows/17-search-rooms.yaml @@ -0,0 +1,25 @@ +# Search the room list. iOS uses SwiftUI's `.searchable` modifier +# which renders the system-style search bar at the top of the List. +# Catches: search predicate not running case-insensitive, accent +# stripping regression, list not re-rendering on query change, +# "no results" empty-state never appearing. +appId: com.ethora.SDKPlayground +env: + ROOM_QUERY: ${MAESTRO_TEST_ROOM_QUERY:-Maestro} +--- +- runFlow: 03-list-rooms.yaml +# .searchable shows the search bar after a slight downward scroll +# on iOS; pull the list down a touch to reveal it. +- swipe: + from: + id: "rooms_list" + direction: DOWN +- tapOn: + text: "Search" + optional: true +- inputText: ${ROOM_QUERY} +- extendedWaitUntil: + visible: + text: ${ROOM_QUERY} + substring: true + timeout: 5000 diff --git a/.maestro/flows/18-multi-message-rapid.yaml b/.maestro/flows/18-multi-message-rapid.yaml new file mode 100644 index 0000000..3dc53da --- /dev/null +++ b/.maestro/flows/18-multi-message-rapid.yaml @@ -0,0 +1,43 @@ +# Rapid-fire send of 5 messages. Catches: send-queue reorder bugs, +# optimistic UI dropping bubbles when acks arrive out of order, XMPP +# rate-limiting collapsing identical timestamps, ChatInputView +# debouncing eating the second tap. +appId: com.ethora.SDKPlayground +env: + PREFIX: "rapid-${EPOCH}" +--- +- runFlow: 03-list-rooms.yaml +- tapOnFirst: + id: "room_row" +- tapOn: + id: "chat_input" +- inputText: "${PREFIX}-1" +- tapOn: + id: "chat_send_button" +- tapOn: + id: "chat_input" +- inputText: "${PREFIX}-2" +- tapOn: + id: "chat_send_button" +- tapOn: + id: "chat_input" +- inputText: "${PREFIX}-3" +- tapOn: + id: "chat_send_button" +- tapOn: + id: "chat_input" +- inputText: "${PREFIX}-4" +- tapOn: + id: "chat_send_button" +- tapOn: + id: "chat_input" +- inputText: "${PREFIX}-5" +- tapOn: + id: "chat_send_button" +- extendedWaitUntil: + visible: "${PREFIX}-1" + timeout: 20000 +- assertVisible: "${PREFIX}-2" +- assertVisible: "${PREFIX}-3" +- assertVisible: "${PREFIX}-4" +- assertVisible: "${PREFIX}-5" diff --git a/.maestro/flows/19-room-info.yaml b/.maestro/flows/19-room-info.yaml new file mode 100644 index 0000000..e2f3cf9 --- /dev/null +++ b/.maestro/flows/19-room-info.yaml @@ -0,0 +1,26 @@ +# Open the per-room info modal — participants, room name, leave-room +# control. Catches: GET /chats/:jid/details regression, avatar URL +# malformed, leave-room button hidden on rooms the user owns, +# participant list not re-rendering on roster change. +appId: com.ethora.SDKPlayground +--- +- runFlow: 04-send-text.yaml +- tapOn: + text: "Info" + optional: true +- tapOn: + id: "chat_room_title" + optional: true +- assertVisible: + text: "Participants" + optional: true +- assertVisible: + text: ${MAESTRO_TEST_EMAIL:-alice@ethora.com} + substring: true + optional: true +- assertVisible: + text: "Leave" + substring: true + optional: true +- back +- assertVisible: "maestro hello" diff --git a/.maestro/flows/20-offline-pending-resend.yaml b/.maestro/flows/20-offline-pending-resend.yaml new file mode 100644 index 0000000..3532f04 --- /dev/null +++ b/.maestro/flows/20-offline-pending-resend.yaml @@ -0,0 +1,34 @@ +# Disconnect → send (message gets queued or marked send-failed) → +# reconnect → assert the message either auto-resends or is offered +# for retry, and ultimately becomes visible. +appId: com.ethora.SDKPlayground +env: + TARGET: "offline-resend-${EPOCH}" +--- +- runFlow: 03-list-rooms.yaml +- tapOnFirst: + id: "room_row" +- tapOn: "Setup" +- tapOn: "Disconnect" +- extendedWaitUntil: + visible: "Disconnected" + timeout: 10000 +- tapOn: "Chat" +- tapOn: + id: "chat_input" +- inputText: ${TARGET} +- tapOn: + id: "chat_send_button" +- tapOn: "Setup" +- tapOn: "Connect" +- extendedWaitUntil: + visible: "Connected" + timeout: 30000 +- tapOn: "Chat" +- tapOn: + text: "Tap to retry" + optional: true +- extendedWaitUntil: + visible: ${TARGET} + timeout: 15000 +- assertNotVisible: "Sending failed" diff --git a/.maestro/scripts/sendAsBob.js b/.maestro/scripts/sendAsBob.js new file mode 100644 index 0000000..24b8721 --- /dev/null +++ b/.maestro/scripts/sendAsBob.js @@ -0,0 +1,63 @@ +// Maestro JS helper for `05-receive-text.yaml`. +// +// Posts a chat message as bob (the second test user) so alice — who's +// running in this Maestro session — sees it arrive in real-time via +// XMPP. Validates the receive path end-to-end without needing a +// second emulator. +// +// Maestro's `runScript:` step exposes: +// `http` — built-in HTTP client (`http.post(url, opts)`) +// `json` — JSON parsing helper +// `output`— shared map writable by scripts, readable by flow steps +// `MESSAGE` etc. — env vars from the calling flow +// +// Required env vars (passed via the `env:` block on `runScript:`): +// MESSAGE text of the message bob will send +// +// Required ambient secrets (set on the CI runner, propagated to +// Maestro via `env:` at the workflow level): +// ETHORA_API_BASE_URL https://api.chat-qa.ethora.com/v1 +// ETHORA_APP_TOKEN JWT app token (must match the app behind +// MAESTRO_TEST_ROOM_JID) +// MAESTRO_TEST_BOB_JWT bob's user-session JWT — obtained ahead of +// time via login-with-email; pass it in +// instead of the password so the helper +// doesn't have to authenticate every run +// MAESTRO_TEST_ROOM_JID room JID alice and bob both belong to +// +// IMPORTANT: this script targets the *muc message* endpoint which on +// the chat.ethora.com cluster is `POST /messages`. If that endpoint +// is renamed or its request schema changes, this helper needs an +// update — keep it in lockstep with the SDK's outgoing send. + +const apiBaseUrl = MAESTRO_API_BASE_URL || 'https://api.chat-qa.ethora.com/v1'; +const appToken = MAESTRO_APP_TOKEN; +const userJwt = MAESTRO_TEST_BOB_JWT; +const roomJid = MAESTRO_TEST_ROOM_JID; +const message = MESSAGE || '(no message)'; + +if (!appToken || !userJwt || !roomJid) { + output.error = + 'sendAsBob: missing one of MAESTRO_APP_TOKEN, MAESTRO_TEST_BOB_JWT, MAESTRO_TEST_ROOM_JID'; + throw new Error(output.error); +} + +const response = http.post(`${apiBaseUrl}/messages`, { + headers: { + 'Authorization': userJwt, + 'X-App-Token': appToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + roomJid, + text: message, + }), +}); + +if (response.status < 200 || response.status >= 300) { + output.error = `sendAsBob: POST /messages returned ${response.status} — ${response.body}`; + throw new Error(output.error); +} + +output.messageId = (json.parse(response.body) || {}).messageId || ''; +output.sentText = message; diff --git a/.maestro/scripts/sendPushIntent.sh b/.maestro/scripts/sendPushIntent.sh new file mode 100755 index 0000000..d66ddbc --- /dev/null +++ b/.maestro/scripts/sendPushIntent.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Synthesises a push-notification tap for the SDKPlayground app on a +# booted iOS Simulator, mimicking what an APNs payload with a +# notification_jid extra would do. Used by `flows/08-push-deep-link.yaml`. +# +# Maestro's JS runtime can't shell out, so this script runs from the +# CI workflow (`.github/workflows/maestro.yml`) **before** Maestro +# runs the flow — the flow then asserts on the launched state. +# +# Required args / env: +# $1 room JID, e.g. abc123_def456@conference.xmpp.chat-qa.ethora.com +# +# Optional env: +# SIMCTL_DEVICE device UDID (when more than one simulator booted). +# Defaults to "booted". + +set -euo pipefail + +JID="${1:-${JID:-}}" +if [[ -z "$JID" ]]; then + echo "sendPushIntent.sh: missing room JID. Usage: $0 " >&2 + exit 1 +fi + +DEVICE="${SIMCTL_DEVICE:-booted}" +BUNDLE_ID="com.ethora.SDKPlayground" + +# Compose a minimal APNs payload. The SDK reads the room JID from +# the custom `notification_jid` claim — keep this in sync with +# PushNotificationManager.swift's payload parsing. +PAYLOAD_FILE="$(mktemp -t ethora_push_payload.XXXXXX).json" +trap 'rm -f "$PAYLOAD_FILE"' EXIT + +cat > "$PAYLOAD_FILE" < Date: Sun, 10 May 2026 22:44:59 +0100 Subject: [PATCH 2/2] docs(readme): add cross-platform testing overview to Testing section Brief Layer 1 / Layer 2 mapping table showing how this iOS sample's Maestro flows fit into the 4-platform stack and how selector strings couple them across iOS accessibilityIdentifier / Android testTag / Web data-testid. --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 32961d3..f51d9b2 100644 --- a/README.md +++ b/README.md @@ -140,3 +140,20 @@ Each flow is ~10–30 lines of YAML; copy [`flows/01-login-email.yaml`](.maestro/flows/01-login-email.yaml) as a template. See [`.maestro/README.md`](.maestro/README.md) for authoring conventions and how to run flows locally. + +### Cross-platform testing overview + +This iOS sample's Maestro flows are part of a four-platform testing +stack. The same flow YAMLs run against Android via +[`ethora-sample-android/.maestro/`](https://github.com/dappros/ethora-sample-android/tree/main/.maestro) +— selectors resolve by accessibility-id strings that match across +iOS `accessibilityIdentifier`, Android `testTag`, and Web `data-testid`. + +| Layer 1 (hermetic) | Layer 2 (E2E) | +|--------------------|----------------| +| [`ethora-sdk-swift`](https://github.com/dappros/ethora-sdk-swift) — XCTest + `accessibilityIdentifier` markers | `ethora-sample-swift/.maestro/` — 19 flows (this repo) | +| [`ethora-sdk-android`](https://github.com/dappros/ethora-sdk-android) — Compose UI tests | [`ethora-sample-android/.maestro/`](https://github.com/dappros/ethora-sample-android) — same 19 flows on Android emulator | +| [`ethora-chat-component`](https://github.com/dappros/ethora-chat-component) — Vitest + RTL + `data-testid` | [`ethora-app-reactjs/tests/e2e/`](https://github.com/dappros/ethora-app-reactjs) — Playwright | + +A Maestro `id: "chat_input"` resolves the same intent on iOS and +Android. Selectors are 4-repo-coupled — keep them in sync.