From 2c614dbba7723c0dc8432c3845ab8442d65a72e7 Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Mon, 23 Feb 2026 23:46:53 +0530 Subject: [PATCH 01/15] feat: add Docker and Fly.io deployment configuration Add Dockerfile for containerized builds and fly.toml for Fly.io deployment. Update environment examples with required vars for Docker deployment. Update mobile AuthContext with improved error handling and async storage. Update AGENTS.md and building.md with deployment instructions. Files added: - Dockerfile: Multi-stage build for mobile app - fly.toml: Fly.io app configuration --- .env.example | 11 ++- AGENTS.md | 23 ++++++ Dockerfile | 42 ++++++++++ apps/mobile/.env.example | 11 ++- apps/mobile/app.json | 4 +- apps/mobile/context/AuthContext.tsx | 122 +++++++++++++++++----------- apps/mobile/package.json | 1 + building.md | 35 ++++++++ fly.toml | 28 +++++++ package-lock.json | 17 ++++ 10 files changed, 241 insertions(+), 53 deletions(-) create mode 100644 Dockerfile create mode 100644 fly.toml diff --git a/.env.example b/.env.example index 3701516..9bd3186 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -# Workspace environment template. -# For mobile development, copy values into apps/mobile/.env. +# Workspace environment template (single source of truth). +# apps/mobile/.env should point to this file (symlink) in local development. # Required for Google Places search and Android Maps key injection. EXPO_PUBLIC_GOOGLE_PLACES_API_KEY=your_google_places_api_key @@ -8,6 +8,13 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk # Optional: local backend used by mobile auth/sync in development. EXPO_PUBLIC_BOOKMARKS_BACKEND_URL=http://127.0.0.1:8787 +# Optional: Google OAuth client IDs (required for Google sign in). +# Create these in Google Cloud Console OAuth credentials. +EXPO_PUBLIC_GOOGLE_EXPO_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= + # Optional: dev-only test auth controls. EXPO_PUBLIC_BOOKMARKS_DEV_TEST_AUTH=1 EXPO_PUBLIC_BOOKMARKS_DEV_TEST_USER=dev-user diff --git a/AGENTS.md b/AGENTS.md index d61d729..e53d316 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,29 @@ npm test # Run Node integration + runtime smoke tests See `building.md` for full setup instructions. +## Physical Device Development (Android) + +When running against a **physical Android device** (not an emulator), the standard `10.0.2.2` loopback alias does not work. Use ADB reverse proxy so the device can reach the local backend: + +```bash +# 1. Connect device via wireless debugging (Developer options > Wireless debugging) +adb connect : + +# 2. Set up reverse proxy — forwards device's localhost:8787 → dev machine's localhost:8787 +adb reverse tcp:8787 tcp:8787 + +# 3. Start the backend +npm run backend:start + +# 4. In apps/mobile/.env, switch the backend URL to local: +# EXPO_PUBLIC_BOOKMARKS_BACKEND_URL=http://127.0.0.1:8787 + +# 5. Start Metro +npm start +``` + +Re-run `adb reverse tcp:8787 tcp:8787` any time the device reconnects. The reverse tunnel does not persist across ADB daemon restarts. + ## Testing Integration tests exist for schema migrations, CLI/backend runtime smoke checks, and sign-out wipe behavior. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..066b038 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Dockerfile for bookmarks backend deployment on Fly.io +# Uses Node 22 for built-in node:sqlite support. + +FROM node:22-slim + +WORKDIR /app + +# Copy package manifests first for layer caching +COPY package.json package-lock.json* ./ +COPY apps/backend/package.json apps/backend/package.json +COPY apps/mobile/package.json apps/mobile/package.json + +# Install workspace dependencies (production only, skip optional/mobile-heavy deps) +RUN npm install --omit=dev --ignore-scripts 2>/dev/null; \ + npm install tsx --save-dev 2>/dev/null; \ + true + +# Copy source modules needed by the backend +COPY apps/backend/ apps/backend/ +COPY core/ core/ +COPY schema/ schema/ +COPY db/ db/ +COPY auth/ auth/ +COPY http/ http/ +COPY sync/ sync/ +COPY share/ share/ +COPY places/ places/ +COPY collections/ collections/ +COPY preferences/ preferences/ +COPY tsconfig.json tsconfig.base.json ./ + +# Create data directory for SQLite +RUN mkdir -p /data + +ENV NODE_ENV=production +ENV BOOKMARKS_BACKEND_HOST=0.0.0.0 +ENV BOOKMARKS_BACKEND_PORT=8080 +ENV BOOKMARKS_BACKEND_DB_PATH=/data/backend.sqlite + +EXPOSE 8080 + +CMD ["npx", "tsx", "apps/backend/src/index.ts"] diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example index 3b61350..c73fd1d 100644 --- a/apps/mobile/.env.example +++ b/apps/mobile/.env.example @@ -1,4 +1,6 @@ -# Copy this file to apps/mobile/.env and replace placeholders with real values. +# Mobile env template. +# Preferred setup in this repo is to keep values in root .env (single source) +# and symlink apps/mobile/.env -> ../../.env. # Required for Google Places search and Android Maps key injection. EXPO_PUBLIC_GOOGLE_PLACES_API_KEY=your_google_places_api_key @@ -7,6 +9,13 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk # Optional: local backend used by mobile auth/sync in development. EXPO_PUBLIC_BOOKMARKS_BACKEND_URL=http://127.0.0.1:8787 +# Optional: Google OAuth client IDs (required for Google sign in). +# Create these in Google Cloud Console OAuth credentials. +EXPO_PUBLIC_GOOGLE_EXPO_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID= +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= + # Optional: dev-only test auth controls. EXPO_PUBLIC_BOOKMARKS_DEV_TEST_AUTH=1 EXPO_PUBLIC_BOOKMARKS_DEV_TEST_USER=dev-user diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 36289e9..fb1e676 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -5,6 +5,7 @@ "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", + "scheme": "bookmarks", "userInterfaceStyle": "dark", "newArchEnabled": true, "splash": { @@ -40,7 +41,8 @@ "expo-web-browser", "expo-apple-authentication", "expo-sqlite", - "expo-secure-store" + "expo-secure-store", + "@react-native-google-signin/google-signin" ], "extra": { "eas": { diff --git a/apps/mobile/context/AuthContext.tsx b/apps/mobile/context/AuthContext.tsx index 3612521..db165c7 100644 --- a/apps/mobile/context/AuthContext.tsx +++ b/apps/mobile/context/AuthContext.tsx @@ -1,12 +1,15 @@ /** * AuthContext - Manages authentication state. - * Supports Google, Apple, and dev/e2e test-user sign in with SQLite-backed session persistence. + * Supports Google (native), Apple, and dev/e2e test-user sign in with SQLite-backed session persistence. */ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; -import * as Google from 'expo-auth-session/providers/google'; +import { + GoogleSignin, + isErrorWithCode, + statusCodes, +} from '@react-native-google-signin/google-signin'; import * as AppleAuthentication from 'expo-apple-authentication'; -import * as WebBrowser from 'expo-web-browser'; import { createAuthHttpClient } from '../../../auth/src/httpClient'; import { BOOKMARKS_BACKEND_URL } from '../config/backend'; import { buildInsecureTestIdentityToken, e2ePrimaryUserId, isE2eModeEnabled } from '../config/e2e'; @@ -25,7 +28,14 @@ import { import { resetActiveUserId, setActiveUserId } from '../data/runtimeSession'; import type { AuthenticatedUser } from '../data/types'; -WebBrowser.maybeCompleteAuthSession(); +// Configure native Google Sign-In +const googleWebClientId = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID; +const googleAndroidClientId = process.env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID; + +GoogleSignin.configure({ + webClientId: googleWebClientId || undefined, + scopes: ['profile', 'email'], +}); interface AuthContextValue { user: AuthenticatedUser | null; @@ -51,12 +61,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [request, response, promptAsync] = Google.useAuthRequest({ - clientId: 'YOUR_EXPO_CLIENT_ID.apps.googleusercontent.com', - iosClientId: 'YOUR_IOS_CLIENT_ID.apps.googleusercontent.com', - androidClientId: 'YOUR_ANDROID_CLIENT_ID.apps.googleusercontent.com', - webClientId: 'YOUR_WEB_CLIENT_ID.apps.googleusercontent.com', - }); + const hasGoogleClientConfiguration = Boolean(googleAndroidClientId || googleWebClientId); const authClient = createAuthHttpClient({ baseUrl: BOOKMARKS_BACKEND_URL }); const isDevTestAuthDisabled = process.env.EXPO_PUBLIC_BOOKMARKS_DEV_TEST_AUTH === '0'; @@ -65,6 +70,16 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { ? e2ePrimaryUserId : process.env.EXPO_PUBLIC_BOOKMARKS_DEV_TEST_USER || 'dev-user'; + useEffect(() => { + if (hasGoogleClientConfiguration) { + return; + } + + console.warn( + 'Google sign in is not configured. Set EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID (and optionally EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID) in apps/mobile/.env.' + ); + }, [hasGoogleClientConfiguration]); + useEffect(() => { const bootstrapUser = async () => { try { @@ -99,21 +114,6 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { void bootstrapUser(); }, []); - useEffect(() => { - if (response?.type !== 'success') { - return; - } - - const accessToken = response.authentication?.accessToken; - - if (!accessToken) { - console.error('Google auth succeeded but no access token was returned.'); - return; - } - - void fetchGoogleUserInfo(accessToken); - }, [response]); - const exchangeBackendSession = async ( userData: AuthenticatedUser, identityToken: string @@ -157,40 +157,57 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { } }; - const fetchGoogleUserInfo = async (accessToken: string): Promise => { + const signInWithGoogle = async (): Promise => { + if (!hasGoogleClientConfiguration) { + throw new Error('Google sign in is not configured. Missing Google OAuth client IDs.'); + } + try { - const responseValue = await fetch('https://www.googleapis.com/userinfo/v2/me', { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - const userInfo = (await responseValue.json()) as { - id?: string; - email?: string; - name?: string; - picture?: string; - }; + await GoogleSignin.hasPlayServices({ showPlayServicesUpdateDialog: true }); + const signInResult = await GoogleSignin.signIn(); + + const userInfo = signInResult.data; + if (!userInfo || !userInfo.user) { + throw new Error('Google sign in succeeded but no user data was returned.'); + } + + const googleUser = userInfo.user; + + // Get the access token to send to the backend for verification + const tokens = await GoogleSignin.getTokens(); + const accessToken = tokens.accessToken; - if (!userInfo.id) { - throw new Error('Google user info response is missing id.'); + if (!accessToken) { + throw new Error('Google sign in succeeded but no access token was returned.'); } const userData: AuthenticatedUser = { - id: userInfo.id, - email: userInfo.email ?? null, - name: userInfo.name ?? 'Google User', - picture: userInfo.picture ?? null, + id: googleUser.id, + email: googleUser.email ?? null, + name: googleUser.name ?? 'Google User', + picture: googleUser.photo ?? null, provider: 'google', }; await saveUser(userData, accessToken); } catch (error) { - console.error('Error fetching Google user info:', error); - } - }; + if (isErrorWithCode(error)) { + if (error.code === statusCodes.SIGN_IN_CANCELLED) { + console.log('Google sign in canceled'); + return; + } + + if (error.code === statusCodes.IN_PROGRESS) { + console.log('Google sign in already in progress'); + return; + } + + if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) { + console.error('Google Play Services not available or outdated'); + throw new Error('Google Play Services is required for Google sign in.'); + } + } - const signInWithGoogle = async (): Promise => { - try { - await promptAsync(); - } catch (error) { console.error('Google sign in error:', error); throw error; } @@ -297,6 +314,13 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { } } + // Sign out of native Google session if active + try { + await GoogleSignin.signOut(); + } catch { + // Ignore — user may not have signed in with Google + } + await wipeLocalDataOnSignOut(undefined, { wipeDatabase: true }); await clearAuthSession(); resetActiveUserId(); @@ -320,7 +344,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { signInWithApple, signInWithTestUser, signOut, - googleAuthRequest: request, + googleAuthRequest: hasGoogleClientConfiguration ? {} : null, }} > {children} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 99c70e4..581c69a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -12,6 +12,7 @@ "dependencies": { "@expo/metro-runtime": "~6.1.2", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-google-signin/google-signin": "^16.1.1", "@react-native-masked-view/masked-view": "^0.3.2", "@react-navigation/bottom-tabs": "^7.9.1", "@react-navigation/native": "^7.1.27", diff --git a/building.md b/building.md index 7efca34..92cf1f1 100644 --- a/building.md +++ b/building.md @@ -111,6 +111,41 @@ adb reverse tcp:8081 tcp:8081 Then reopen the app. +## Release Build (Physical Device) + +To install a standalone release APK that works without a USB/ADB connection or Metro bundler: + +1. **Temporarily restrict architectures** to your device's ABI to speed up the build and avoid NDK compiler issues on other ABIs. In `apps/mobile/android/gradle.properties`, change: + + ```properties + reactNativeArchitectures=arm64-v8a + ``` + + (Check your device's ABI with `adb shell getprop ro.product.cpu.abi`.) + +2. **Ensure `.env` points to your deployed backend** — the release build embeds env vars at build time, so `EXPO_PUBLIC_BOOKMARKS_BACKEND_URL` must be the production/deployed URL (not `127.0.0.1`). + +3. **Build and install:** + + ```bash + npx expo run:android --variant release --no-bundler + ``` + + This bundles JavaScript via Hermes, builds a release APK signed with the debug keystore, installs it on the connected device, and launches it. + + The resulting APK is at: + ``` + apps/mobile/android/app/build/outputs/apk/release/app-release.apk + ``` + +4. **Restore architectures** in `gradle.properties` for future emulator/debug builds: + + ```properties + reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + ``` + +> **Note:** The release build uses the debug keystore for signing (`android/app/debug.keystore`). For Play Store distribution, generate a dedicated release keystore — see [React Native signed APK docs](https://reactnative.dev/docs/signed-apk-android). + ## Other Run Commands | Command | Description | diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..49bbd27 --- /dev/null +++ b/fly.toml @@ -0,0 +1,28 @@ +# Fly.io configuration for bookmarks backend + +app = 'bookmarks-backend' +primary_region = 'sjc' + +[build] + +[env] + NODE_ENV = 'production' + BOOKMARKS_BACKEND_HOST = '0.0.0.0' + BOOKMARKS_BACKEND_PORT = '8080' + BOOKMARKS_BACKEND_DB_PATH = '/data/backend.sqlite' + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + +[[vm]] + memory = '256mb' + cpu_kind = 'shared' + cpus = 1 + +[mounts] + source = 'bookmarks_data' + destination = '/data' diff --git a/package-lock.json b/package-lock.json index 246ec01..1234ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "dependencies": { "@expo/metro-runtime": "~6.1.2", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-google-signin/google-signin": "^16.1.1", "@react-native-masked-view/masked-view": "^0.3.2", "@react-navigation/bottom-tabs": "^7.9.1", "@react-navigation/native": "^7.1.27", @@ -3250,6 +3251,22 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-google-signin/google-signin": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-16.1.1.tgz", + "integrity": "sha512-lcHBnZ7uvCJiWtGooKOklo/4okqszWvJ0BatW1UaIe+ynmpVpp1lyJkvv1Mj08d39k4soaWuhZVNKjD/RFL34Q==", + "license": "MIT", + "peerDependencies": { + "expo": ">=52.0.40", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", From c9d717d304601ded0ba46d58d9b8c8e5cddc0f7f Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Wed, 25 Feb 2026 21:05:52 +0530 Subject: [PATCH 02/15] feat(e2e): migrate to maestro-runner with parallel execution and ci sharding Replace maestro CLI with maestro-runner to enable parallel test execution and improved reliability. Add CI workflow matrix with 4 shards distributing 8 P0 flows across separate jobs to reduce total runtime. Update all test flows with extended wait conditions and scroll actions for stability in CI environments. Assign unique e2e test users (A through H) to each flow to ensure test isolation and prevent cross-test data conflicts. Add deep linking bookmarks:// scheme support and disable automatic sync during e2e mode to prevent race conditions. Implement retry logic in diagnostics screen snapshot loading. Update mobileE2eAndroid.sh script with auto-emulator start, parallel worker scaling (capped at 4 for local stability), and proper artifact handling per shard. --- .github/workflows/mobile-e2e-android.yml | 72 ++++-- apps/backend/src/e2eStart.ts | 43 ++-- .../android/app/src/main/AndroidManifest.xml | 6 + apps/mobile/config/e2e.ts | 6 + apps/mobile/context/AuthContext.tsx | 27 ++- apps/mobile/index.js | 6 + apps/mobile/screens/DiagnosticsScreen.js | 96 +++++++- building.md | 17 +- maestro/README.md | 31 ++- .../android/helpers/capture-diagnostics.yaml | 4 + .../android/p0/auth-session-restart-sync.yaml | 53 +++++ ...ction-membership-persistence-relaunch.yaml | 68 +++++- .../p0/local-place-persistence-relaunch.yaml | 64 +++++- .../p0/offline-write-online-sync-push.yaml | 56 ++++- .../p0/preferences-persistence-sync.yaml | 80 ++++++- .../android/p0/remote-pull-convergence.yaml | 60 ++++- .../saved-state-derived-removal-relaunch.yaml | 86 +++++++- .../android/p0/signout-wipe-persistence.yaml | 65 +++++- scripts/mobileE2eAndroid.sh | 205 ++++++++++++++++-- 19 files changed, 945 insertions(+), 100 deletions(-) diff --git a/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index a10bab5..09ab661 100644 --- a/.github/workflows/mobile-e2e-android.yml +++ b/.github/workflows/mobile-e2e-android.yml @@ -48,14 +48,20 @@ concurrency: cancel-in-progress: true jobs: - maestro-android: - name: Maestro Android e2e + mobile-e2e-android-sharded: + name: Maestro Runner Android e2e (shard ${{ matrix.shard }}/4) runs-on: ubuntu-latest timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] env: CI: "1" EXPO_NO_INTERACTIVE: "1" EXPO_PUBLIC_GOOGLE_PLACES_API_KEY: ${{ secrets.EXPO_PUBLIC_GOOGLE_PLACES_API_KEY }} + E2E_SHARD: ${{ matrix.shard }} + E2E_ARTIFACT_ROOT: .bookmarks/e2e-artifacts/shard-${{ matrix.shard }} steps: - name: Checkout @@ -92,10 +98,17 @@ jobs: - name: Install dependencies run: npm ci - - name: Install Maestro CLI + - name: Install maestro-runner run: | - curl -Ls "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + curl -fsSL "https://open.devicelab.dev/install/maestro-runner" | bash + + if ! command -v maestro-runner >/dev/null 2>&1 && [ -x "$HOME/.local/bin/maestro-runner" ]; then + export PATH="$HOME/.local/bin:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + fi + + command -v maestro-runner + maestro-runner --version - name: Write mobile e2e env run: | @@ -110,7 +123,7 @@ jobs: JAVA_HOME=${JAVA_HOME} EOF - - name: Run Maestro e2e suite on Android emulator + - name: Run maestro-runner shard on Android emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 @@ -121,7 +134,8 @@ jobs: emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: | set -euo pipefail - mkdir -p .bookmarks + + mkdir -p "${E2E_ARTIFACT_ROOT}" cleanup() { if [ -n "${METRO_PID:-}" ]; then @@ -133,10 +147,7 @@ jobs: } trap cleanup EXIT - adb reverse tcp:8081 tcp:8081 - adb reverse tcp:8787 tcp:8787 - - npm run mobile:e2e:backend:start > .bookmarks/backend-e2e.log 2>&1 & + npm run mobile:e2e:backend:start > "${E2E_ARTIFACT_ROOT}/backend-e2e.log" 2>&1 & BACKEND_PID=$! for attempt in $(seq 1 60); do @@ -152,7 +163,7 @@ jobs: sleep 2 done - npm --workspace @bookmarks/mobile run start -- --dev-client --port 8081 --localhost --non-interactive > .bookmarks/metro.log 2>&1 & + npm --workspace @bookmarks/mobile run start -- --dev-client --port 8081 --localhost --non-interactive > "${E2E_ARTIFACT_ROOT}/metro.log" 2>&1 & METRO_PID=$! for attempt in $(seq 1 90); do @@ -168,21 +179,44 @@ jobs: sleep 2 done + adb reverse tcp:8081 tcp:8081 + adb reverse tcp:8787 tcp:8787 + pushd apps/mobile/android > /dev/null ./gradlew assembleDebug --no-daemon popd > /dev/null - adb install -r apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk - - npm run mobile:e2e:android + APP_APK_PATH="apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk" + adb install -r "${APP_APK_PATH}" + + case "${E2E_SHARD}" in + 1) + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/auth-session-restart-sync" --flatten "maestro/flows/android/p0/auth-session-restart-sync.yaml" + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/collection-membership-persistence-relaunch" --flatten "maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml" + ;; + 2) + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/local-place-persistence-relaunch" --flatten "maestro/flows/android/p0/local-place-persistence-relaunch.yaml" + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/offline-write-online-sync-push" --flatten "maestro/flows/android/p0/offline-write-online-sync-push.yaml" + ;; + 3) + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/preferences-persistence-sync" --flatten "maestro/flows/android/p0/preferences-persistence-sync.yaml" + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/remote-pull-convergence" --flatten "maestro/flows/android/p0/remote-pull-convergence.yaml" + ;; + 4) + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/saved-state-derived-removal-relaunch" --flatten "maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml" + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/signout-wipe-persistence" --flatten "maestro/flows/android/p0/signout-wipe-persistence.yaml" + ;; + *) + echo "Unsupported shard: ${E2E_SHARD}" + exit 1 + ;; + esac - name: Upload e2e artifacts if: always() uses: actions/upload-artifact@v4 with: - name: mobile-e2e-android-artifacts + name: mobile-e2e-android-artifacts-shard-${{ matrix.shard }} if-no-files-found: warn path: | - .bookmarks/e2e-artifacts - .bookmarks/backend-e2e.log - .bookmarks/metro.log + .bookmarks/e2e-artifacts/shard-${{ matrix.shard }} diff --git a/apps/backend/src/e2eStart.ts b/apps/backend/src/e2eStart.ts index 2edfd39..7b173f9 100644 --- a/apps/backend/src/e2eStart.ts +++ b/apps/backend/src/e2eStart.ts @@ -30,31 +30,26 @@ interface SeedUser { preferenceTheme: string; } +const createSeedUser = (suffix: string, latitude: number, longitude: number, preferenceTheme: string): SeedUser => { + const uppercaseSuffix = suffix.toUpperCase(); + + return { + userId: `e2e-user-${suffix}`, + email: `e2e-user-${suffix}@bookmarks.test`, + name: `E2E User ${uppercaseSuffix}`, + seededPlaceId: `e2e-place-${suffix}-1`, + seededCollectionId: `e2e-collection-${suffix}-1`, + latitude, + longitude, + placeName: `Seeded Place ${uppercaseSuffix}`, + collectionName: `Seeded Collection ${uppercaseSuffix}`, + preferenceTheme, + }; +}; + const seededUsers: SeedUser[] = [ - { - userId: 'e2e-user-a', - email: 'e2e-user-a@bookmarks.test', - name: 'E2E User A', - seededPlaceId: 'e2e-place-a-1', - seededCollectionId: 'e2e-collection-a-1', - latitude: 53.3498, - longitude: -6.2603, - placeName: 'Seeded Place A', - collectionName: 'Seeded Collection A', - preferenceTheme: 'basalt', - }, - { - userId: 'e2e-user-b', - email: 'e2e-user-b@bookmarks.test', - name: 'E2E User B', - seededPlaceId: 'e2e-place-b-1', - seededCollectionId: 'e2e-collection-b-1', - latitude: 40.7128, - longitude: -74.006, - placeName: 'Seeded Place B', - collectionName: 'Seeded Collection B', - preferenceTheme: 'stone', - }, + createSeedUser('a', 53.3498, -6.2603, 'basalt'), + createSeedUser('b', 40.7128, -74.006, 'stone'), ]; const seedDatabase = async (databasePath: string): Promise => { diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index c5b3f3f..63d05b4 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,12 @@ + + + + + + \ No newline at end of file diff --git a/apps/mobile/config/e2e.ts b/apps/mobile/config/e2e.ts index 4606b10..5888886 100644 --- a/apps/mobile/config/e2e.ts +++ b/apps/mobile/config/e2e.ts @@ -6,6 +6,12 @@ export const isE2eModeEnabled = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_MODE === ' export const e2ePrimaryUserId = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_USER_A || 'e2e-user-a'; export const e2eSecondaryUserId = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_USER_B || 'e2e-user-b'; +export const e2eTertiaryUserId = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_USER_C || 'e2e-user-c'; +export const e2eQuaternaryUserId = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_USER_D || 'e2e-user-d'; +export const e2eQuinaryUserId = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_USER_E || 'e2e-user-e'; +export const e2eSenaryUserId = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_USER_F || 'e2e-user-f'; +export const e2eSeptenaryUserId = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_USER_G || 'e2e-user-g'; +export const e2eOctonaryUserId = process.env.EXPO_PUBLIC_BOOKMARKS_E2E_USER_H || 'e2e-user-h'; /** * Build an insecure test token accepted by backend only when explicitly enabled. diff --git a/apps/mobile/context/AuthContext.tsx b/apps/mobile/context/AuthContext.tsx index db165c7..56d857b 100644 --- a/apps/mobile/context/AuthContext.tsx +++ b/apps/mobile/context/AuthContext.tsx @@ -95,9 +95,12 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { setActiveUserId(storedUser.id); setUser(storedUser); - void syncAuthenticatedData().catch((error) => { - console.error('Error running initial authenticated sync:', error); - }); + + if (!isE2eModeEnabled) { + void syncAuthenticatedData().catch((error) => { + console.error('Error running initial authenticated sync:', error); + }); + } } else { await clearAuthSession(); resetActiveUserId(); @@ -147,9 +150,11 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { setActiveUserId(authenticatedUser.id); setUser(authenticatedUser); - void syncAuthenticatedData().catch((error) => { - console.error('Error running post-login sync:', error); - }); + if (!isE2eModeEnabled) { + void syncAuthenticatedData().catch((error) => { + console.error('Error running post-login sync:', error); + }); + } } catch (error) { console.error('Error saving user:', error); await clearAuthSession(); @@ -290,10 +295,12 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { setActiveUserId(authenticatedUser.id); setUser(authenticatedUser); - try { - await syncAuthenticatedData(); - } catch (error) { - console.error('Error running test-user sync:', error); + if (!isE2eModeEnabled) { + try { + await syncAuthenticatedData(); + } catch (error) { + console.error('Error running test-user sync:', error); + } } } catch (error) { console.error('Error signing in test user:', error); diff --git a/apps/mobile/index.js b/apps/mobile/index.js index 1d6e981..7fdb877 100644 --- a/apps/mobile/index.js +++ b/apps/mobile/index.js @@ -1,7 +1,13 @@ import { registerRootComponent } from 'expo'; +import { LogBox } from 'react-native'; +import { isE2eModeEnabled } from './config/e2e'; import App from './App'; +if (isE2eModeEnabled) { + LogBox.ignoreAllLogs(true); +} + // registerRootComponent calls AppRegistry.registerComponent('main', () => App); // It also ensures that whether you load the app in Expo Go or in a native build, // the environment is set up appropriately diff --git a/apps/mobile/screens/DiagnosticsScreen.js b/apps/mobile/screens/DiagnosticsScreen.js index e93f7e0..4dfe96f 100644 --- a/apps/mobile/screens/DiagnosticsScreen.js +++ b/apps/mobile/screens/DiagnosticsScreen.js @@ -24,7 +24,16 @@ import { runDiagnosticsSync, wipeDiagnosticsLocalData, } from '../data/diagnostics'; -import { e2ePrimaryUserId, e2eSecondaryUserId } from '../config/e2e'; +import { + e2ePrimaryUserId, + e2eSecondaryUserId, + e2eTertiaryUserId, + e2eQuaternaryUserId, + e2eQuinaryUserId, + e2eSenaryUserId, + e2eSeptenaryUserId, + e2eOctonaryUserId, +} from '../config/e2e'; import { colors } from '../styles/colors'; const DiagnosticsScreen = ({ navigation }) => { @@ -36,11 +45,26 @@ const DiagnosticsScreen = ({ navigation }) => { const loadSnapshot = async () => { setIsLoadingSnapshot(true); + let lastError = null; + try { - const nextSnapshot = await getDiagnosticsSnapshot(); - setSnapshot(nextSnapshot); - } catch (error) { - console.error('Error loading diagnostics snapshot:', error); + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const nextSnapshot = await getDiagnosticsSnapshot(); + setSnapshot(nextSnapshot); + return; + } catch (error) { + lastError = error; + + if (attempt < 3) { + await new Promise((resolve) => { + setTimeout(resolve, 300 * attempt); + }); + } + } + } + + console.error('Error loading diagnostics snapshot:', lastError); Alert.alert('Diagnostics Error', 'Unable to load diagnostics snapshot.'); } finally { setIsLoadingSnapshot(false); @@ -71,6 +95,10 @@ const DiagnosticsScreen = ({ navigation }) => { } }; + const signInForDiagnostics = async (userId) => { + await signInWithTestUser(userId); + }; + const handleBack = () => { navigation.goBack(); }; @@ -172,7 +200,7 @@ const DiagnosticsScreen = ({ navigation }) => { runAction(() => signInWithTestUser(e2ePrimaryUserId))} + onPress={() => runAction(() => signInForDiagnostics(e2ePrimaryUserId))} testID="e2e-diagnostics-action-signin-user-a" disabled={isRunningAction} > @@ -181,13 +209,67 @@ const DiagnosticsScreen = ({ navigation }) => { runAction(() => signInWithTestUser(e2eSecondaryUserId))} + onPress={() => runAction(() => signInForDiagnostics(e2eSecondaryUserId))} testID="e2e-diagnostics-action-signin-user-b" disabled={isRunningAction} > Sign In User B + runAction(() => signInForDiagnostics(e2eTertiaryUserId))} + testID="e2e-diagnostics-action-signin-user-c" + disabled={isRunningAction} + > + Sign In User C + + + runAction(() => signInForDiagnostics(e2eQuaternaryUserId))} + testID="e2e-diagnostics-action-signin-user-d" + disabled={isRunningAction} + > + Sign In User D + + + runAction(() => signInForDiagnostics(e2eQuinaryUserId))} + testID="e2e-diagnostics-action-signin-user-e" + disabled={isRunningAction} + > + Sign In User E + + + runAction(() => signInForDiagnostics(e2eSenaryUserId))} + testID="e2e-diagnostics-action-signin-user-f" + disabled={isRunningAction} + > + Sign In User F + + + runAction(() => signInForDiagnostics(e2eSeptenaryUserId))} + testID="e2e-diagnostics-action-signin-user-g" + disabled={isRunningAction} + > + Sign In User G + + + runAction(() => signInForDiagnostics(e2eOctonaryUserId))} + testID="e2e-diagnostics-action-signin-user-h" + disabled={isRunningAction} + > + Sign In User H + + runAction(signOut)} diff --git a/building.md b/building.md index 92cf1f1..21f2275 100644 --- a/building.md +++ b/building.md @@ -164,7 +164,7 @@ When running the local backend in development (`npm run backend:start`), test au - Optional: set `EXPO_PUBLIC_BOOKMARKS_DEV_TEST_AUTH=0` to hide the dev test-auth button. - Production remains protected: backend rejects test auth when `NODE_ENV=production`. -## Mobile E2E (Maestro, Android) +## Mobile E2E (maestro-runner, Android) 1. Enable e2e mode in `apps/mobile/.env`: @@ -185,7 +185,20 @@ npm run mobile:e2e:backend:start npm run mobile:e2e:android ``` -Artifacts (JUnit, logs, diagnostics capture on failure) are written under: +The runner uses `maestro-runner` with a reliability-first parallelism cap. + +By default, worker count is capped at `4` via `E2E_MAX_PARALLEL` to reduce emulator instability, while still auto-scaling up to available/startable devices. + +Optional overrides: + +```bash +E2E_MAX_PARALLEL=4 npm run mobile:e2e:android # default reliability cap +E2E_PARALLEL=6 npm run mobile:e2e:android # force exact worker count +E2E_WAIT_FOR_IDLE_TIMEOUT=100 npm run mobile:e2e:android +E2E_BOOT_TIMEOUT=300 npm run mobile:e2e:android +``` + +Artifacts (report JSON/HTML/JUnit, logs, diagnostics capture on failure) are written under: ```bash .bookmarks/e2e-artifacts/ diff --git a/maestro/README.md b/maestro/README.md index 9c664c6..5aa8f09 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -1,4 +1,4 @@ -# Mobile E2E (Maestro) +# Mobile E2E (maestro-runner) Android-first persistence coverage lives under `maestro/flows/android/p0`. @@ -16,12 +16,39 @@ npm run mobile:e2e:backend:start npm run mobile:e2e:android ``` -The runner retries once and stores artifacts in: +The runner now uses `maestro-runner` with a reliability-first default parallelism strategy: + +- target parallelism = number of flow files +- capped by available connected devices + startable AVDs +- capped again by `E2E_MAX_PARALLEL` (default `4` for local stability) +- starts additional Android emulators automatically when needed + +Optional overrides: + +```bash +E2E_MAX_PARALLEL=4 npm run mobile:e2e:android # default reliability cap +E2E_PARALLEL=6 npm run mobile:e2e:android # force exact worker count +E2E_WAIT_FOR_IDLE_TIMEOUT=100 npm run mobile:e2e:android +E2E_BOOT_TIMEOUT=300 npm run mobile:e2e:android +``` + +Artifacts (report JSON/HTML/JUnit, logs, diagnostics capture on failure) are written under: ```bash .bookmarks/e2e-artifacts/ ``` +## CI sharding (GitHub Actions) + +Android CI uses workflow-level sharding (4 parallel jobs), with one emulator per shard: + +- shard 1: `auth-session-restart-sync.yaml`, `collection-membership-persistence-relaunch.yaml` +- shard 2: `local-place-persistence-relaunch.yaml`, `offline-write-online-sync-push.yaml` +- shard 3: `preferences-persistence-sync.yaml`, `remote-pull-convergence.yaml` +- shard 4: `saved-state-derived-removal-relaunch.yaml`, `signout-wipe-persistence.yaml` + +Each shard writes artifacts under `.bookmarks/e2e-artifacts/shard-/` and uploads them as shard-specific GitHub Actions artifacts. + ## P0 scenarios - `local-place-persistence-relaunch.yaml` diff --git a/maestro/flows/android/helpers/capture-diagnostics.yaml b/maestro/flows/android/helpers/capture-diagnostics.yaml index e94ee04..a742784 100644 --- a/maestro/flows/android/helpers/capture-diagnostics.yaml +++ b/maestro/flows/android/helpers/capture-diagnostics.yaml @@ -1,6 +1,10 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics - tapOn: id: e2e-diagnostics-action-refresh diff --git a/maestro/flows/android/p0/auth-session-restart-sync.yaml b/maestro/flows/android/p0/auth-session-restart-sync.yaml index bb3ae73..9664862 100644 --- a/maestro/flows/android/p0/auth-session-restart-sync.yaml +++ b/maestro/flows/android/p0/auth-session-restart-sync.yaml @@ -2,22 +2,75 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-a + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-signin-user-a +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-a + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-active-user-id + direction: UP + timeout: 60000 - assertVisible: "activeUserId: e2e-user-a" - assertVisible: "hasAuthSession: true" - stopApp - launchApp +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-active-user-id + direction: UP + timeout: 60000 - assertVisible: "activeUserId: e2e-user-a" - assertVisible: "hasAuthSession: true" +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-run-sync + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-run-sync +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-has-auth-session + direction: UP + timeout: 60000 - assertVisible: "hasAuthSession: true" diff --git a/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml b/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml index e799fb8..ae633a2 100644 --- a/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml +++ b/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml @@ -2,21 +2,79 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-b + direction: DOWN + timeout: 60000 - tapOn: - id: e2e-diagnostics-action-signin-user-a + id: e2e-diagnostics-action-signin-user-b +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-b + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-create-place + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-create-place +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-create-place + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-create-collection-with-place + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-create-collection-with-place +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-create-collection-with-place + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "collections: 2" -- assertVisible: "collection_places: 2" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-collections + direction: UP + timeout: 60000 +- assertVisible: "collections: 1" +- assertVisible: "collection_places: 1" - stopApp - launchApp +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "collections: 2" -- assertVisible: "collection_places: 2" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-collections + direction: UP + timeout: 60000 +- assertVisible: "collections: 1" +- assertVisible: "collection_places: 1" diff --git a/maestro/flows/android/p0/local-place-persistence-relaunch.yaml b/maestro/flows/android/p0/local-place-persistence-relaunch.yaml index e1b4fcb..16bf3f8 100644 --- a/maestro/flows/android/p0/local-place-persistence-relaunch.yaml +++ b/maestro/flows/android/p0/local-place-persistence-relaunch.yaml @@ -2,17 +2,75 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-c + direction: DOWN + timeout: 60000 - tapOn: - id: e2e-diagnostics-action-signin-user-a + id: e2e-diagnostics-action-signin-user-c +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-c + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-create-place + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-create-place +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-create-place + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "places: 2" +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-places + direction: UP + timeout: 60000 +- assertVisible: "places: 1" - stopApp - launchApp +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "places: 2" +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-places + direction: UP + timeout: 60000 +- assertVisible: "places: 1" diff --git a/maestro/flows/android/p0/offline-write-online-sync-push.yaml b/maestro/flows/android/p0/offline-write-online-sync-push.yaml index 8a45b3b..2fd163d 100644 --- a/maestro/flows/android/p0/offline-write-online-sync-push.yaml +++ b/maestro/flows/android/p0/offline-write-online-sync-push.yaml @@ -2,16 +2,70 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-d + direction: DOWN + timeout: 60000 - tapOn: - id: e2e-diagnostics-action-signin-user-a + id: e2e-diagnostics-action-signin-user-d +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-d + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-create-place + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-create-place +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-create-place + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-outbox-pending + direction: UP + timeout: 60000 - assertVisible: "outboxPending: 1" +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-run-sync + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-run-sync +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-run-sync + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-outbox-pending + direction: UP + timeout: 60000 - assertVisible: "outboxPending: 0" diff --git a/maestro/flows/android/p0/preferences-persistence-sync.yaml b/maestro/flows/android/p0/preferences-persistence-sync.yaml index 1009ff0..abf14b1 100644 --- a/maestro/flows/android/p0/preferences-persistence-sync.yaml +++ b/maestro/flows/android/p0/preferences-persistence-sync.yaml @@ -2,24 +2,102 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-e + direction: DOWN + timeout: 60000 - tapOn: - id: e2e-diagnostics-action-signin-user-a + id: e2e-diagnostics-action-signin-user-e +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-e + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-set-theme-stone + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-set-theme-stone +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-set-theme-stone + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-hexagon-theme + direction: UP + timeout: 60000 - assertVisible: "hexagonTheme: stone" - stopApp - launchApp +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-hexagon-theme + direction: UP + timeout: 60000 - assertVisible: "hexagonTheme: stone" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-outbox-pending + direction: UP + timeout: 60000 - assertVisible: "outboxPending: 1" +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-run-sync + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-run-sync +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-run-sync + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-outbox-pending + direction: UP + timeout: 60000 - assertVisible: "outboxPending: 0" +- scrollUntilVisible: + element: + id: e2e-diagnostics-hexagon-theme + direction: UP + timeout: 60000 - assertVisible: "hexagonTheme: stone" diff --git a/maestro/flows/android/p0/remote-pull-convergence.yaml b/maestro/flows/android/p0/remote-pull-convergence.yaml index 2d8a590..9501758 100644 --- a/maestro/flows/android/p0/remote-pull-convergence.yaml +++ b/maestro/flows/android/p0/remote-pull-convergence.yaml @@ -2,16 +2,70 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-f + direction: DOWN + timeout: 60000 - tapOn: - id: e2e-diagnostics-action-signin-user-a + id: e2e-diagnostics-action-signin-user-f +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-f + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "places: 1" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-places + direction: UP + timeout: 60000 +- assertVisible: "places: 0" +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-create-remote-place + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-create-remote-place +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-create-remote-place + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-run-sync + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-run-sync +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-run-sync + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "places: 2" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-places + direction: UP + timeout: 60000 +- assertVisible: "places: 1" diff --git a/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml b/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml index b52d456..1f6886e 100644 --- a/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml +++ b/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml @@ -2,24 +2,102 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-g + direction: DOWN + timeout: 60000 - tapOn: - id: e2e-diagnostics-action-signin-user-a + id: e2e-diagnostics-action-signin-user-g +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-g + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-create-place + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-create-place +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-create-place + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-create-collection-with-place + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-create-collection-with-place +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-create-collection-with-place + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "savedPlaces: 2" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-saved-places + direction: UP + timeout: 60000 +- assertVisible: "savedPlaces: 1" +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-remove-latest-place-memberships + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-remove-latest-place-memberships +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-remove-latest-place-memberships + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "savedPlaces: 1" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-saved-places + direction: UP + timeout: 60000 +- assertVisible: "savedPlaces: 0" - stopApp - launchApp +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh -- assertVisible: "savedPlaces: 1" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-saved-places + direction: UP + timeout: 60000 +- assertVisible: "savedPlaces: 0" diff --git a/maestro/flows/android/p0/signout-wipe-persistence.yaml b/maestro/flows/android/p0/signout-wipe-persistence.yaml index 0d49cc9..b46988b 100644 --- a/maestro/flows/android/p0/signout-wipe-persistence.yaml +++ b/maestro/flows/android/p0/signout-wipe-persistence.yaml @@ -2,23 +2,86 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-h + direction: DOWN + timeout: 60000 - tapOn: - id: e2e-diagnostics-action-signin-user-a + id: e2e-diagnostics-action-signin-user-h +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-h + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-create-place + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-create-place +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-create-place + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signout + direction: DOWN + timeout: 60000 - tapOn: id: e2e-diagnostics-action-signout +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signout + enabled: true + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-active-user-id + direction: UP + timeout: 60000 - assertVisible: "activeUserId: local-guest-user" - assertVisible: "hasAuthSession: false" +- scrollUntilVisible: + element: + id: e2e-diagnostics-count-places + direction: UP + timeout: 60000 - assertVisible: "places: 0" - assertVisible: "collections: 0" - stopApp - launchApp +- extendedWaitUntil: + visible: + id: tab-map + timeout: 60000 - openLink: bookmarks://diagnostics +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-refresh + direction: UP + timeout: 60000 - tapOn: id: e2e-diagnostics-action-refresh +- scrollUntilVisible: + element: + id: e2e-diagnostics-active-user-id + direction: UP + timeout: 60000 - assertVisible: "activeUserId: local-guest-user" - assertVisible: "hasAuthSession: false" diff --git a/scripts/mobileE2eAndroid.sh b/scripts/mobileE2eAndroid.sh index b7cdccf..f83f871 100755 --- a/scripts/mobileE2eAndroid.sh +++ b/scripts/mobileE2eAndroid.sh @@ -2,33 +2,198 @@ set -euo pipefail -FLOW_DIR="${1:-maestro/flows/android/p0}" +FLOW_TARGET="${1:-maestro/flows/android/p0}" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" ARTIFACT_ROOT=".bookmarks/e2e-artifacts/${TIMESTAMP}" mkdir -p "${ARTIFACT_ROOT}" -if ! command -v maestro >/dev/null 2>&1; then - echo "maestro CLI is required but was not found in PATH." >&2 - echo "Install from https://maestro.mobile.dev/ and rerun." >&2 +if ! command -v maestro-runner >/dev/null 2>&1; then + echo "maestro-runner is required but was not found in PATH." >&2 + echo "Install from https://github.com/devicelab-dev/maestro-runner and rerun." >&2 exit 1 fi +EMULATOR_BIN="" +if command -v emulator >/dev/null 2>&1; then + EMULATOR_BIN="$(command -v emulator)" +elif [ -n "${ANDROID_HOME:-}" ] && [ -x "${ANDROID_HOME}/emulator/emulator" ]; then + EMULATOR_BIN="${ANDROID_HOME}/emulator/emulator" +fi + +if [ -z "${ANDROID_AVD_HOME:-}" ] && [ -d "${HOME}/.config/.android/avd" ]; then + export ANDROID_AVD_HOME="${HOME}/.config/.android/avd" +fi + +if [ ! -e "${FLOW_TARGET}" ]; then + echo "Flow target does not exist: ${FLOW_TARGET}" >&2 + exit 1 +fi + +APP_APK_PATH="${E2E_APP_APK_PATH:-apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk}" + +if [ ! -f "${APP_APK_PATH}" ]; then + echo "Android debug APK not found at: ${APP_APK_PATH}" >&2 + echo "Build the app first (for example: cd apps/mobile/android && ./gradlew assembleDebug)" >&2 + exit 1 +fi + +count_flow_files() { + if [ -f "${FLOW_TARGET}" ]; then + echo "1" + return + fi + + find "${FLOW_TARGET}" -type f -name '*.yaml' | wc -l | tr -d ' ' +} + +count_connected_devices() { + if ! command -v adb >/dev/null 2>&1; then + echo "0" + return + fi + + adb devices | awk 'NR > 1 && $2 == "device" { count++ } END { print count + 0 }' +} + +count_running_emulators() { + if ! command -v adb >/dev/null 2>&1; then + echo "0" + return + fi + + adb devices | awk 'NR > 1 && $1 ~ /^emulator-/ && $2 == "device" { count++ } END { print count + 0 }' +} + +count_available_avds() { + if [ -z "${EMULATOR_BIN}" ]; then + echo "0" + return + fi + + "${EMULATOR_BIN}" -list-avds | awk 'NF { count++ } END { print count + 0 }' +} + +resolve_parallelism() { + local flow_count="${1}" + + if [ -n "${E2E_PARALLEL:-}" ]; then + echo "${E2E_PARALLEL}" + return + fi + + local connected_devices + connected_devices="$(count_connected_devices)" + + local running_emulators + running_emulators="$(count_running_emulators)" + + local total_avds + total_avds="$(count_available_avds)" + + local startable_avds=$(( total_avds - running_emulators )) + if [ "${startable_avds}" -lt 0 ]; then + startable_avds=0 + fi + + local max_parallel=$(( connected_devices + startable_avds )) + if [ "${max_parallel}" -lt 1 ]; then + max_parallel=1 + fi + + local reliability_cap="${E2E_MAX_PARALLEL:-4}" + if [ "${reliability_cap}" -lt 1 ]; then + reliability_cap=1 + fi + + if [ "${max_parallel}" -gt "${reliability_cap}" ]; then + max_parallel="${reliability_cap}" + fi + + if [ "${flow_count}" -lt "${max_parallel}" ]; then + echo "${flow_count}" + else + echo "${max_parallel}" + fi +} + +sync_report_aliases() { + if [ -f "${ARTIFACT_ROOT}/junit-report.xml" ]; then + cp "${ARTIFACT_ROOT}/junit-report.xml" "${ARTIFACT_ROOT}/junit.xml" + fi +} + +FLOW_COUNT="$(count_flow_files)" +if [ "${FLOW_COUNT}" -lt 1 ]; then + echo "No .yaml flows found under ${FLOW_TARGET}" >&2 + exit 1 +fi + +PARALLELISM="$(resolve_parallelism "${FLOW_COUNT}")" + +echo "Running Android e2e with maestro-runner" +echo "Flow target: ${FLOW_TARGET}" +echo "Flow count: ${FLOW_COUNT}" +echo "Parallel workers: ${PARALLELISM}" +echo "Emulator binary: ${EMULATOR_BIN:-not-found}" +echo "App APK: ${APP_APK_PATH}" +echo "Artifacts: ${ARTIFACT_ROOT}" + run_suite() { - maestro test "${FLOW_DIR}" \ - --format junit \ - --output "${ARTIFACT_ROOT}/junit.xml" \ - --test-output-dir "${ARTIFACT_ROOT}" + maestro-runner \ + --platform android \ + --auto-start-emulator \ + --boot-timeout "${E2E_BOOT_TIMEOUT:-240}" \ + --app-file "${APP_APK_PATH}" \ + ${CI:+--no-ansi} \ + test \ + --parallel "${PARALLELISM}" \ + --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" \ + --output "${ARTIFACT_ROOT}" \ + --flatten \ + "${FLOW_TARGET}" + + sync_report_aliases + + local report_path="${ARTIFACT_ROOT}/report.json" + if [ -f "${report_path}" ]; then + local status + status="$(python3 - "${report_path}" <<'PY' +import json +import sys +with open(sys.argv[1], 'r', encoding='utf-8') as handle: + report = json.load(handle) +print(report.get('status', 'unknown')) +PY +)" + + if [ "${status}" != "passed" ]; then + echo "maestro-runner reported status=${status}" >&2 + return 1 + fi + fi } capture_failure_artifacts() { if command -v adb >/dev/null 2>&1; then - adb logcat -d > "${ARTIFACT_ROOT}/adb-logcat.txt" || true - fi + mapfile -t devices < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }') - if [ -f "maestro/flows/android/helpers/capture-diagnostics.yaml" ]; then - maestro test "maestro/flows/android/helpers/capture-diagnostics.yaml" \ - --test-output-dir "${ARTIFACT_ROOT}/diagnostics" || true + for device_id in "${devices[@]}"; do + adb -s "${device_id}" logcat -d > "${ARTIFACT_ROOT}/adb-logcat-${device_id}.txt" || true + done + + if [ -f "maestro/flows/android/helpers/capture-diagnostics.yaml" ] && [ "${#devices[@]}" -gt 0 ]; then + maestro-runner \ + --platform android \ + --device "${devices[0]}" \ + --app-file "${APP_APK_PATH}" \ + ${CI:+--no-ansi} \ + test \ + --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" \ + --output "${ARTIFACT_ROOT}/diagnostics" \ + --flatten \ + "maestro/flows/android/helpers/capture-diagnostics.yaml" || true + fi fi } @@ -37,14 +202,18 @@ if run_suite; then exit 0 fi -echo "Mobile e2e suite failed. Retrying once..." -if run_suite; then - echo "Mobile e2e suite passed on retry. Artifacts: ${ARTIFACT_ROOT}" - exit 0 +if [ "${E2E_RETRY_ON_FAILURE:-0}" = "1" ]; then + echo "Mobile e2e suite failed. Retrying once..." + + if run_suite; then + echo "Mobile e2e suite passed on retry. Artifacts: ${ARTIFACT_ROOT}" + exit 0 + fi fi -echo "Mobile e2e suite failed after retry. Collecting failure artifacts..." +echo "Mobile e2e suite failed. Collecting failure artifacts..." capture_failure_artifacts +sync_report_aliases echo "Failure artifacts stored in ${ARTIFACT_ROOT}" exit 1 From e650c97373e30c0ca7061803f2a92ea89741b8bd Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Sat, 28 Feb 2026 15:53:19 +0530 Subject: [PATCH 03/15] ci(android-e2e): improve maestro-runner installation and path detection - Add multiple fallback locations for maestro-runner binary - Check PATH first, then ~/.maestro-runner/bin, then ~/.local/bin - Exit with error if binary not found after installation - Remove redundant command existence check before version output --- .github/workflows/mobile-e2e-android.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index 09ab661..fe68fb0 100644 --- a/.github/workflows/mobile-e2e-android.yml +++ b/.github/workflows/mobile-e2e-android.yml @@ -102,12 +102,19 @@ jobs: run: | curl -fsSL "https://open.devicelab.dev/install/maestro-runner" | bash - if ! command -v maestro-runner >/dev/null 2>&1 && [ -x "$HOME/.local/bin/maestro-runner" ]; then + if command -v maestro-runner >/dev/null 2>&1; then + echo "$(dirname $(command -v maestro-runner))" >> "$GITHUB_PATH" + elif [ -x "$HOME/.maestro-runner/bin/maestro-runner" ]; then + export PATH="$HOME/.maestro-runner/bin:$PATH" + echo "$HOME/.maestro-runner/bin" >> "$GITHUB_PATH" + elif [ -x "$HOME/.local/bin/maestro-runner" ]; then export PATH="$HOME/.local/bin:$PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH" + else + echo "maestro-runner not found after installation!" + exit 1 fi - command -v maestro-runner maestro-runner --version - name: Write mobile e2e env From c3ee4cb9f0694d17f5a555f7669ff8ae5501a534 Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Sat, 28 Feb 2026 16:08:01 +0530 Subject: [PATCH 04/15] ci: add bash shebang to Android E2E script Ensure the shell script in mobile-e2e-android.yml explicitly specifies bash as the interpreter for proper script execution. --- .github/workflows/mobile-e2e-android.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index fe68fb0..f4bdee9 100644 --- a/.github/workflows/mobile-e2e-android.yml +++ b/.github/workflows/mobile-e2e-android.yml @@ -140,6 +140,7 @@ jobs: disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: | + #!/bin/bash set -euo pipefail mkdir -p "${E2E_ARTIFACT_ROOT}" From 30cc1bc90ca49dd34d03a3cd35df8f4194adee91 Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Sat, 28 Feb 2026 16:12:36 +0530 Subject: [PATCH 05/15] ci: wrap E2E script in bash heredoc Fix shell incompatibility by explicitly invoking bash via heredoc. This ensures -o pipefail works on ubuntu-latest runners. --- .github/workflows/mobile-e2e-android.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index f4bdee9..9b17bc4 100644 --- a/.github/workflows/mobile-e2e-android.yml +++ b/.github/workflows/mobile-e2e-android.yml @@ -140,7 +140,7 @@ jobs: disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: | - #!/bin/bash + bash << 'BASH_SCRIPT' set -euo pipefail mkdir -p "${E2E_ARTIFACT_ROOT}" @@ -219,6 +219,7 @@ jobs: exit 1 ;; esac + BASH_SCRIPT - name: Upload e2e artifacts if: always() From d9f16a00ef64e162db9e0916ec8dfc3e9e948ff2 Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Sat, 28 Feb 2026 20:36:09 +0530 Subject: [PATCH 06/15] ci(android-e2e): extract inline script to standalone file Move the inline bash script from the workflow YAML into a separate scripts/mobileE2eAndroidShard.sh file. This improves maintainability by allowing the script to be versioned independently and reused outside the CI context. Update workflow triggers to include the new script file. --- .github/workflows/mobile-e2e-android.yml | 84 +----------------------- scripts/mobileE2eAndroidShard.sh | 80 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 81 deletions(-) create mode 100644 scripts/mobileE2eAndroidShard.sh diff --git a/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index 9b17bc4..69a5aec 100644 --- a/.github/workflows/mobile-e2e-android.yml +++ b/.github/workflows/mobile-e2e-android.yml @@ -16,6 +16,7 @@ on: - preferences/** - schema/** - scripts/mobileE2eAndroid.sh + - scripts/mobileE2eAndroidShard.sh - share/** - sync/** - package.json @@ -37,6 +38,7 @@ on: - preferences/** - schema/** - scripts/mobileE2eAndroid.sh + - scripts/mobileE2eAndroidShard.sh - share/** - sync/** - package.json @@ -139,87 +141,7 @@ jobs: profile: pixel_7 disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: | - bash << 'BASH_SCRIPT' - set -euo pipefail - - mkdir -p "${E2E_ARTIFACT_ROOT}" - - cleanup() { - if [ -n "${METRO_PID:-}" ]; then - kill "${METRO_PID}" >/dev/null 2>&1 || true - fi - if [ -n "${BACKEND_PID:-}" ]; then - kill "${BACKEND_PID}" >/dev/null 2>&1 || true - fi - } - trap cleanup EXIT - - npm run mobile:e2e:backend:start > "${E2E_ARTIFACT_ROOT}/backend-e2e.log" 2>&1 & - BACKEND_PID=$! - - for attempt in $(seq 1 60); do - if curl --fail --silent http://127.0.0.1:8787/health > /dev/null; then - break - fi - - if [ "$attempt" -eq 60 ]; then - echo "Backend failed to become healthy." - exit 1 - fi - - sleep 2 - done - - npm --workspace @bookmarks/mobile run start -- --dev-client --port 8081 --localhost --non-interactive > "${E2E_ARTIFACT_ROOT}/metro.log" 2>&1 & - METRO_PID=$! - - for attempt in $(seq 1 90); do - if curl --silent http://127.0.0.1:8081/status | grep -q "packager-status:running"; then - break - fi - - if [ "$attempt" -eq 90 ]; then - echo "Metro failed to start." - exit 1 - fi - - sleep 2 - done - - adb reverse tcp:8081 tcp:8081 - adb reverse tcp:8787 tcp:8787 - - pushd apps/mobile/android > /dev/null - ./gradlew assembleDebug --no-daemon - popd > /dev/null - - APP_APK_PATH="apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk" - adb install -r "${APP_APK_PATH}" - - case "${E2E_SHARD}" in - 1) - maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/auth-session-restart-sync" --flatten "maestro/flows/android/p0/auth-session-restart-sync.yaml" - maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/collection-membership-persistence-relaunch" --flatten "maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml" - ;; - 2) - maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/local-place-persistence-relaunch" --flatten "maestro/flows/android/p0/local-place-persistence-relaunch.yaml" - maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/offline-write-online-sync-push" --flatten "maestro/flows/android/p0/offline-write-online-sync-push.yaml" - ;; - 3) - maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/preferences-persistence-sync" --flatten "maestro/flows/android/p0/preferences-persistence-sync.yaml" - maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/remote-pull-convergence" --flatten "maestro/flows/android/p0/remote-pull-convergence.yaml" - ;; - 4) - maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/saved-state-derived-removal-relaunch" --flatten "maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml" - maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/signout-wipe-persistence" --flatten "maestro/flows/android/p0/signout-wipe-persistence.yaml" - ;; - *) - echo "Unsupported shard: ${E2E_SHARD}" - exit 1 - ;; - esac - BASH_SCRIPT + script: bash scripts/mobileE2eAndroidShard.sh - name: Upload e2e artifacts if: always() diff --git a/scripts/mobileE2eAndroidShard.sh b/scripts/mobileE2eAndroidShard.sh new file mode 100644 index 0000000..55e9f40 --- /dev/null +++ b/scripts/mobileE2eAndroidShard.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +set -euo pipefail + +mkdir -p "${E2E_ARTIFACT_ROOT}" + +cleanup() { + if [ -n "${METRO_PID:-}" ]; then + kill "${METRO_PID}" >/dev/null 2>&1 || true + fi + if [ -n "${BACKEND_PID:-}" ]; then + kill "${BACKEND_PID}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +npm run mobile:e2e:backend:start > "${E2E_ARTIFACT_ROOT}/backend-e2e.log" 2>&1 & +BACKEND_PID=$! + +for attempt in $(seq 1 60); do + if curl --fail --silent http://127.0.0.1:8787/health > /dev/null; then + break + fi + + if [ "$attempt" -eq 60 ]; then + echo "Backend failed to become healthy." + exit 1 + fi + + sleep 2 +done + +npm --workspace @bookmarks/mobile run start -- --dev-client --port 8081 --localhost --non-interactive > "${E2E_ARTIFACT_ROOT}/metro.log" 2>&1 & +METRO_PID=$! + +for attempt in $(seq 1 90); do + if curl --silent http://127.0.0.1:8081/status | grep -q "packager-status:running"; then + break + fi + + if [ "$attempt" -eq 90 ]; then + echo "Metro failed to start." + exit 1 + fi + + sleep 2 +done + +adb reverse tcp:8081 tcp:8081 +adb reverse tcp:8787 tcp:8787 + +pushd apps/mobile/android > /dev/null +./gradlew assembleDebug --no-daemon +popd > /dev/null + +APP_APK_PATH="apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk" +adb install -r "${APP_APK_PATH}" + +case "${E2E_SHARD}" in + 1) + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/auth-session-restart-sync" --flatten "maestro/flows/android/p0/auth-session-restart-sync.yaml" + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/collection-membership-persistence-relaunch" --flatten "maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml" + ;; + 2) + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/local-place-persistence-relaunch" --flatten "maestro/flows/android/p0/local-place-persistence-relaunch.yaml" + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/offline-write-online-sync-push" --flatten "maestro/flows/android/p0/offline-write-online-sync-push.yaml" + ;; + 3) + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/preferences-persistence-sync" --flatten "maestro/flows/android/p0/preferences-persistence-sync.yaml" + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/remote-pull-convergence" --flatten "maestro/flows/android/p0/remote-pull-convergence.yaml" + ;; + 4) + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/saved-state-derived-removal-relaunch" --flatten "maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml" + maestro-runner --platform android --app-file "${APP_APK_PATH}" ${CI:+--no-ansi} test --wait-for-idle-timeout "${E2E_WAIT_FOR_IDLE_TIMEOUT:-100}" --output "${E2E_ARTIFACT_ROOT}/signout-wipe-persistence" --flatten "maestro/flows/android/p0/signout-wipe-persistence.yaml" + ;; + *) + echo "Unsupported shard: ${E2E_SHARD}" + exit 1 + ;; +esac From edaaa66627a5e5cbafadacc62fe43c02e06715cd Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Sat, 28 Feb 2026 20:53:14 +0530 Subject: [PATCH 07/15] ci(android-e2e): change emulator to pixel_5 Change the Android emulator profile from pixel_7 to pixel_5 and allocate 4096MB of memory to improve test stability and performance in the CI environment. --- .github/workflows/mobile-e2e-android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index 69a5aec..0053e61 100644 --- a/.github/workflows/mobile-e2e-android.yml +++ b/.github/workflows/mobile-e2e-android.yml @@ -138,9 +138,9 @@ jobs: api-level: 34 arch: x86_64 target: google_apis - profile: pixel_7 + profile: pixel_5 disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 4096 script: bash scripts/mobileE2eAndroidShard.sh - name: Upload e2e artifacts From 5211d46164771737eafd8e33350cfdf9a6677522 Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Sat, 28 Feb 2026 21:41:41 +0530 Subject: [PATCH 08/15] ci(android-e2e): improve emulator stability and wait for boot Increase emulator memory from 4GB to 6GB to prevent out-of-memory issues during test execution. Add explicit checks to restart the adb server and wait for `sys.boot_completed` property to ensure the emulator is fully initialized before attempting reverse proxy and test commands. --- .github/workflows/mobile-e2e-android.yml | 2 +- scripts/mobileE2eAndroidShard.sh | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index 0053e61..da88569 100644 --- a/.github/workflows/mobile-e2e-android.yml +++ b/.github/workflows/mobile-e2e-android.yml @@ -140,7 +140,7 @@ jobs: target: google_apis profile: pixel_5 disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 4096 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 6144 script: bash scripts/mobileE2eAndroidShard.sh - name: Upload e2e artifacts diff --git a/scripts/mobileE2eAndroidShard.sh b/scripts/mobileE2eAndroidShard.sh index 55e9f40..9059d74 100644 --- a/scripts/mobileE2eAndroidShard.sh +++ b/scripts/mobileE2eAndroidShard.sh @@ -46,6 +46,13 @@ for attempt in $(seq 1 90); do sleep 2 done +adb kill-server || true +adb start-server +adb devices # confirm emulator online + +# Wait for emulator to boot +timeout 300 bash -c 'until adb shell getprop sys.boot_completed | grep -m 1 "1"; do sleep 5; done' + adb reverse tcp:8081 tcp:8081 adb reverse tcp:8787 tcp:8787 From 06c2a7b7d6e1dfbb14c8c987f7619e0ca8e8c84c Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Tue, 3 Mar 2026 21:53:15 +0530 Subject: [PATCH 09/15] fix(android): stabilize maestro startup in e2e flows - route android e2e flows to diagnostics via deep link before startup assertions - wait for e2e-diagnostics-action-refresh instead of tab-map visibility - align capture-diagnostics helper with the same readiness probe - suppress logbox overlays when EXPO_PUBLIC_BOOKMARKS_E2E_MODE=1 - verify with npm run typecheck and npm test --- apps/mobile/App.js | 6 +++++- maestro/flows/android/helpers/capture-diagnostics.yaml | 4 ++-- maestro/flows/android/p0/auth-session-restart-sync.yaml | 8 ++++---- .../p0/collection-membership-persistence-relaunch.yaml | 8 ++++---- .../android/p0/local-place-persistence-relaunch.yaml | 8 ++++---- .../flows/android/p0/offline-write-online-sync-push.yaml | 4 ++-- .../flows/android/p0/preferences-persistence-sync.yaml | 8 ++++---- maestro/flows/android/p0/remote-pull-convergence.yaml | 4 ++-- .../android/p0/saved-state-derived-removal-relaunch.yaml | 8 ++++---- maestro/flows/android/p0/signout-wipe-persistence.yaml | 8 ++++---- 10 files changed, 35 insertions(+), 31 deletions(-) diff --git a/apps/mobile/App.js b/apps/mobile/App.js index 93e1a65..8f8c7a9 100644 --- a/apps/mobile/App.js +++ b/apps/mobile/App.js @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import { StatusBar } from 'expo-status-bar'; import { NavigationContainer } from '@react-navigation/native'; -import { View, Text, ActivityIndicator, StyleSheet, Platform } from 'react-native'; +import { View, Text, ActivityIndicator, StyleSheet, Platform, LogBox } from 'react-native'; import * as Linking from 'expo-linking'; import AppNavigator from './navigation/AppNavigator'; @@ -15,6 +15,10 @@ import { colors } from './styles/colors'; import { AuthProvider } from './context/AuthContext'; import { isE2eModeEnabled } from './config/e2e'; +if (isE2eModeEnabled) { + LogBox.ignoreAllLogs(true); +} + // Deep linking configuration const prefix = Linking.createURL('/'); diff --git a/maestro/flows/android/helpers/capture-diagnostics.yaml b/maestro/flows/android/helpers/capture-diagnostics.yaml index a742784..3a51f47 100644 --- a/maestro/flows/android/helpers/capture-diagnostics.yaml +++ b/maestro/flows/android/helpers/capture-diagnostics.yaml @@ -1,11 +1,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - tapOn: id: e2e-diagnostics-action-refresh - assertVisible: diff --git a/maestro/flows/android/p0/auth-session-restart-sync.yaml b/maestro/flows/android/p0/auth-session-restart-sync.yaml index 9664862..3c06c2d 100644 --- a/maestro/flows/android/p0/auth-session-restart-sync.yaml +++ b/maestro/flows/android/p0/auth-session-restart-sync.yaml @@ -2,11 +2,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-signin-user-a @@ -35,11 +35,11 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "hasAuthSession: true" - stopApp - launchApp +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-refresh diff --git a/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml b/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml index ae633a2..ad6a081 100644 --- a/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml +++ b/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml @@ -2,11 +2,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-signin-user-b @@ -59,11 +59,11 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "collection_places: 1" - stopApp - launchApp +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-refresh diff --git a/maestro/flows/android/p0/local-place-persistence-relaunch.yaml b/maestro/flows/android/p0/local-place-persistence-relaunch.yaml index 16bf3f8..553cae8 100644 --- a/maestro/flows/android/p0/local-place-persistence-relaunch.yaml +++ b/maestro/flows/android/p0/local-place-persistence-relaunch.yaml @@ -2,11 +2,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-signin-user-c @@ -51,11 +51,11 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "places: 1" - stopApp - launchApp +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-refresh diff --git a/maestro/flows/android/p0/offline-write-online-sync-push.yaml b/maestro/flows/android/p0/offline-write-online-sync-push.yaml index 2fd163d..309d01f 100644 --- a/maestro/flows/android/p0/offline-write-online-sync-push.yaml +++ b/maestro/flows/android/p0/offline-write-online-sync-push.yaml @@ -2,11 +2,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-signin-user-d diff --git a/maestro/flows/android/p0/preferences-persistence-sync.yaml b/maestro/flows/android/p0/preferences-persistence-sync.yaml index abf14b1..208878e 100644 --- a/maestro/flows/android/p0/preferences-persistence-sync.yaml +++ b/maestro/flows/android/p0/preferences-persistence-sync.yaml @@ -2,11 +2,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-signin-user-e @@ -46,11 +46,11 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "hexagonTheme: stone" - stopApp - launchApp +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-refresh diff --git a/maestro/flows/android/p0/remote-pull-convergence.yaml b/maestro/flows/android/p0/remote-pull-convergence.yaml index 9501758..2eb1ede 100644 --- a/maestro/flows/android/p0/remote-pull-convergence.yaml +++ b/maestro/flows/android/p0/remote-pull-convergence.yaml @@ -2,11 +2,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-signin-user-f diff --git a/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml b/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml index 1f6886e..0cd1c00 100644 --- a/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml +++ b/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml @@ -2,11 +2,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-signin-user-g @@ -83,11 +83,11 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "savedPlaces: 0" - stopApp - launchApp +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-refresh diff --git a/maestro/flows/android/p0/signout-wipe-persistence.yaml b/maestro/flows/android/p0/signout-wipe-persistence.yaml index b46988b..8623356 100644 --- a/maestro/flows/android/p0/signout-wipe-persistence.yaml +++ b/maestro/flows/android/p0/signout-wipe-persistence.yaml @@ -2,11 +2,11 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-signin-user-h @@ -66,11 +66,11 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "collections: 0" - stopApp - launchApp +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: - id: tab-map + id: e2e-diagnostics-action-refresh timeout: 60000 -- openLink: bookmarks://diagnostics - scrollUntilVisible: element: id: e2e-diagnostics-action-refresh From 7d51f44c720fcec0c4534f5ea7cde1171e1385ee Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Tue, 3 Mar 2026 22:30:34 +0530 Subject: [PATCH 10/15] fix(android): harden e2e deep-link startup readiness - add e2e-home-root test id on HomeScreen root container - wait for e2e-home-root after every launchApp in android maestro flows - deep-link to diagnostics only after home screen readiness is confirmed - keep diagnostics readiness wait on e2e-diagnostics-action-refresh before actions - update capture-diagnostics helper to follow the same launch readiness sequence --- apps/mobile/screens/HomeScreen.js | 2 +- maestro/flows/android/helpers/capture-diagnostics.yaml | 4 ++++ maestro/flows/android/p0/auth-session-restart-sync.yaml | 8 ++++++++ .../p0/collection-membership-persistence-relaunch.yaml | 8 ++++++++ .../android/p0/local-place-persistence-relaunch.yaml | 8 ++++++++ .../flows/android/p0/offline-write-online-sync-push.yaml | 4 ++++ .../flows/android/p0/preferences-persistence-sync.yaml | 8 ++++++++ maestro/flows/android/p0/remote-pull-convergence.yaml | 4 ++++ .../android/p0/saved-state-derived-removal-relaunch.yaml | 8 ++++++++ maestro/flows/android/p0/signout-wipe-persistence.yaml | 8 ++++++++ 10 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/mobile/screens/HomeScreen.js b/apps/mobile/screens/HomeScreen.js index 5b5052d..c5eda2b 100644 --- a/apps/mobile/screens/HomeScreen.js +++ b/apps/mobile/screens/HomeScreen.js @@ -148,7 +148,7 @@ const HomeScreen = ({ navigation }) => { }; return ( - + Date: Tue, 3 Mar 2026 22:54:24 +0530 Subject: [PATCH 11/15] fix(android): handle launcher anr during e2e startup - add non-collapsable e2e-home-root marker on HomeScreen for reliable selector lookup - dismiss transient system anr popup in maestro flows with optional wait-button tap after launch - avoid adb restart races in shard script and wait for healthy device state before boot checks - disable crash/anr system dialogs via adb global settings during e2e setup - run emulator with no snapshot load/save to reduce stale launcher snapshot instability --- .github/workflows/mobile-e2e-android.yml | 2 +- apps/mobile/screens/HomeScreen.js | 2 +- .../android/helpers/capture-diagnostics.yaml | 3 +++ .../android/p0/auth-session-restart-sync.yaml | 6 ++++++ ...lection-membership-persistence-relaunch.yaml | 6 ++++++ .../p0/local-place-persistence-relaunch.yaml | 6 ++++++ .../p0/offline-write-online-sync-push.yaml | 3 +++ .../p0/preferences-persistence-sync.yaml | 6 ++++++ .../android/p0/remote-pull-convergence.yaml | 3 +++ .../saved-state-derived-removal-relaunch.yaml | 6 ++++++ .../android/p0/signout-wipe-persistence.yaml | 6 ++++++ scripts/mobileE2eAndroidShard.sh | 17 +++++++++++++---- 12 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index da88569..fe37aca 100644 --- a/.github/workflows/mobile-e2e-android.yml +++ b/.github/workflows/mobile-e2e-android.yml @@ -140,7 +140,7 @@ jobs: target: google_apis profile: pixel_5 disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 6144 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 6144 -no-snapshot-load -no-snapshot-save script: bash scripts/mobileE2eAndroidShard.sh - name: Upload e2e artifacts diff --git a/apps/mobile/screens/HomeScreen.js b/apps/mobile/screens/HomeScreen.js index c5eda2b..953b0e7 100644 --- a/apps/mobile/screens/HomeScreen.js +++ b/apps/mobile/screens/HomeScreen.js @@ -148,7 +148,7 @@ const HomeScreen = ({ navigation }) => { }; return ( - + /dev/null | grep -q "device"; do adb reconnect offline >/dev/null 2>&1 || true; sleep 2; done' + +# Wait for emulator boot completion signal +timeout 300 bash -c 'until adb shell getprop sys.boot_completed 2>/dev/null | tr -d "\r" | grep -m 1 "^1$"; do adb reconnect offline >/dev/null 2>&1 || true; sleep 5; done' + +# Suppress system ANR/crash dialogs that can cover the app during CI startup. +adb shell settings put global anr_show_background 0 >/dev/null 2>&1 || true +adb shell settings put global show_first_crash_dialog 0 >/dev/null 2>&1 || true +adb shell settings put global show_first_crash_dialog_dev_option 0 >/dev/null 2>&1 || true +adb shell settings put global show_app_error_dialog 0 >/dev/null 2>&1 || true +adb shell settings put global show_restart_in_crash_dialog 0 >/dev/null 2>&1 || true adb reverse tcp:8081 tcp:8081 adb reverse tcp:8787 tcp:8787 From 262eaab6b4d672e3bca8e5acbd8781ffa5785877 Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Tue, 3 Mar 2026 23:11:03 +0530 Subject: [PATCH 12/15] fix(android): dismiss recurring launcher anr in e2e flows - tap Close app optionally after launchApp to terminate stuck Pixel Launcher process - keep Wait fallback tap for transient launcher anr dialogs - apply launcher-anr dismissal sequence across all android p0 flows and diagnostics helper - set anr/crash suppression flags in secure settings as well as global in shard setup - preserve existing e2e startup ordering and diagnostics deep-link readiness checks --- maestro/flows/android/helpers/capture-diagnostics.yaml | 3 +++ maestro/flows/android/p0/auth-session-restart-sync.yaml | 6 ++++++ .../p0/collection-membership-persistence-relaunch.yaml | 6 ++++++ .../flows/android/p0/local-place-persistence-relaunch.yaml | 6 ++++++ .../flows/android/p0/offline-write-online-sync-push.yaml | 3 +++ maestro/flows/android/p0/preferences-persistence-sync.yaml | 6 ++++++ maestro/flows/android/p0/remote-pull-convergence.yaml | 3 +++ .../android/p0/saved-state-derived-removal-relaunch.yaml | 6 ++++++ maestro/flows/android/p0/signout-wipe-persistence.yaml | 6 ++++++ scripts/mobileE2eAndroidShard.sh | 3 +++ 10 files changed, 48 insertions(+) diff --git a/maestro/flows/android/helpers/capture-diagnostics.yaml b/maestro/flows/android/helpers/capture-diagnostics.yaml index 5333d80..e370738 100644 --- a/maestro/flows/android/helpers/capture-diagnostics.yaml +++ b/maestro/flows/android/helpers/capture-diagnostics.yaml @@ -1,6 +1,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/maestro/flows/android/p0/auth-session-restart-sync.yaml b/maestro/flows/android/p0/auth-session-restart-sync.yaml index 449a5ec..0d8af3e 100644 --- a/maestro/flows/android/p0/auth-session-restart-sync.yaml +++ b/maestro/flows/android/p0/auth-session-restart-sync.yaml @@ -2,6 +2,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true @@ -42,6 +45,9 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "hasAuthSession: true" - stopApp - launchApp +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml b/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml index 0ce5c85..ae770b2 100644 --- a/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml +++ b/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml @@ -2,6 +2,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true @@ -66,6 +69,9 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "collection_places: 1" - stopApp - launchApp +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/maestro/flows/android/p0/local-place-persistence-relaunch.yaml b/maestro/flows/android/p0/local-place-persistence-relaunch.yaml index 13581a3..0bb9fed 100644 --- a/maestro/flows/android/p0/local-place-persistence-relaunch.yaml +++ b/maestro/flows/android/p0/local-place-persistence-relaunch.yaml @@ -2,6 +2,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true @@ -58,6 +61,9 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "places: 1" - stopApp - launchApp +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/maestro/flows/android/p0/offline-write-online-sync-push.yaml b/maestro/flows/android/p0/offline-write-online-sync-push.yaml index 076395f..17c4295 100644 --- a/maestro/flows/android/p0/offline-write-online-sync-push.yaml +++ b/maestro/flows/android/p0/offline-write-online-sync-push.yaml @@ -2,6 +2,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/maestro/flows/android/p0/preferences-persistence-sync.yaml b/maestro/flows/android/p0/preferences-persistence-sync.yaml index 4eede53..fcbc05f 100644 --- a/maestro/flows/android/p0/preferences-persistence-sync.yaml +++ b/maestro/flows/android/p0/preferences-persistence-sync.yaml @@ -2,6 +2,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true @@ -53,6 +56,9 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "hexagonTheme: stone" - stopApp - launchApp +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/maestro/flows/android/p0/remote-pull-convergence.yaml b/maestro/flows/android/p0/remote-pull-convergence.yaml index 558bcc9..a85b321 100644 --- a/maestro/flows/android/p0/remote-pull-convergence.yaml +++ b/maestro/flows/android/p0/remote-pull-convergence.yaml @@ -2,6 +2,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml b/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml index 603291f..9db9a76 100644 --- a/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml +++ b/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml @@ -2,6 +2,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true @@ -90,6 +93,9 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "savedPlaces: 0" - stopApp - launchApp +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/maestro/flows/android/p0/signout-wipe-persistence.yaml b/maestro/flows/android/p0/signout-wipe-persistence.yaml index 407ff6d..1dc9bab 100644 --- a/maestro/flows/android/p0/signout-wipe-persistence.yaml +++ b/maestro/flows/android/p0/signout-wipe-persistence.yaml @@ -2,6 +2,9 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true @@ -73,6 +76,9 @@ appId: com.sumitnarang76.bookmarksmobile - assertVisible: "collections: 0" - stopApp - launchApp +- tapOn: + text: "Close app" + optional: true - tapOn: text: "Wait" optional: true diff --git a/scripts/mobileE2eAndroidShard.sh b/scripts/mobileE2eAndroidShard.sh index cbc6002..9b8c8a3 100644 --- a/scripts/mobileE2eAndroidShard.sh +++ b/scripts/mobileE2eAndroidShard.sh @@ -56,7 +56,10 @@ timeout 300 bash -c 'until adb get-state 2>/dev/null | grep -q "device"; do adb timeout 300 bash -c 'until adb shell getprop sys.boot_completed 2>/dev/null | tr -d "\r" | grep -m 1 "^1$"; do adb reconnect offline >/dev/null 2>&1 || true; sleep 5; done' # Suppress system ANR/crash dialogs that can cover the app during CI startup. +# Some flags live under secure on certain Android builds, so set both secure/global where possible. +adb shell settings put secure anr_show_background 0 >/dev/null 2>&1 || true adb shell settings put global anr_show_background 0 >/dev/null 2>&1 || true +adb shell settings put secure show_first_crash_dialog 0 >/dev/null 2>&1 || true adb shell settings put global show_first_crash_dialog 0 >/dev/null 2>&1 || true adb shell settings put global show_first_crash_dialog_dev_option 0 >/dev/null 2>&1 || true adb shell settings put global show_app_error_dialog 0 >/dev/null 2>&1 || true From a73fe1c64df333c8465667634097096c878cda7e Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Tue, 3 Mar 2026 23:54:47 +0530 Subject: [PATCH 13/15] fix(android): make preference mutation deep-link driven in e2e flow Handle diagnostics deep-link actions for deterministic e2e operations and switch the shard-3 preferences flow to trigger theme mutation via deep link instead of scrolling to a UI button. --- apps/mobile/screens/DiagnosticsScreen.js | 42 ++++++++++++++++++- .../p0/preferences-persistence-sync.yaml | 11 +---- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/mobile/screens/DiagnosticsScreen.js b/apps/mobile/screens/DiagnosticsScreen.js index 4dfe96f..38d191c 100644 --- a/apps/mobile/screens/DiagnosticsScreen.js +++ b/apps/mobile/screens/DiagnosticsScreen.js @@ -2,7 +2,7 @@ * DiagnosticsScreen - Dev/e2e persistence observability and deterministic actions. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, @@ -36,11 +36,12 @@ import { } from '../config/e2e'; import { colors } from '../styles/colors'; -const DiagnosticsScreen = ({ navigation }) => { +const DiagnosticsScreen = ({ navigation, route }) => { const { signInWithTestUser, signOut } = useAuth(); const [snapshot, setSnapshot] = useState(null); const [isLoadingSnapshot, setIsLoadingSnapshot] = useState(true); const [isRunningAction, setIsRunningAction] = useState(false); + const lastHandledRouteActionRef = useRef(null); const loadSnapshot = async () => { setIsLoadingSnapshot(true); @@ -95,6 +96,43 @@ const DiagnosticsScreen = ({ navigation }) => { } }; + const runRouteAction = async (action) => { + switch (action) { + case 'set-theme-stone': + await runAction(() => setDiagnosticsHexagonTheme('stone')); + break; + case 'set-theme-basalt': + await runAction(() => setDiagnosticsHexagonTheme('basalt')); + break; + case 'run-sync': + await runAction(runDiagnosticsSync); + break; + case 'refresh': + await runAction(loadSnapshot); + break; + default: + break; + } + }; + + useEffect(() => { + const action = route?.params?.action; + + if (!action || typeof action !== 'string') { + return; + } + + const nonce = route?.params?.nonce; + const routeActionKey = `${action}:${typeof nonce === 'string' ? nonce : ''}`; + + if (lastHandledRouteActionRef.current === routeActionKey) { + return; + } + + lastHandledRouteActionRef.current = routeActionKey; + void runRouteAction(action); + }, [route?.params?.action, route?.params?.nonce]); + const signInForDiagnostics = async (userId) => { await signInWithTestUser(userId); }; diff --git a/maestro/flows/android/p0/preferences-persistence-sync.yaml b/maestro/flows/android/p0/preferences-persistence-sync.yaml index fcbc05f..ca500c4 100644 --- a/maestro/flows/android/p0/preferences-persistence-sync.yaml +++ b/maestro/flows/android/p0/preferences-persistence-sync.yaml @@ -29,17 +29,10 @@ appId: com.sumitnarang76.bookmarksmobile id: e2e-diagnostics-action-signin-user-e enabled: true timeout: 60000 -- scrollUntilVisible: - element: - id: e2e-diagnostics-action-set-theme-stone - direction: DOWN - timeout: 60000 -- tapOn: - id: e2e-diagnostics-action-set-theme-stone +- openLink: bookmarks://diagnostics?action=set-theme-stone&nonce=preferences-initial - extendedWaitUntil: visible: - id: e2e-diagnostics-action-set-theme-stone - enabled: true + id: e2e-diagnostics-action-refresh timeout: 60000 - scrollUntilVisible: element: From 46495edea414523b332e71ff128a708439bcd6fc Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Wed, 4 Mar 2026 12:58:31 +0530 Subject: [PATCH 14/15] fix(android): stabilize diagnostics deep-link flows in e2e shards - enable diagnostics route registration in debug and e2e builds - filter linking prefixes to avoid malformed deep-link matching - persist diagnostics theme updates via preferences storage to avoid runtime reference errors - add a second diagnostics deep-link open in shard-3 and shard-4 flows for deterministic routing - verify shard 3 and shard 4 maestro runs pass locally --- apps/mobile/App.js | 10 ++++-- apps/mobile/navigation/AppNavigator.js | 4 ++- apps/mobile/screens/DiagnosticsScreen.js | 32 +++++++++++++++++-- .../p0/preferences-persistence-sync.yaml | 2 ++ .../saved-state-derived-removal-relaunch.yaml | 2 ++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/mobile/App.js b/apps/mobile/App.js index 8f8c7a9..b6ce1e4 100644 --- a/apps/mobile/App.js +++ b/apps/mobile/App.js @@ -19,6 +19,8 @@ if (isE2eModeEnabled) { LogBox.ignoreAllLogs(true); } +const isDiagnosticsEnabled = isE2eModeEnabled || __DEV__; + // Deep linking configuration const prefix = Linking.createURL('/'); @@ -30,8 +32,12 @@ const getWebOrigin = () => { return ''; }; +const linkingPrefixes = [prefix, getWebOrigin(), 'https://bookmarks.app', 'bookmarks://'].filter( + (value) => typeof value === 'string' && value.length > 0 +); + const linking = { - prefixes: [prefix, getWebOrigin(), 'https://bookmarks.app', 'bookmarks://'], + prefixes: linkingPrefixes, config: { screens: { SharedCollection: { @@ -40,7 +46,7 @@ const linking = { data: (data) => data, }, }, - ...(isE2eModeEnabled ? { Diagnostics: 'diagnostics' } : {}), + ...(isDiagnosticsEnabled ? { Diagnostics: 'diagnostics' } : {}), Main: { screens: { LibraryTab: { diff --git a/apps/mobile/navigation/AppNavigator.js b/apps/mobile/navigation/AppNavigator.js index 88f1ee7..e0bc73d 100644 --- a/apps/mobile/navigation/AppNavigator.js +++ b/apps/mobile/navigation/AppNavigator.js @@ -31,6 +31,8 @@ import CollectionOptionsModal from '../modals/CollectionOptionsModal'; import EditSavedOptionsModal from '../modals/EditSavedOptionsModal'; import EditPlaceCollectionsModal from '../modals/EditPlaceCollectionsModal'; +const isDiagnosticsEnabled = isE2eModeEnabled || __DEV__; + const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); @@ -132,7 +134,7 @@ const AppNavigator = () => { > - {isE2eModeEnabled && ( + {isDiagnosticsEnabled && ( )} diff --git a/apps/mobile/screens/DiagnosticsScreen.js b/apps/mobile/screens/DiagnosticsScreen.js index 38d191c..1cce906 100644 --- a/apps/mobile/screens/DiagnosticsScreen.js +++ b/apps/mobile/screens/DiagnosticsScreen.js @@ -24,6 +24,7 @@ import { runDiagnosticsSync, wipeDiagnosticsLocalData, } from '../data/diagnostics'; +import { loadHexagonPreferences, saveHexagonPreferences } from '../data/preferencesStorage'; import { e2ePrimaryUserId, e2eSecondaryUserId, @@ -43,6 +44,15 @@ const DiagnosticsScreen = ({ navigation, route }) => { const [isRunningAction, setIsRunningAction] = useState(false); const lastHandledRouteActionRef = useRef(null); + const persistDiagnosticsHexagonTheme = async (theme) => { + const current = await loadHexagonPreferences(); + + await saveHexagonPreferences({ + ...current, + hexagonTheme: theme, + }); + }; + const loadSnapshot = async () => { setIsLoadingSnapshot(true); @@ -99,10 +109,10 @@ const DiagnosticsScreen = ({ navigation, route }) => { const runRouteAction = async (action) => { switch (action) { case 'set-theme-stone': - await runAction(() => setDiagnosticsHexagonTheme('stone')); + await runAction(() => persistDiagnosticsHexagonTheme('stone')); break; case 'set-theme-basalt': - await runAction(() => setDiagnosticsHexagonTheme('basalt')); + await runAction(() => persistDiagnosticsHexagonTheme('basalt')); break; case 'run-sync': await runAction(runDiagnosticsSync); @@ -344,6 +354,24 @@ const DiagnosticsScreen = ({ navigation, route }) => { Remove Latest Place From All Collections + runAction(() => persistDiagnosticsHexagonTheme('stone'))} + testID="e2e-diagnostics-action-set-theme-stone" + disabled={isRunningAction} + > + Set Hexagon Theme: stone + + + runAction(() => persistDiagnosticsHexagonTheme('basalt'))} + testID="e2e-diagnostics-action-set-theme-basalt" + disabled={isRunningAction} + > + Set Hexagon Theme: basalt + + runAction(createDiagnosticsRemotePlaceMutation)} diff --git a/maestro/flows/android/p0/preferences-persistence-sync.yaml b/maestro/flows/android/p0/preferences-persistence-sync.yaml index ca500c4..7552b62 100644 --- a/maestro/flows/android/p0/preferences-persistence-sync.yaml +++ b/maestro/flows/android/p0/preferences-persistence-sync.yaml @@ -13,6 +13,7 @@ appId: com.sumitnarang76.bookmarksmobile id: e2e-home-root timeout: 60000 - openLink: bookmarks://diagnostics +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: id: e2e-diagnostics-action-refresh @@ -60,6 +61,7 @@ appId: com.sumitnarang76.bookmarksmobile id: e2e-home-root timeout: 60000 - openLink: bookmarks://diagnostics +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: id: e2e-diagnostics-action-refresh diff --git a/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml b/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml index 9db9a76..9367d49 100644 --- a/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml +++ b/maestro/flows/android/p0/saved-state-derived-removal-relaunch.yaml @@ -13,6 +13,7 @@ appId: com.sumitnarang76.bookmarksmobile id: e2e-home-root timeout: 60000 - openLink: bookmarks://diagnostics +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: id: e2e-diagnostics-action-refresh @@ -104,6 +105,7 @@ appId: com.sumitnarang76.bookmarksmobile id: e2e-home-root timeout: 60000 - openLink: bookmarks://diagnostics +- openLink: bookmarks://diagnostics - extendedWaitUntil: visible: id: e2e-diagnostics-action-refresh From 0b96c8c072eafbaa3a5b272d4bb22b53745f0018 Mon Sep 17 00:00:00 2001 From: Akash Shakdwipeea Date: Wed, 4 Mar 2026 19:59:54 +0530 Subject: [PATCH 15/15] fix(android): restore diagnostics preference handling after rebase - move diagnostics theme persistence into apps/mobile/data/diagnostics.ts using shared preferences repository APIs - remove DiagnosticsScreen dependency on missing data/preferencesStorage module to fix Metro bundling - include preferences snapshot fields in diagnostics payload for hexagon theme assertions - keep diagnostics route actions and buttons wired to setDiagnosticsHexagonTheme - rerun local checks: typecheck, tests, and all android e2e shards passing --- apps/mobile/data/diagnostics.ts | 32 ++++++++++++++++++++++++ apps/mobile/screens/DiagnosticsScreen.js | 28 ++++++++++----------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/apps/mobile/data/diagnostics.ts b/apps/mobile/data/diagnostics.ts index 03b60b9..00eb6a4 100644 --- a/apps/mobile/data/diagnostics.ts +++ b/apps/mobile/data/diagnostics.ts @@ -3,6 +3,7 @@ */ import { createHttpRequest, readJsonResponse } from '../../../http/src'; +import { getOrCreatePreferences, setPreferences } from '../../../preferences/src'; import { getActiveUserId, resetActiveUserId } from './runtimeSession'; import { clearAuthSession, loadAuthSession } from './authSession'; import { getDatabase } from './database'; @@ -56,6 +57,10 @@ export interface DiagnosticsSnapshot { places: DiagnosticsSyncStateSnapshot; collections: DiagnosticsSyncStateSnapshot; }; + preferences: { + hexagonTheme: string | null; + hexagonVariant: string | null; + }; } const emptySyncStateSnapshot: DiagnosticsSyncStateSnapshot = { @@ -113,6 +118,7 @@ export const getDiagnosticsSnapshot = async (): Promise => outboxPendingCount, syncStateCount, syncStateRows, + preferences, ] = await Promise.all([ readCount(database, 'SELECT COUNT(*) AS count FROM places WHERE user_id = ?;', [userId]), readCount( @@ -147,6 +153,7 @@ export const getDiagnosticsSnapshot = async (): Promise => WHERE user_id = ?;`, [userId] ), + getOrCreatePreferences(database, userId), ]); return { @@ -163,6 +170,10 @@ export const getDiagnosticsSnapshot = async (): Promise => syncState: syncStateCount, }, syncState: mapSyncStateRows(syncStateRows), + preferences: { + hexagonTheme: preferences.hexagonTheme, + hexagonVariant: preferences.hexagonVariant, + }, }; }; @@ -253,6 +264,27 @@ export const removeLatestPlaceFromAllCollections = async (): Promise => return true; }; +/** + * Persist a deterministic preference change for e2e assertions. + * @param theme + */ +export const setDiagnosticsHexagonTheme = async (theme: string): Promise => { + const database = await getDatabase(); + const userId = getActiveUserId(); + const current = await getOrCreatePreferences(database, userId); + + await setPreferences(database, { + userId, + patch: { + hexagonTheme: theme, + hexagonVariant: current.hexagonVariant, + hexagonSize: current.hexagonSize, + hexagonCustomDepth: current.hexagonCustomDepth, + hexagonUseCustomDepth: current.hexagonUseCustomDepth, + }, + }); +}; + /** * Create a backend-only place mutation so the next sync pull has remote changes. * @returns {Promise} diff --git a/apps/mobile/screens/DiagnosticsScreen.js b/apps/mobile/screens/DiagnosticsScreen.js index 1cce906..0e782c2 100644 --- a/apps/mobile/screens/DiagnosticsScreen.js +++ b/apps/mobile/screens/DiagnosticsScreen.js @@ -22,9 +22,9 @@ import { getDiagnosticsSnapshot, removeLatestPlaceFromAllCollections, runDiagnosticsSync, + setDiagnosticsHexagonTheme, wipeDiagnosticsLocalData, } from '../data/diagnostics'; -import { loadHexagonPreferences, saveHexagonPreferences } from '../data/preferencesStorage'; import { e2ePrimaryUserId, e2eSecondaryUserId, @@ -44,15 +44,6 @@ const DiagnosticsScreen = ({ navigation, route }) => { const [isRunningAction, setIsRunningAction] = useState(false); const lastHandledRouteActionRef = useRef(null); - const persistDiagnosticsHexagonTheme = async (theme) => { - const current = await loadHexagonPreferences(); - - await saveHexagonPreferences({ - ...current, - hexagonTheme: theme, - }); - }; - const loadSnapshot = async () => { setIsLoadingSnapshot(true); @@ -109,10 +100,10 @@ const DiagnosticsScreen = ({ navigation, route }) => { const runRouteAction = async (action) => { switch (action) { case 'set-theme-stone': - await runAction(() => persistDiagnosticsHexagonTheme('stone')); + await runAction(() => setDiagnosticsHexagonTheme('stone')); break; case 'set-theme-basalt': - await runAction(() => persistDiagnosticsHexagonTheme('basalt')); + await runAction(() => setDiagnosticsHexagonTheme('basalt')); break; case 'run-sync': await runAction(runDiagnosticsSync); @@ -231,6 +222,15 @@ const DiagnosticsScreen = ({ navigation, route }) => { + + Preferences + + hexagonTheme: {snapshot.preferences.hexagonTheme || 'null'} + + + hexagonVariant: {snapshot.preferences.hexagonVariant || 'null'} + + )} @@ -356,7 +356,7 @@ const DiagnosticsScreen = ({ navigation, route }) => { runAction(() => persistDiagnosticsHexagonTheme('stone'))} + onPress={() => runAction(() => setDiagnosticsHexagonTheme('stone'))} testID="e2e-diagnostics-action-set-theme-stone" disabled={isRunningAction} > @@ -365,7 +365,7 @@ const DiagnosticsScreen = ({ navigation, route }) => { runAction(() => persistDiagnosticsHexagonTheme('basalt'))} + onPress={() => runAction(() => setDiagnosticsHexagonTheme('basalt'))} testID="e2e-diagnostics-action-set-theme-basalt" disabled={isRunningAction} >