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/.github/workflows/mobile-e2e-android.yml b/.github/workflows/mobile-e2e-android.yml index a10bab5..fe37aca 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 @@ -48,14 +50,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 +100,24 @@ 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; 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 + + maestro-runner --version - name: Write mobile e2e env run: | @@ -110,79 +132,22 @@ 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 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 - script: | - set -euo pipefail - mkdir -p .bookmarks - - 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 - - adb reverse tcp:8081 tcp:8081 - adb reverse tcp:8787 tcp:8787 - - npm run mobile:e2e:backend:start > .bookmarks/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 > .bookmarks/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 - - 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 + 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 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/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/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/.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.js b/apps/mobile/App.js index 93e1a65..b6ce1e4 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,12 @@ import { colors } from './styles/colors'; import { AuthProvider } from './context/AuthContext'; import { isE2eModeEnabled } from './config/e2e'; +if (isE2eModeEnabled) { + LogBox.ignoreAllLogs(true); +} + +const isDiagnosticsEnabled = isE2eModeEnabled || __DEV__; + // Deep linking configuration const prefix = Linking.createURL('/'); @@ -26,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: { @@ -36,7 +46,7 @@ const linking = { data: (data) => data, }, }, - ...(isE2eModeEnabled ? { Diagnostics: 'diagnostics' } : {}), + ...(isDiagnosticsEnabled ? { Diagnostics: 'diagnostics' } : {}), Main: { screens: { LibraryTab: { 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/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/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 3612521..56d857b 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 { @@ -80,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(); @@ -99,21 +117,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 @@ -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(); @@ -157,40 +162,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.'); + } - if (!userInfo.id) { - throw new Error('Google user info response is missing id.'); + 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 (!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; } @@ -273,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); @@ -297,6 +321,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 +351,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { signInWithApple, signInWithTestUser, signOut, - googleAuthRequest: request, + googleAuthRequest: hasGoogleClientConfiguration ? {} : null, }} > {children} 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/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/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/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/apps/mobile/screens/DiagnosticsScreen.js b/apps/mobile/screens/DiagnosticsScreen.js index e93f7e0..0e782c2 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, @@ -22,25 +22,51 @@ import { getDiagnosticsSnapshot, removeLatestPlaceFromAllCollections, runDiagnosticsSync, + setDiagnosticsHexagonTheme, 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 }) => { +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); + 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 +97,47 @@ 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); + }; + const handleBack = () => { navigation.goBack(); }; @@ -155,6 +222,15 @@ const DiagnosticsScreen = ({ navigation }) => { + + Preferences + + hexagonTheme: {snapshot.preferences.hexagonTheme || 'null'} + + + hexagonVariant: {snapshot.preferences.hexagonVariant || 'null'} + + )} @@ -172,7 +248,7 @@ const DiagnosticsScreen = ({ navigation }) => { runAction(() => signInWithTestUser(e2ePrimaryUserId))} + onPress={() => runAction(() => signInForDiagnostics(e2ePrimaryUserId))} testID="e2e-diagnostics-action-signin-user-a" disabled={isRunningAction} > @@ -181,13 +257,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)} @@ -224,6 +354,24 @@ const DiagnosticsScreen = ({ navigation }) => { Remove Latest Place From All Collections + runAction(() => setDiagnosticsHexagonTheme('stone'))} + testID="e2e-diagnostics-action-set-theme-stone" + disabled={isRunningAction} + > + Set Hexagon Theme: stone + + + runAction(() => setDiagnosticsHexagonTheme('basalt'))} + testID="e2e-diagnostics-action-set-theme-basalt" + disabled={isRunningAction} + > + Set Hexagon Theme: basalt + + runAction(createDiagnosticsRemotePlaceMutation)} diff --git a/apps/mobile/screens/HomeScreen.js b/apps/mobile/screens/HomeScreen.js index 5b5052d..953b0e7 100644 --- a/apps/mobile/screens/HomeScreen.js +++ b/apps/mobile/screens/HomeScreen.js @@ -148,7 +148,7 @@ const HomeScreen = ({ navigation }) => { }; return ( - + **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 | @@ -129,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`: @@ -150,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/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/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..e370738 100644 --- a/maestro/flows/android/helpers/capture-diagnostics.yaml +++ b/maestro/flows/android/helpers/capture-diagnostics.yaml @@ -1,7 +1,21 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 - 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 bb3ae73..0d8af3e 100644 --- a/maestro/flows/android/p0/auth-session-restart-sync.yaml +++ b/maestro/flows/android/p0/auth-session-restart-sync.yaml @@ -2,22 +2,95 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 +- 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 +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + 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" +- 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..ae770b2 100644 --- a/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml +++ b/maestro/flows/android/p0/collection-membership-persistence-relaunch.yaml @@ -2,21 +2,99 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 +- 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 +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + 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" diff --git a/maestro/flows/android/p0/local-place-persistence-relaunch.yaml b/maestro/flows/android/p0/local-place-persistence-relaunch.yaml index e1b4fcb..0bb9fed 100644 --- a/maestro/flows/android/p0/local-place-persistence-relaunch.yaml +++ b/maestro/flows/android/p0/local-place-persistence-relaunch.yaml @@ -2,17 +2,95 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 +- 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 +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + 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" 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..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,16 +2,80 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 +- 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..7552b62 100644 --- a/maestro/flows/android/p0/preferences-persistence-sync.yaml +++ b/maestro/flows/android/p0/preferences-persistence-sync.yaml @@ -2,24 +2,117 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true -- openLink: bookmarks://diagnostics - tapOn: - id: e2e-diagnostics-action-signin-user-a + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 +- openLink: bookmarks://diagnostics +- openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 +- scrollUntilVisible: + element: + id: e2e-diagnostics-action-signin-user-e + direction: DOWN + timeout: 60000 - tapOn: - id: e2e-diagnostics-action-set-theme-stone + id: e2e-diagnostics-action-signin-user-e +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-signin-user-e + enabled: true + timeout: 60000 +- openLink: bookmarks://diagnostics?action=set-theme-stone&nonce=preferences-initial +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + 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 +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 +- openLink: bookmarks://diagnostics - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + 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" +- 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..a85b321 100644 --- a/maestro/flows/android/p0/remote-pull-convergence.yaml +++ b/maestro/flows/android/p0/remote-pull-convergence.yaml @@ -2,16 +2,80 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 +- 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..9367d49 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,124 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 +- openLink: bookmarks://diagnostics - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 +- 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 +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + 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" diff --git a/maestro/flows/android/p0/signout-wipe-persistence.yaml b/maestro/flows/android/p0/signout-wipe-persistence.yaml index 0d49cc9..1dc9bab 100644 --- a/maestro/flows/android/p0/signout-wipe-persistence.yaml +++ b/maestro/flows/android/p0/signout-wipe-persistence.yaml @@ -2,23 +2,106 @@ appId: com.sumitnarang76.bookmarksmobile --- - launchApp: clearState: true +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + timeout: 60000 +- 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 +- tapOn: + text: "Close app" + optional: true +- tapOn: + text: "Wait" + optional: true +- extendedWaitUntil: + visible: + id: e2e-home-root + timeout: 60000 - openLink: bookmarks://diagnostics +- extendedWaitUntil: + visible: + id: e2e-diagnostics-action-refresh + 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" 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", 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 diff --git a/scripts/mobileE2eAndroidShard.sh b/scripts/mobileE2eAndroidShard.sh new file mode 100644 index 0000000..9b8c8a3 --- /dev/null +++ b/scripts/mobileE2eAndroidShard.sh @@ -0,0 +1,99 @@ +#!/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 devices # confirm emulator online + +# Wait for adb to report a healthy online device without restarting adb server. +# Restarting adb here can race with emulator startup in CI. +timeout 300 bash -c 'until adb get-state 2>/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. +# 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 +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 + +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