round 2 android e2e fixes: bundled media asset, report dialog deps, flow guards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-19 15:59:45 +03:00
parent bc72ad4733
commit 0c0c577374
10 changed files with 157 additions and 90 deletions
-10
View File
@@ -153,16 +153,6 @@ if [[ "$platform" == "android" ]]; then
phase "Configuring Android localhost routing"
adb -s "$device_id" reverse tcp:3000 tcp:3000
adb -s "$device_id" reverse tcp:8081 tcp:8081
# The e2e media picker mock reads a seeded photo from the app's external
# files directory (see src/lib/media/picker.e2e.tsx). The app must already
# be installed (the install step runs before this script), so the directory
# exists and is owned by the app; sdcardfs makes it world-readable so the
# mock can read the file without any runtime media permission.
phase "Seeding e2e media"
e2e_media_dir="/sdcard/Android/data/xyz.blueskyweb.app/files/e2e"
adb -s "$device_id" shell mkdir -p "$e2e_media_dir"
adb -s "$device_id" push assets/images/welcome-modal-bg.jpg "$e2e_media_dir/test.jpg"
fi
phase "Running Maestro flows"
+16
View File
@@ -44,6 +44,12 @@ appId: xyz.blueskyweb.app
id: "e2eRefreshHome"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing. Tapping replyBtn right
# after the previous publish can race the closing composer on Android.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply text only"
- tapOn:
id: "composerPublishBtn"
@@ -51,6 +57,11 @@ appId: xyz.blueskyweb.app
id: "composeFAB"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply with an image"
- tapOn:
id: "openMediaBtn"
@@ -63,6 +74,11 @@ appId: xyz.blueskyweb.app
id: "composeFAB"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply with a https://example.com link card"
- tapOn:
id: "composerPublishBtn"
+54 -59
View File
@@ -29,86 +29,81 @@ appId: xyz.blueskyweb.app
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
# Set alice-favs first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
# The id+direction swipe doesn't register the RNGH pan on Android, so we
# use an explicit coordinate swipe that travels several row heights. iOS
# keeps the id-based swipe (its screen aspect ratio differs).
# Reordering feeds is driven by a drag on the feed-drag-handle. Maestro cannot
# activate the RNGH Pan gesture from a synthetic swipe on Android (proven
# twice - coordinate swipes never register the pan), so the reorder
# verification below runs on iOS only. If Android drag coverage is needed,
# revisit with the SavedFeedsA11y move buttons rather than a swipe.
- runFlow:
when:
platform: iOS
commands:
# Set alice-favs first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- runFlow:
when:
platform: Android
commands:
- swipe:
label: "Drag feed down"
start: "93%,31%"
end: "93%,45%"
duration: 1500
- assertVisible:
id: "saveChangesBtn"
enabled: true
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "Following"
- assertVisible:
id: "saveChangesBtn"
enabled: true
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "Following"
# Set following first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- runFlow:
when:
platform: iOS
commands:
# Set following first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- assertVisible:
id: "saveChangesBtn"
enabled: true
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "Following"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
# On Android, the reorder path above is skipped. Smoke-test that the feeds
# edit screen opens and the pinned feeds render, then return to a valid state.
- runFlow:
when:
platform: Android
commands:
- swipe:
label: "Drag feed down"
start: "93%,31%"
end: "93%,45%"
duration: 1500
- assertVisible:
id: "saveChangesBtn"
enabled: true
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "Following"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- assertVisible: "Following"
- assertVisible: "alice-favs"
- tapOn: "Go back"
# Remove following
- tapOn: "Open drawer menu"
+7 -2
View File
@@ -15,8 +15,13 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eStartOnboarding"
- tapOn: "Open avatar creator"
- extendedWaitUntil:
visible: "Select an emoji"
# The avatar-creator bottom sheet opens as a sliver on the short E2E emulator
# (720x1600), so the picker content sits below the fold. Scroll it into view
# instead of just waiting. No-op on iOS, where it is already visible.
- scrollUntilVisible:
element:
text: "Select an emoji"
direction: DOWN
timeout: 15000
- tapOn: "Select the zap emoji as your avatar"
- tapOn:
+4 -1
View File
@@ -64,7 +64,10 @@ appId: xyz.blueskyweb.app
id: "editProfileSaveBtn"
- assertNotVisible:
id: "editProfileModal"
- assertVisible: "alice.test"
# The display-name node renders the handle as a Text with a nested badge View
# once the display name is cleared, so the a11y text is not the bare handle
# string on Android. Match it as a substring instead.
- assertVisible: ".*alice\\.test.*"
- assertNotVisible: "One cool hacker"
# Set avi and banner via the edit profile modal
+38
View File
@@ -20,6 +20,12 @@ appId: xyz.blueskyweb.app
- inputText: "Test thread"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to close and the home feed to settle before signing
# out. Without a settle guard the next action can race the closing composer.
- extendedWaitUntil:
visible:
id: "composeFAB"
timeout: 10000
# Login, reply to the thread, and log out
- tapOn:
@@ -31,9 +37,19 @@ appId: xyz.blueskyweb.app
id: "viewHeaderHomeFeedPrefsBtn"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 1"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to close before signing out.
- extendedWaitUntil:
visible:
id: "composeFAB"
timeout: 10000
# Login, confirm notification exists, mute thread, and log out
- tapOn:
@@ -67,14 +83,36 @@ appId: xyz.blueskyweb.app
id: "profilePager-selector-1"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 2"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to close and the thread reply button to return before
# opening the composer again. Previously this raced and the second replyBtn tap
# landed before the composer was ready, so the composer never opened.
- extendedWaitUntil:
visible:
id: "replyBtn"
timeout: 10000
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 3"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to close and the thread to settle before signing out.
- extendedWaitUntil:
visible:
id: "replyBtn"
timeout: 10000
# Login, confirm notifications dont exist, unmute the thread, ~~confirm notifications exist~~
# Mute thread behaviour no longer change old notifications after muting/unmuting a thread -sfn
+1
View File
@@ -161,6 +161,7 @@
"expo": "54.0.34",
"expo-age-range": "0.2.18",
"expo-application": "~7.0.8",
"expo-asset": "~12.0.13",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10",
+3
View File
@@ -436,6 +436,9 @@ importers:
expo-application:
specifier: ~7.0.8
version: 7.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
expo-asset:
specifier: ~12.0.13
version: 12.0.13(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-blur:
specifier: ~15.0.8
version: 15.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -249,7 +249,23 @@ function Inner(props: ReportDialogProps) {
} finally {
setIsPending(false)
}
}, [logger, submitReport, props, state, ax, l])
/*
* Depend on the specific props members used, not the whole props object.
* props is a new object every render, so depending on it recreates this
* callback each render, which on Android tears down the pending
* setTimeout(close) before it fires and the dialog never closes on the
* fast submit path (APP-1999).
*/
}, [
logger,
submitReport,
props.subject,
props.control,
props.onAfterSubmit,
state,
ax,
l,
])
useCallOnce(() => {
ax.metric('reportDialog:open', {
+17 -17
View File
@@ -1,3 +1,4 @@
import {Asset} from 'expo-asset'
import {
documentDirectory,
getInfoAsync,
@@ -13,17 +14,6 @@ import {IS_ANDROID} from '#/env'
import {compressIfNeeded} from './manip'
import {type PickerImage} from './picker.shared'
/*
* The Android emulator can't reach the iOS simulator's sample photo library,
* so run-nightly-e2e.sh seeds a single jpg into the app's external files
* directory before the flows run. That directory is world-readable via the
* emulator's sdcardfs, so expo-file-system can read it without any runtime
* media permission (unlike /sdcard/DCIM, which is gated behind scoped storage
* on target SDK 35).
*/
const ANDROID_E2E_MEDIA_DIR =
'file:///sdcard/Android/data/xyz.blueskyweb.app/files/e2e'
async function getFile() {
if (IS_ANDROID) {
return await getAndroidFile()
@@ -57,25 +47,35 @@ async function getFile() {
)
}
/*
* The Android emulator can't reach the iOS simulator's sample photo library,
* so we load a jpg bundled with the app instead. It is bundled via require()
* (resolved by Metro), so it survives `pm clear`, which Maestro's clearState
* runs at the start of every flow. An adb-seeded file in app-scoped external
* storage does not survive: pm clear wipes that directory each flow, so the
* seeded file is gone before the picker mock ever reads it.
*/
async function getAndroidFile() {
let files = await readDirectoryAsync(ANDROID_E2E_MEDIA_DIR)
files = files.filter(file => file.toLowerCase().endsWith('.jpg'))
const file = `${ANDROID_E2E_MEDIA_DIR}/${files[0]}`
const asset = Asset.fromModule(
require('../../../assets/images/welcome-modal-bg.jpg'),
)
await asset.downloadAsync()
const fileInfo = await getInfoAsync(file)
const path = asset.localUri!
const fileInfo = await getInfoAsync(path)
if (!fileInfo.exists) {
throw new Error('Failed to get file info')
}
/*
* Dimensions of the seeded asset (assets/images/welcome-modal-bg.jpg). Only
* Dimensions of the bundled asset (assets/images/welcome-modal-bg.jpg). Only
* used for downstream aspect-ratio display; the actual bytes are read from
* disk by compressIfNeeded.
*/
return await compressIfNeeded(
{
path: file,
path,
mime: 'image/jpeg',
size: fileInfo.size,
width: 1432,