# ABOUTME: CI workflow for Pierre Mobile app (React Native/Expo)
# ABOUTME: Runs unit/integration tests on push/PR; Maestro E2E tests nightly at midnight EDT
name: Mobile Tests
on:
push:
branches: [ "main", "debug/*", "feature/*", "claude/*", "copilot/*" ]
paths:
- 'frontend-mobile/**'
- 'packages/**'
- 'src/routes/auth/**'
- 'src/routes/coaches.rs'
- 'src/routes/chat.rs'
- 'src/models/**'
- '.github/workflows/mobile-tests.yml'
pull_request:
branches: [ main ]
paths:
- 'frontend-mobile/**'
- 'packages/**'
- 'src/routes/auth/**'
- 'src/routes/coaches.rs'
- 'src/routes/chat.rs'
- 'src/models/**'
schedule:
# Midnight EDT (04:00 UTC). During EST (Nov-Mar) this runs at 11pm ET.
- cron: '0 4 * * *'
workflow_dispatch: # Allow manual triggering
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Security: Explicit permissions following principle of least privilege
permissions:
contents: read
jobs:
mobile-unit-tests:
name: Mobile Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.4"
- name: Install shared-types dependencies
working-directory: packages/shared-types
run: bun install
- name: Install api-client dependencies
working-directory: packages/api-client
run: bun install
- name: Install mobile dependencies
working-directory: frontend-mobile
run: bun install --frozen-lockfile
- name: Type check
working-directory: frontend-mobile
run: bun run typecheck
- name: Run unit tests
working-directory: frontend-mobile
run: bun run test -- --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: mobile-coverage
path: frontend-mobile/coverage/
retention-days: 7
# Build the Rust server once on Linux, shared by integration tests and Android E2E
build-server-linux:
name: Build Server (Linux)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@1.92.0
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-mobile-server-1.92.0-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-mobile-server-1.92.0-
${{ runner.os }}-cargo-
- name: Build Pierre server (release)
run: cargo build --release
env:
CARGO_INCREMENTAL: 0
- name: Upload server binaries
uses: actions/upload-artifact@v4
with:
name: pierre-server-linux
path: |
target/release/pierre-mcp-server
target/release/pierre-cli
retention-days: 1
# Build the Rust server once on macOS for iOS E2E tests
build-server-macos:
name: Build Server (macOS)
runs-on: macos-latest
# Only needed when E2E tests will run (nightly schedule or manual trigger)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@1.92.0
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-mobile-server-1.92.0-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-mobile-server-1.92.0-
${{ runner.os }}-cargo-
- name: Build Pierre server (release)
run: cargo build --release
env:
CARGO_INCREMENTAL: 0
- name: Upload server binaries
uses: actions/upload-artifact@v4
with:
name: pierre-server-macos
path: |
target/release/pierre-mcp-server
target/release/pierre-cli
retention-days: 1
mobile-integration-tests:
name: Mobile Integration Tests (Real Server)
needs: build-server-linux
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download server binaries
uses: actions/download-artifact@v4
with:
name: pierre-server-linux
path: target/release/
- name: Make binaries executable
run: chmod +x target/release/pierre-mcp-server target/release/pierre-cli
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.4"
- name: Install shared-types dependencies
working-directory: packages/shared-types
run: bun install
- name: Install api-client dependencies
working-directory: packages/api-client
run: bun install
- name: Install mobile dependencies
working-directory: frontend-mobile
run: bun install --frozen-lockfile
- name: Create data directory
run: mkdir -p data
- name: Start Pierre server
run: |
./target/release/pierre-mcp-server &
echo "Waiting for server to start..."
for i in {1..30}; do
if curl -s http://localhost:8081/health > /dev/null; then
echo "Server is healthy!"
break
fi
echo "Attempt $i: Server not ready yet..."
sleep 2
done
env:
DATABASE_URL: "sqlite:${{ github.workspace }}/data/mobile-integration-test.db"
PIERRE_MASTER_ENCRYPTION_KEY: "rEFe91l6lqLahoyl9OSzum9dKa40VvV5RYj8bHGNTeo="
PIERRE_RSA_KEY_SIZE: "2048"
HTTP_PORT: "8081"
RUST_LOG: "warn"
STRAVA_CLIENT_ID: "test_client_id_ci"
STRAVA_CLIENT_SECRET: "test_client_secret_ci"
STRAVA_REDIRECT_URI: "http://localhost:8081/auth/strava/callback"
- name: Run mobile integration tests
working-directory: frontend-mobile
run: bun run e2e:integration
env:
CI: true
BACKEND_URL: "http://localhost:8081"
- name: Upload integration test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: mobile-integration-test-results
path: frontend-mobile/integration/
retention-days: 7
mobile-e2e-maestro:
name: Mobile E2E Tests (Maestro - iOS/Expo Go)
needs: build-server-macos
runs-on: macos-latest
# Run only on nightly schedule (midnight EDT) or manual trigger
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
timeout-minutes: 90
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download server binaries
uses: actions/download-artifact@v4
with:
name: pierre-server-macos
path: target/release/
- name: Make binaries executable
run: chmod +x target/release/pierre-mcp-server target/release/pierre-cli
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.4"
- name: Install shared-types dependencies
working-directory: packages/shared-types
run: bun install
- name: Install api-client dependencies
working-directory: packages/api-client
run: bun install
- name: Remove expo-dev-client for E2E build
working-directory: frontend-mobile
run: |
# Remove expo-dev-client from package.json BEFORE install
# This package interferes with Maestro testing - its native code
# tries to connect to a dev server that doesn't exist in CI
cat package.json | jq 'del(.dependencies["expo-dev-client"])' > package.json.tmp
mv package.json.tmp package.json
- name: Install mobile dependencies
working-directory: frontend-mobile
run: bun install
- name: Install Maestro CLI
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- name: Create data directory
run: mkdir -p data
- name: Start Pierre server
run: |
./target/release/pierre-mcp-server > /tmp/pierre-server.log 2>&1 &
echo "Waiting for server to start..."
for i in {1..30}; do
if curl -s http://localhost:8081/health > /dev/null; then
echo "Server is healthy!"
break
fi
echo "Attempt $i: Server not ready yet..."
sleep 2
done
env:
DATABASE_URL: "sqlite:${{ github.workspace }}/data/maestro-test.db"
PIERRE_MASTER_ENCRYPTION_KEY: "rEFe91l6lqLahoyl9OSzum9dKa40VvV5RYj8bHGNTeo="
PIERRE_RSA_KEY_SIZE: "2048"
HTTP_PORT: "8081"
RUST_LOG: "info"
STRAVA_CLIENT_ID: "test_client_id_ci"
STRAVA_CLIENT_SECRET: "test_client_secret_ci"
STRAVA_REDIRECT_URI: "http://localhost:8081/auth/strava/callback"
- name: Seed test user for Maestro tests
run: |
./target/release/pierre-cli user create --email mobiletest@pierre.dev --password MobileTest1234 --force
env:
DATABASE_URL: "sqlite:${{ github.workspace }}/data/maestro-test.db"
PIERRE_MASTER_ENCRYPTION_KEY: "rEFe91l6lqLahoyl9OSzum9dKa40VvV5RYj8bHGNTeo="
RUST_LOG: "warn"
- name: List available simulators
run: xcrun simctl list devices available
- name: Boot iOS Simulator
run: |
SIMULATOR_UDID=$(xcrun simctl list devices available | grep "iPhone 16 Pro (" | head -1 | grep -oE '[0-9A-Fa-f-]{36}')
if [ -z "$SIMULATOR_UDID" ]; then
SIMULATOR_UDID=$(xcrun simctl list devices available | grep "iPhone 16 (" | head -1 | grep -oE '[0-9A-Fa-f-]{36}')
fi
if [ -z "$SIMULATOR_UDID" ]; then
SIMULATOR_UDID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -oE '[0-9A-Fa-f-]{36}')
fi
if [ -z "$SIMULATOR_UDID" ]; then
echo "No iPhone simulator found!"
exit 1
fi
echo "Booting simulator: $SIMULATOR_UDID"
# Disable hardware keyboard BEFORE boot so Maestro types through
# the software keyboard. XCUITest's direct text insertion (used when
# hardware keyboard is connected) bypasses React Native's onChangeText
# handler in Expo Go, causing inputText commands to silently fail.
defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
xcrun simctl boot "$SIMULATOR_UDID" || echo "Simulator may already be booted"
echo "SIMULATOR_UDID=$SIMULATOR_UDID" >> $GITHUB_ENV
# EXDevMenuIsOnboardingFinished must be set in the app's sandboxed container
# (not NSGlobalDomain). We use PlistBuddy to write directly to the container's
# preferences plist after Expo Go is installed. Combined with clearKeychain
# (instead of clearState) in Maestro, this flag persists across test runs.
- name: Adapt Maestro tests for Expo Go
run: |
# Replace native app ID with Expo Go app ID in all Maestro YAML files.
# The checked-in files use com.pierre.fitness (native bundle ID); Expo Go
# uses host.exp.Exponent. launch-app.yaml already has the correct flow
# (clearKeychain, openLink, dev menu dismiss) — only appId needs replacing.
find frontend-mobile/.maestro -name "*.yaml" -exec sed -i '' 's/appId: com.pierre.fitness/appId: host.exp.Exponent/g' {} +
echo "=== launch-app.yaml (after appId replacement) ==="
cat frontend-mobile/.maestro/helpers/launch-app.yaml
- name: Strip Expo owner for CI
working-directory: frontend-mobile
run: |
# Remove owner and eas config to avoid "Input is required" auth error in CI
# The owner field triggers Expo CLI to verify project ownership, which needs
# interactive login or EXPO_TOKEN that we don't have in CI
# Using node for safe AST-aware modification (sed breaks brace matching)
node << 'STRIP_EOF'
const fs = require('fs');
const config = require('./app.config.js');
delete config.owner;
if (config.extra) delete config.extra.eas;
const header = '// ABOUTME: Expo configuration for Pierre mobile app\n// ABOUTME: Uses Expo Go for development; CI-stripped owner/eas fields\n\n';
fs.writeFileSync('app.config.js', header + 'module.exports = ' + JSON.stringify(config, null, 2) + ';\n');
STRIP_EOF
echo "=== app.config.js (stripped owner/eas) ==="
grep -n "owner\|eas\|projectId" app.config.js || echo "Owner/eas fields removed successfully"
- name: Start Metro, install Expo Go, and open app
working-directory: frontend-mobile
run: |
# Use --ios to both start Metro AND install Expo Go in one process.
# The --ios flag will try to open exp://<network-IP>:8082 which fails
# (simulator can't reach the host's network IP), but Metro keeps running
# and Expo Go gets installed. We then override with 127.0.0.1.
echo "Starting Metro + installing Expo Go..."
npx expo start --go --ios --port 8082 2>&1 | tee /tmp/metro.log &
echo "METRO_PID=$!" >> $GITHUB_ENV
# Wait for Expo Go to be installed
echo "Waiting for Expo Go to be installed..."
for i in {1..90}; do
if xcrun simctl get_app_container "$SIMULATOR_UDID" host.exp.Exponent 2>/dev/null; then
echo "Expo Go is installed! (attempt $i)"
break
fi
if [ "$i" -eq 90 ]; then
echo "ERROR: Expo Go installation timed out!"
cat /tmp/metro.log
exit 1
fi
sleep 2
done
# The --ios flag triggers an "Open in Expo Go?" iOS dialog that blocks
# URL processing (nobody taps "Open" in CI). Terminate Expo Go to
# dismiss the dialog, then relaunch it so we can forward URLs without
# the dialog (URLs sent to an already-running app skip the dialog).
echo "Terminating Expo Go to dismiss system URL dialog..."
xcrun simctl terminate "$SIMULATOR_UDID" host.exp.Exponent || true
sleep 2
# Wait for Metro HTTP to be ready
echo "Waiting for Metro to be ready..."
for i in {1..90}; do
if curl -s http://127.0.0.1:8082 > /dev/null 2>&1; then
echo "Metro is ready! (attempt $i)"
break
fi
if [ "$i" -eq 90 ]; then
echo "ERROR: Metro failed to start within 180s!"
cat /tmp/metro.log
exit 1
fi
sleep 2
done
# Launch Expo Go first (opens to its home screen), THEN open the URL.
# When Expo Go is already running, openurl forwards the URL directly
# to the app without showing the iOS "Open in Expo Go?" dialog.
echo "Launching Expo Go on simulator $SIMULATOR_UDID..."
xcrun simctl launch "$SIMULATOR_UDID" host.exp.Exponent
sleep 3
echo "Opening deep link with 127.0.0.1..."
xcrun simctl openurl "$SIMULATOR_UDID" "exp://127.0.0.1:8082"
# Wait for the JS bundle to compile (can take 30s-5min depending on CI runner)
echo "Waiting for JS bundle to compile..."
for i in {1..180}; do
if grep -q "Bundled" /tmp/metro.log 2>/dev/null; then
echo "Bundle compiled! (waited ~$((i*2))s)"
break
fi
if [ "$i" -eq 180 ]; then
echo "ERROR: Bundle compilation timed out after 360s!"
cat /tmp/metro.log
exit 1
fi
sleep 2
done
# Give the app time to render after bundle loads
sleep 15
# Verify Metro is still alive
if curl -s http://127.0.0.1:8082 > /dev/null 2>&1; then
echo "Metro is still running - good!"
else
echo "ERROR: Metro died! Check /tmp/metro.log"
tail -30 /tmp/metro.log
exit 1
fi
echo "=== Metro log summary ==="
grep -iE "bundled|error|warn" /tmp/metro.log | tail -10 || echo "No relevant messages"
env:
EXPO_PUBLIC_API_URL: "http://127.0.0.1:8081"
- name: Suppress Expo Go developer menu onboarding
run: |
# Write EXDevMenuIsOnboardingFinished directly to Expo Go's sandboxed
# preferences plist. This prevents the developer menu overlay from appearing.
# Combined with clearKeychain (not clearState) in Maestro, the flag persists.
APP_CONTAINER=$(xcrun simctl get_app_container "$SIMULATOR_UDID" host.exp.Exponent data 2>/dev/null) || true
if [ -n "$APP_CONTAINER" ]; then
PLIST="$APP_CONTAINER/Library/Preferences/host.exp.Exponent.plist"
echo "App container: $APP_CONTAINER"
echo "Plist path: $PLIST"
# Ensure Preferences directory exists
mkdir -p "$(dirname "$PLIST")"
# Create or update the flag
if [ -f "$PLIST" ]; then
/usr/libexec/PlistBuddy -c "Set :EXDevMenuIsOnboardingFinished true" "$PLIST" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :EXDevMenuIsOnboardingFinished bool true" "$PLIST"
else
/usr/libexec/PlistBuddy -c "Add :EXDevMenuIsOnboardingFinished bool true" "$PLIST"
fi
echo "EXDevMenuIsOnboardingFinished set to true"
/usr/libexec/PlistBuddy -c "Print :EXDevMenuIsOnboardingFinished" "$PLIST" || echo "Verification failed"
else
echo "WARNING: Could not find Expo Go app container - developer menu may appear"
fi
- name: Debug - capture simulator state before tests
if: always()
run: |
echo "=== Simulator UDID: $SIMULATOR_UDID ==="
echo "=== Installed apps on simulator ==="
xcrun simctl listapps "$SIMULATOR_UDID" 2>/dev/null | grep -A2 "CFBundleIdentifier" | head -30 || echo "Failed to list apps"
echo ""
echo "=== Simulator screenshot ==="
xcrun simctl io "$SIMULATOR_UDID" screenshot /tmp/pre-maestro-screenshot.png || echo "Screenshot failed"
echo ""
echo "=== Running processes ==="
ps aux | grep -iE "expo|metro|node" | grep -v grep || echo "No relevant processes"
echo ""
echo "=== Check Metro ==="
curl -s http://127.0.0.1:8082 | head -20 || echo "Metro not responding"
echo ""
echo "=== Check Pierre server ==="
curl -s http://127.0.0.1:8081/health || echo "Server not responding"
echo ""
echo "=== Maestro hierarchy dump (what Maestro can see) ==="
$HOME/.maestro/bin/maestro --device "$SIMULATOR_UDID" hierarchy 2>&1 | head -100 || echo "Hierarchy dump failed"
echo ""
echo "=== Metro log (last 30 lines) ==="
tail -30 /tmp/metro.log 2>/dev/null || echo "No metro log"
echo ""
echo "=== Expo install log (last 20 lines) ==="
tail -20 /tmp/expo-install.log 2>/dev/null || echo "No expo install log"
echo ""
echo "=== Booted simulators ==="
xcrun simctl list devices booted 2>/dev/null || echo "Failed to list booted devices"
- name: Test Pierre API connectivity
run: |
echo "=== Health check ==="
curl -sf http://127.0.0.1:8081/health && echo " OK" || echo " FAILED!"
echo ""
echo "=== Login API test (OAuth token endpoint) ==="
LOGIN_RESPONSE=$(curl -sf -X POST http://127.0.0.1:8081/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=mobiletest@pierre.dev&password=MobileTest1234" 2>&1) || true
if echo "$LOGIN_RESPONSE" | grep -q "access_token"; then
echo "Login API: SUCCESS (got access_token)"
else
echo "Login API: FAILED"
echo "Response: $LOGIN_RESPONSE"
fi
echo ""
echo "=== Network connectivity test ==="
echo "IPv4 (127.0.0.1:8081):"
curl -sf http://127.0.0.1:8081/health > /dev/null 2>&1 && echo " Reachable" || echo " NOT reachable"
echo "IPv6 ([::1]:8081):"
curl -sf http://[::1]:8081/health > /dev/null 2>&1 && echo " Reachable" || echo " NOT reachable"
echo "localhost:8081:"
curl -sf http://localhost:8081/health > /dev/null 2>&1 && echo " Reachable" || echo " NOT reachable"
- name: Run Maestro tests
timeout-minutes: 25
working-directory: frontend-mobile
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Running full E2E suite (manual trigger)..."
$HOME/.maestro/bin/maestro test --device "$SIMULATOR_UDID" .maestro/ --format junit --output maestro-results.xml
else
echo "Running CI smoke tests (push trigger)..."
$HOME/.maestro/bin/maestro test --device "$SIMULATOR_UDID" \
.maestro/login/01-show-login-screen.yaml \
.maestro/login/02-show-email-password-inputs.yaml \
--format junit --output maestro-results.xml
fi
env:
TEST_EMAIL: "mobiletest@pierre.dev"
TEST_PASSWORD: "MobileTest1234"
BACKEND_URL: "http://localhost:8081"
- name: Post-test diagnostics
if: always()
run: |
echo "=== Maestro hierarchy dump (what's on screen after tests) ==="
$HOME/.maestro/bin/maestro --device "$SIMULATOR_UDID" hierarchy 2>&1 | head -200 || echo "Hierarchy dump failed"
echo ""
echo "=== Post-test screenshot ==="
xcrun simctl io "$SIMULATOR_UDID" screenshot /tmp/post-maestro-screenshot.png || echo "Screenshot failed"
echo ""
echo "=== Find Maestro failure screenshots ==="
find $HOME/.maestro -name "*.png" -newer /tmp/metro.log 2>/dev/null | head -20 || echo "No screenshots found"
# Copy any Maestro screenshots to a known location for upload
mkdir -p /tmp/maestro-screenshots
find $HOME/.maestro -name "*.png" -newer /tmp/metro.log -exec cp {} /tmp/maestro-screenshots/ \; 2>/dev/null || true
find /tmp -name "*maestro*" -name "*.png" 2>/dev/null | head -20 || true
echo ""
echo "=== Pierre server still running? ==="
curl -sf http://127.0.0.1:8081/health && echo "Server: OK" || echo "Server: DOWN"
echo ""
echo "=== Metro still running? ==="
curl -sf http://127.0.0.1:8082 > /dev/null 2>&1 && echo "Metro: OK" || echo "Metro: DOWN"
echo ""
echo "=== Metro log (last 20 lines) ==="
tail -20 /tmp/metro.log 2>/dev/null || echo "No metro log"
echo ""
echo "=== Pierre server log (login attempts) ==="
grep -i "oauth\|token\|login\|auth\|password" /tmp/pierre-server.log 2>/dev/null | tail -30 || echo "No auth-related log entries"
echo ""
echo "=== Pierre server log (ALL HTTP requests - check for 401s after login) ==="
grep -E "HTTP|status|401|403|500|error" /tmp/pierre-server.log 2>/dev/null | tail -50 || echo "No HTTP entries"
echo ""
echo "=== Pierre server log (errors) ==="
grep -iE "error|fail|reject|invalid" /tmp/pierre-server.log 2>/dev/null | tail -20 || echo "No error entries"
echo ""
echo "=== Post-login API test (verify JWT works for subsequent calls) ==="
JWT=$(curl -sf -X POST http://127.0.0.1:8081/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=mobiletest@pierre.dev&password=MobileTest1234" 2>&1 | jq -r '.access_token // empty') || true
if [ -n "$JWT" ]; then
echo "Got JWT token (length: ${#JWT})"
echo "Testing GET /api/coaches with JWT:"
curl -sf -w "HTTP %{http_code}" http://127.0.0.1:8081/api/coaches \
-H "Authorization: Bearer $JWT" 2>&1 | tail -1 || echo "FAILED"
echo ""
echo "Testing GET /api/conversations with JWT:"
curl -sf -w "HTTP %{http_code}" http://127.0.0.1:8081/api/conversations \
-H "Authorization: Bearer $JWT" 2>&1 | tail -1 || echo "FAILED"
echo ""
echo "Testing GET /api/providers with JWT:"
curl -sf -w "HTTP %{http_code}" http://127.0.0.1:8081/api/providers \
-H "Authorization: Bearer $JWT" 2>&1 | tail -1 || echo "FAILED"
else
echo "FAILED to get JWT token"
fi
echo ""
echo "=== Find takeScreenshot output files ==="
find frontend-mobile -name "*.png" -newer /tmp/metro.log 2>/dev/null | head -20 || echo "No screenshots"
# Copy Maestro takeScreenshot files to known location
find frontend-mobile -name "*.png" -newer /tmp/metro.log -exec cp {} /tmp/maestro-screenshots/ \; 2>/dev/null || true
- name: Upload Maestro test results
uses: actions/upload-artifact@v4
if: always()
with:
name: maestro-test-results-ios
path: |
frontend-mobile/maestro-results.xml
frontend-mobile/.maestro/
/tmp/pre-maestro-screenshot.png
/tmp/post-maestro-screenshot.png
/tmp/maestro-screenshots/
/tmp/metro.log
/tmp/expo-install.log
/tmp/pierre-server.log
retention-days: 7
mobile-e2e-maestro-android:
name: Mobile E2E Tests - Android (Maestro)
needs: build-server-linux
runs-on: ubuntu-latest
# Run only on nightly schedule (midnight EDT) or manual trigger
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
timeout-minutes: 90
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download server binaries
uses: actions/download-artifact@v4
with:
name: pierre-server-linux
path: target/release/
- name: Make binaries executable
run: chmod +x target/release/pierre-mcp-server target/release/pierre-cli
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.4"
- name: Install shared-types dependencies
working-directory: packages/shared-types
run: bun install
- name: Install api-client dependencies
working-directory: packages/api-client
run: bun install
- name: Remove expo-dev-client for E2E build
working-directory: frontend-mobile
run: |
# Remove expo-dev-client from package.json BEFORE install
# This package interferes with Maestro testing - its native code
# tries to connect to a dev server that doesn't exist in CI
cat package.json | jq 'del(.dependencies["expo-dev-client"])' > package.json.tmp
mv package.json.tmp package.json
- name: Install mobile dependencies
working-directory: frontend-mobile
run: bun install
- name: Strip Expo owner for CI
working-directory: frontend-mobile
run: |
# Remove owner and eas config to avoid "Input is required" auth error in CI
node << 'STRIP_EOF'
const fs = require('fs');
const config = require('./app.config.js');
delete config.owner;
if (config.extra) delete config.extra.eas;
const header = '// ABOUTME: Expo configuration for Pierre mobile app\n// ABOUTME: Uses Expo Go for development; CI-stripped owner/eas fields\n\n';
fs.writeFileSync('app.config.js', header + 'module.exports = ' + JSON.stringify(config, null, 2) + ';\n');
STRIP_EOF
echo "=== app.config.js (stripped owner/eas) ==="
grep -n "owner\|eas\|projectId" app.config.js || echo "Owner/eas fields removed successfully"
- name: Install Maestro CLI
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- name: Free disk space for emulator
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android/sdk/ndk
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
- name: Create data directory
run: mkdir -p data
- name: Start Pierre server
run: |
./target/release/pierre-mcp-server &
echo "Waiting for server to start..."
for i in {1..30}; do
if curl -s http://localhost:8081/health > /dev/null; then
echo "Server is healthy!"
break
fi
echo "Attempt $i: Server not ready yet..."
sleep 2
done
env:
DATABASE_URL: "sqlite:${{ github.workspace }}/data/maestro-android-test.db"
PIERRE_MASTER_ENCRYPTION_KEY: "rEFe91l6lqLahoyl9OSzum9dKa40VvV5RYj8bHGNTeo="
PIERRE_RSA_KEY_SIZE: "2048"
HTTP_PORT: "8081"
RUST_LOG: "info"
STRAVA_CLIENT_ID: "test_client_id_ci"
STRAVA_CLIENT_SECRET: "test_client_secret_ci"
STRAVA_REDIRECT_URI: "http://localhost:8081/auth/strava/callback"
- name: Seed test user for Maestro tests
run: |
./target/release/pierre-cli user create --email mobiletest@pierre.dev --password MobileTest1234 --force
env:
DATABASE_URL: "sqlite:${{ github.workspace }}/data/maestro-android-test.db"
PIERRE_MASTER_ENCRYPTION_KEY: "rEFe91l6lqLahoyl9OSzum9dKa40VvV5RYj8bHGNTeo="
RUST_LOG: "warn"
- name: Enable KVM for Android emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Adapt Maestro tests for Expo Go
run: |
# Replace native app ID with Android Expo Go package name (lowercase)
find frontend-mobile/.maestro -name "*.yaml" -exec sed -i 's/appId: com.pierre.fitness/appId: host.exp.exponent/g' {} +
# Override launch helper for Expo Go.
# Same strategy as iOS: coordinate tap at 90% to dismiss developer menu overlay.
cat > frontend-mobile/.maestro/helpers/launch-app.yaml << 'LAUNCH_EOF'
appId: host.exp.exponent
---
- stopApp
- clearState
- openLink: "exp://10.0.2.2:8082"
# Dismiss Expo Go developer menu overlay before waiting for login-screen.
# Tap "Continue" at ~90% height, then dismiss full menu at 10%.
- tapOn:
point: "50%,90%"
- tapOn:
point: "50%,10%"
- extendedWaitUntil:
visible:
id: "login-screen"
timeout: 120000
- extendedWaitUntil:
visible:
id: "email-input"
timeout: 30000
LAUNCH_EOF
echo "=== Generated launch-app.yaml ==="
cat frontend-mobile/.maestro/helpers/launch-app.yaml
- name: Create Android E2E test script
run: |
cat > /tmp/run-android-e2e.sh << 'SCRIPT_EOF'
#!/bin/bash
set -e
cd frontend-mobile
npx expo start --go --android --port 8082 &
METRO_PID=$!
echo "Waiting for Metro bundler to be ready..."
for i in $(seq 1 90); do
if curl -s http://localhost:8082 > /dev/null 2>&1; then
echo "Metro is ready!"
break
fi
if [ "$i" -eq 90 ]; then
echo "Metro failed to start within timeout!"
kill $METRO_PID 2>/dev/null || true
exit 1
fi
echo "Attempt $i: Metro not ready yet..."
sleep 2
done
# Wait for Expo Go to install and load the initial bundle
sleep 30
MAESTRO_EXIT=0
if [ "$MAESTRO_FULL_SUITE" = "true" ]; then
echo "Running full E2E suite (manual trigger)..."
$HOME/.maestro/bin/maestro test .maestro/ --format junit --output maestro-results-android.xml || MAESTRO_EXIT=$?
else
echo "Running CI smoke tests (push trigger)..."
$HOME/.maestro/bin/maestro test \
.maestro/login/01-show-login-screen.yaml \
.maestro/login/02-show-email-password-inputs.yaml \
--format junit --output maestro-results-android.xml || MAESTRO_EXIT=$?
fi
# Kill Metro and all child processes to prevent emulator-runner cleanup from hanging
echo "Killing Metro (PID $METRO_PID) and child processes..."
kill $METRO_PID 2>/dev/null || true
pkill -f "expo start" 2>/dev/null || true
pkill -f "node.*metro" 2>/dev/null || true
wait $METRO_PID 2>/dev/null || true
# Kill the emulator so emulator-runner doesn't hang on graceful shutdown
adb emu kill 2>/dev/null || true
sleep 2
exit $MAESTRO_EXIT
SCRIPT_EOF
chmod +x /tmp/run-android-e2e.sh
- name: Run Maestro tests on Android
timeout-minutes: 25
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
avd-name: maestro_test_emulator
force-avd-creation: true
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
emulator-boot-timeout: 600
disk-size: 4096M
script: bash /tmp/run-android-e2e.sh
env:
CI: "true"
EXPO_PUBLIC_API_URL: "http://10.0.2.2:8081"
TEST_EMAIL: "mobiletest@pierre.dev"
TEST_PASSWORD: "MobileTest1234"
BACKEND_URL: "http://10.0.2.2:8081"
MAESTRO_FULL_SUITE: ${{ github.event_name == 'workflow_dispatch' && 'true' || 'false' }}
- name: Upload Maestro test results
uses: actions/upload-artifact@v4
if: always()
with:
name: maestro-test-results-android
path: |
frontend-mobile/maestro-results-android.xml
frontend-mobile/.maestro/
retention-days: 7