e2e tests (Maestro) #
Cross-platform Maestro flows for the mobile app, run against a self-contained
streamplace e2e harness (a local PDS and PLC, a Streamplace node and a
looping test stream). hack/e2e-local.sh android runs them on an emulator,
and so does the android-e2e job in .github/workflows/build.yaml. iOS is not
wired up yet. The web suite (js/e2e-web, hack/e2e-web-local.sh) mirrors
these flows.
Organization and prerequisites #
Use feature/behavior names, not sequence numbers. Add a lowercase kebab-case
flow file to logged-out/ or logged-in/, depending on its required session
state. For example, a new signed-in chat scenario is
logged-in/chat-message-expiry.yaml; its web counterpart is
js/e2e-web/flows/chat-message-expiry.spec.ts. Filenames must be unique across
the mobile suite; do not override the flow's name property.
The runner generates .maestro/artifacts/workspace.yaml using
hack/maestro-config.sh. It discovers scenario files and orders phases:
setup/server-setup.yamlpoints the app at the harness.- Every
logged-out/*.yamlscenario runs. setup/oauth-login.yamlsigns in and verifies persisted chat.- Every
logged-in/*.yamlscenario runs.
Maestro only accepts individual names in executionOrder.flowsOrder, not
phase globs, so that list is generated at runtime, never committed. Adding a
scenario does not require editing a manifest, this README, or another test.
Alphabetical order within each phase is only for reproducible reports, not a
dependency contract. Every scenario must launch/navigate to its own starting
screen and create its own test data; it must not depend on another scenario's
messages or navigation. A flow must leave its phase's session state intact.
Reusable prerequisite flows belong in setup/, outside scenario discovery.
Use hack/e2e-local.sh android rather than running the directory without its
generated config. For a subset, prerequisites are included automatically:
E2E_FLOWS=".maestro/logged-in/chat-profile.yaml" hack/e2e-local.sh android
# Inspect the selected plan without a device:
bash hack/maestro-config.sh .maestro/logged-in/chat-profile.yaml
The organization and generated config are platform-neutral; the current runner
only provisions Android. No native Go Live scenario exists: native builds hide
those controls, and logged-out/tabs.yaml checks they stay hidden.
Chat submission coverage #
logged-in/chat-submit.yaml sends with keyboard Return, then the chat Send
button, then Return again without erasing or tapping the input between messages.
Exact message rows and server history catch a sent draft being retained and
prepended to the next message. The flow is shared by iOS and Android;
oauth-login.spec.ts covers keyboard/button sending and empty fields on Web.
The component regression exercises stale React drafts, final native text,
single submission, the 300-grapheme limit, preservation of the next draft,
composer identity, and text-responder continuity.
On iOS both Send controls transfer focus to a permanently mounted, nonvisible
companion input and back to the composer. That ends editing to commit pending
autocorrection without explicitly dismissing the keyboard. Both inputs use the
same textarea wrapper and keyboard traits, including bottom-sheet integration.
Submission still waits for the composer's final onEndEditing text before
clearing. The companion is excluded from touch and accessibility navigation.
Ordinary blur does not send.
Maestro inputText does not drive UIKit's autocorrection UI. On a real iOS
keyboard with Auto-Correction enabled, type a sentence ending in a misspelling
with a visible correction suggestion, without adding a trailing space. Test the
keyboard Send key and the app's Send button separately. Each must send exactly
one corrected message, leave an empty input, and allow a fresh next message
without the keyboard dropping down and reopening.
Moving focus away without pressing either Send control must not send anything.
Chat timestamp coverage #
logged-in/chat-timezone.yaml sends a unique message, verifies it survives a
relaunch, then reads the actual place.stream.chat.message record from the PDS.
It requires a numeric RFC 3339 offset matching the device, and checks that the
timestamp represents the send instant rather than UTC mislabeled as local time.
The shared flow works on iOS and Android; the Android runner supplies
ACCOUNT_DID, PDS_HTTP_URL (the harness's loopback PDS listener), and
DEVICE_UTC_OFFSET (+HH:MM, read with adb shell date +%z).
Maestro's JavaScript runs on the host, so its own local time zone is not a device
offset oracle. An iOS invocation must supply the same variables using the
simulator's offset (normally the Mac host's).
The Web counterpart, js/e2e-web/flows/chat-timezone.spec.ts, exercises both the
Expo and Vite send paths in Los Angeles, Kathmandu, and UTC.
Profile coverage #
logged-in/chat-profile.yaml taps the signed-in chat author's name, checks that the profile
sheet contains that account's handle and the View Profile action, dismisses it
by swiping its handle down, and opens it again. It then sends another message without
restarting and verifies that message survives a relaunch from server history.
The dismissal uses the same sheet handle gesture on iOS and Android, not Android
Back. Android flattens nested username text, so the flow taps near the start of
the matching message row. The logged-in Playwright flow (oauth-login.spec.ts) covers the matching
open/dismiss/reopen/chat sequence in the Expo web app served by the default harness.
Native dropdowns render through the default @rn-primitives portal host. That
host belongs below the app stores, i18n, branded theme and font providers so
profile content retains their context. The sheet also has its own error boundary:
unexpected content errors show a menu-local fallback with Try again and Dismiss,
rather than replacing the app. The profile flow requires actual profile content
and rejects that fallback.
pnpm --filter @streamplace/components test covers failures in both child
components and menu render callbacks, repeated and successful retries,
dismissal/reopening, and preservation of the surrounding screen's unsent draft.
These focused tests use native presentation adapters; the Maestro flow exercises
the actual native sheet and portal. The component tests also run in
pnpm run check.
Invalid handle coverage #
logged-out/invalid-handle.yaml covers an account whose handle no longer
verifies, while its DID and live stream stay the same. Its onFlowStart hook
runs invalid-handle.js on the host. The script signs in to the harness PDS as
the harness account and moves it to a fresh invalid-<base36>.test handle.
Public resolution can't verify that handle, so when the script asks the node to
com.atproto.identity.refreshIdentity the account, the node must report the
handle.invalid sentinel.
The flow waits up to 60 seconds for the home card to name the account by its
bare DID. The node's live feed caches identities for 30 seconds, and the app
polls it every 3. The flow then checks that the screen shows no sentinel and no
@did: label, opens the stream from that card, and waits for the "Now streaming"
chat system message.
onFlowComplete runs even when the flow or its setup fails. It moves the account
back to ACCOUNT_HANDLE and requires the node to verify that handle again. It
then relaunches the app and waits for the card to show @<handle>, so later
flows see the verified handle.
The flow is shared by iOS and Android. It needs ACCOUNT_DID, ACCOUNT_HANDLE,
ACCOUNT_PASSWORD, PDS_HTTP_URL, and SERVER_HTTP_URL. SERVER_HTTP_URL is
the node's plain-HTTP loopback URL, which is the harness's own SERVER_URL.
Maestro's SERVER_URL is the HTTPS name the app uses, and the host's JavaScript
doesn't trust the harness CA. The harness must accept .test handles on its PDS,
as js/dev-env configures it to. To run the flow alone:
E2E_FLOWS=".maestro/logged-out/invalid-handle.yaml" hack/e2e-local.sh android
The Web counterpart, js/e2e-web/flows/invalid-handle.spec.ts, covers the Expo
and Vite shells with the same fixture.
HTTPS, and logging in #
The app reaches the harness over HTTPS only: release builds refuse cleartext,
and setup/oauth-login.yaml signs in to the harness's own PDS through the real atproto
OAuth flow (the node's OAuth proxy, then the PDS's sign-in and consent pages
in a Chrome Custom Tab). So the harness runs in its HTTPS mode, serving public
names for 127.0.0.1 with a throwaway CA (see pkg/cmd/e2e_https.go), and the
runner sets the emulator up, as root, to reach and trust it:
- a hosts file maps the harness's names, and
plc.directory(the app resolvesdid:plcthere itself), to the host at 10.0.2.2; - the CA goes in the user trust store. Chrome trusts that store; the app only
does when built with
make android-e2e(SP_E2E_BUILDinjs/app/app.config.ts, which also turns off OTA updates so the run tests the JS it was built with). Never ship that build.
Run it locally #
make dev # harness binary
make android-e2e # bin/streamplace-*-android-e2e.apk (needs JDK 17 + ANDROID_HOME)
# boot a Google APIs emulator (Play Store images can't be rooted), e.g.
sdkmanager "system-images;android-34;google_apis;x86_64"
avdmanager create avd -n e2e-api34 -k "system-images;android-34;google_apis;x86_64" -d pixel_6
emulator -avd e2e-api34 -no-window -no-audio -no-snapshot-save &
hack/e2e-local.sh android
The runner starts the harness, installs the APK, prepares the emulator, runs
the flows and tears everything down. Screenshots, maestro's logs and the
report land in .maestro/artifacts/.
The runner runs on the host, not in the build container: the emulator has to reach the harness.
Port 443 #
The harness binds 127.0.0.1:443, which an unprivileged host process can't do by default; if it fails, it says how to allow that until the next reboot. To set a development machine up once instead, redirect loopback 443 to a high port with a systemd unit, and point the harness at that port:
# /etc/systemd/system/loopback-443-redirect.service
[Unit]
Description=Redirect loopback port 443 to 38444 (Streamplace e2e harness, E2E_HTTPS_PORT=38444)
After=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
# -C first so starting it again never stacks a duplicate rule
ExecStart=/bin/sh -c '/usr/sbin/iptables -t nat -C OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 38444 2>/dev/null || /usr/sbin/iptables -t nat -A OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 38444'
ExecStop=/usr/sbin/iptables -t nat -D OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 38444
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now loopback-443-redirect.service
E2E_HTTPS_PORT=38444 hack/e2e-local.sh android
The rule covers the emulator too: its connections to the host (10.0.2.2)
leave from the emulator process on the host's loopback. It only affects
connections to loopback 443, so it can get in the way of anything else that
serves HTTPS on 127.0.0.1:443 on that machine. Only set E2E_HTTPS_PORT when
the redirect is in place: the app and PDS still use https://<host> URLs
with no port.
Upstream-station native flow #
The default single-node plan excludes flows tagged upstream-station. Opt in
with the real atmoq executable built from the pinned, certificate-watcher-fixed
commit in the Web harness prerequisites:
make android-e2e
SP_E2E_ATMOQ_BIN=/path/to/atmoq \
hack/e2e-local.sh android --upstream-station
# Explicit subset, including server setup and real OAuth prerequisites:
SP_E2E_ATMOQ_BIN=/path/to/atmoq \
E2E_FLOWS=.maestro/logged-in/upstream-station.yaml \
hack/e2e-local.sh android --upstream-station
The runner selects the local HTTP SERVER_URL, not the HTTPS upstream station.
It creates an owned adb reverse tcp:<local-port> tcp:<local-port> mapping and
removes it at teardown. Using 10.0.2.2 as the node URL would change the
DPoP-bound issuer/callback, so the app keeps http://127.0.0.1:<local-port>.
The E2E-only network security plugin allows cleartext solely for 127.0.0.1
and localhost; ordinary releases and all other E2E destinations remain
HTTPS-only. The station/PDS still use genuine TLS and the installed throwaway
CA. The harness prints local/station/MoQ URLs, and uses actual per-DID QUIC
subscriptions rather than a mock firehose.
setup/oauth-login.yaml completes the local → HTTPS station → PDS consent
chain. Upstream mode defers fixture go-live until this login, so its first-card
wait allows 60 seconds for initial segments and the existing 30-second feed-cache
TTL; standalone setup has already established go-live and retains its 30-second
wait. logged-in/upstream-station.yaml performs an authenticated chat write,
kills/relaunches the app, checks indexed history rather than an optimistic
bubble, and writes again with the restored OAuth session. It is platform-neutral
and uses the same semantic selectors on Android and iOS. Browser coverage adds
transport audits, renewal, the actual Go-node restart and final-user revocation
checks; native coverage is login/write/app-relaunch, not a claim those lower-level
assertions were run on a device.
iOS Simulator manual recipe (macOS required) #
The Linux runner cannot provision an iOS Simulator. On a macOS development machine, build/install the current app on a booted simulator, pre-grant its notification permission, and start the same harness from the checkout root:
# Keep this shell alive; its EXIT trap owns the harness and the ephemeral CA.
export SP_E2E_UPSTREAM_STATION=true
export SP_E2E_ATMOQ_BIN=/path/to/atmoq
export SP_E2E_APP_BUNDLE_ID=<installed-app-bundle-id>
. hack/lib/e2e-harness.sh
e2e_harness_start
xcrun simctl keychain booted add-root-cert "$E2E_TLS_CA"
SP_E2E_UPSTREAM_STATION=true bash hack/maestro-config.sh \
.maestro/logged-in/upstream-station.yaml > /tmp/streamplace-ios-upstream.yaml
maestro --device <booted-simulator-udid> test \
--config /tmp/streamplace-ios-upstream.yaml \
-e APP_ID="$SP_E2E_APP_BUNDLE_ID" -e SERVER_URL="$SERVER_URL" \
-e ACCOUNT_HANDLE="$ACCOUNT_HANDLE" -e ACCOUNT_PASSWORD="$ACCOUNT_PASSWORD" \
-e E2E_UPSTREAM_STATION=true .maestro
Simulator networking must also resolve the harness's plc.directory to this
run's local PLC TLS front end, not the public directory: arrange a temporary
local DNS/hosts override or a simulator CONNECT proxy using E2E_PROXY_URL,
and remove only that override afterward. The station/PDS/account DNS names
already need to resolve to host loopback as described above. Trust the CA in
the simulator, not the macOS system keychain. Confirm the installed build's
generated Info.plist permits local-network transport with
NSAllowsLocalNetworking=true; do not enable blanket arbitrary HTTP loads.
Remove the temporary workspace file and the simulator's test CA after the run.
These are execution prerequisites, not
evidence that an iOS run has occurred.
Notes / gotchas #
- Other adb devices: maestro lists no Android devices at all ("Device
emulator-5554 was requested, but it is not connected") while any device
adb knows about is
unauthorized, such as a phone plugged in over USB that hasn't allowed this computer. Authorize or unplug it, or restart the adb server so it leaves USB devices alone:adb kill-server && adb --one-device emulator-5554 start-server. - Android dialogs: the runner turns off ANR/crash dialogs ("Pixel Launcher isn't responding") and the stylus handwriting tutorial, both of which eat taps.
- Signing: every build signs with its own throwaway keystore, so the runner uninstalls any earlier copy before installing.
- iOS notification dialog: the first-launch "would like to send you
notifications" prompt dims the whole screen and blocks taps. It only shows
on a fresh install; pre-grant the permission with
applesimutilsand avoidclearState(which reinstalls and re-triggers it). - iOS accessibility collapse: iOS merges tappable containers (the settings
toggle, the stream cards, the login modal) into a single accessibility
element, so those are matched by
testIDor substring regex, not exact text.