diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml
new file mode 100644
index 00000000..0813deef
--- /dev/null
+++ b/.github/workflows/mobile-e2e.yml
@@ -0,0 +1,158 @@
+name: Mobile E2E Tests
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'mobile/**'
+ pull_request:
+ branches: [main]
+ paths:
+ - 'mobile/**'
+ workflow_dispatch:
+ # Weekly run to catch regressions
+ schedule:
+ - cron: '0 9 * * 1' # Monday 9am UTC
+
+jobs:
+ maestro-ios:
+ runs-on: macos-14
+ timeout-minutes: 45
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache bun dependencies
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.bun/install/cache
+ mobile/node_modules
+ key: ${{ runner.os }}-bun-mobile-${{ hashFiles('mobile/bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-mobile-
+
+ - name: Install dependencies
+ working-directory: mobile
+ run: bun install
+
+ - name: Select Xcode version
+ run: |
+ # Try Xcode 16.2 first, fall back to 16.1 if not available
+ if [ -d "/Applications/Xcode_16.2.app" ]; then
+ sudo xcode-select -s /Applications/Xcode_16.2.app
+ elif [ -d "/Applications/Xcode_16.1.app" ]; then
+ sudo xcode-select -s /Applications/Xcode_16.1.app
+ elif [ -d "/Applications/Xcode_16.app" ]; then
+ sudo xcode-select -s /Applications/Xcode_16.app
+ else
+ echo "Available Xcode versions:"
+ ls -d /Applications/Xcode*.app 2>/dev/null || echo "No Xcode found"
+ exit 1
+ fi
+ xcodebuild -version
+
+ - name: Generate iOS native code
+ working-directory: mobile
+ run: bunx expo prebuild --platform ios --clean
+
+ - name: Cache CocoaPods
+ uses: actions/cache@v4
+ with:
+ path: mobile/ios/Pods
+ key: ${{ runner.os }}-pods-${{ hashFiles('mobile/ios/Podfile.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-pods-
+
+ - name: Install CocoaPods dependencies
+ working-directory: mobile/ios
+ run: pod install
+
+ - name: List available simulators
+ run: xcrun simctl list devices available
+
+ - name: Boot iOS Simulator
+ run: |
+ DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone 16" | head -1 | grep -oE '[A-F0-9-]{36}')
+ if [ -z "$DEVICE_ID" ]; then
+ DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone 15" | head -1 | grep -oE '[A-F0-9-]{36}')
+ fi
+ if [ -z "$DEVICE_ID" ]; then
+ DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -oE '[A-F0-9-]{36}')
+ fi
+ echo "DEVICE_ID=$DEVICE_ID" >> $GITHUB_ENV
+ xcrun simctl boot "$DEVICE_ID" || true
+ xcrun simctl bootstatus "$DEVICE_ID" -b
+
+ - name: Cache Xcode build
+ uses: actions/cache@v4
+ with:
+ path: mobile/ios/build
+ key: ${{ runner.os }}-xcode-${{ hashFiles('mobile/ios/Podfile.lock', 'mobile/src/**/*.ts', 'mobile/src/**/*.tsx') }}
+ restore-keys: |
+ ${{ runner.os }}-xcode-
+
+ - name: Build iOS app for simulator
+ working-directory: mobile/ios
+ run: |
+ xcodebuild -workspace Perry.xcworkspace \
+ -scheme Perry \
+ -configuration Release \
+ -sdk iphonesimulator \
+ -destination "id=${{ env.DEVICE_ID }}" \
+ -derivedDataPath build \
+ build
+
+ - name: Install app on simulator
+ run: |
+ APP_PATH=$(find mobile/ios/build -name "*.app" -type d | head -1)
+ xcrun simctl install "${{ env.DEVICE_ID }}" "$APP_PATH"
+
+ - name: Install Maestro
+ run: |
+ curl -Ls "https://get.maestro.mobile.dev" | bash
+ echo "$HOME/.maestro/bin" >> $GITHUB_PATH
+
+ - name: Launch app and take debug screenshot
+ run: |
+ export PATH="$HOME/.maestro/bin:$PATH"
+ xcrun simctl launch "${{ env.DEVICE_ID }}" com.gricha.perry || true
+ sleep 5
+ xcrun simctl io "${{ env.DEVICE_ID }}" screenshot /tmp/app-launch-debug.png || true
+
+ - name: Run Maestro tests
+ working-directory: mobile
+ run: |
+ export PATH="$HOME/.maestro/bin:$PATH"
+ maestro test .maestro/flows/ --format junit --output maestro-report.xml
+ env:
+ MAESTRO_DRIVER_STARTUP_TIMEOUT: 120000
+
+ - name: Upload debug screenshot
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: debug-screenshot
+ path: /tmp/app-launch-debug.png
+ retention-days: 7
+
+ - name: Upload Maestro report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: maestro-report
+ path: mobile/maestro-report.xml
+ retention-days: 7
+
+ - name: Upload Maestro screenshots
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: maestro-screenshots
+ path: ~/.maestro/tests/
+ retention-days: 7
diff --git a/mobile/.maestro/flows/01-app-launch.yaml b/mobile/.maestro/flows/01-app-launch.yaml
new file mode 100644
index 00000000..cbd5eb65
--- /dev/null
+++ b/mobile/.maestro/flows/01-app-launch.yaml
@@ -0,0 +1,18 @@
+appId: com.gricha.perry
+name: App Launch - Fresh Install
+tags:
+ - smoke
+ - setup
+---
+- launchApp:
+ clearState: true
+
+# On fresh install, should show setup screen with Perry branding
+- assertVisible: "Perry"
+- assertVisible: "Connect to your workspace server"
+- assertVisible:
+ id: "hostname-input"
+- assertVisible:
+ id: "port-input"
+- assertVisible:
+ id: "connect-button"
diff --git a/mobile/.maestro/flows/02-setup-validation.yaml b/mobile/.maestro/flows/02-setup-validation.yaml
new file mode 100644
index 00000000..945a4b78
--- /dev/null
+++ b/mobile/.maestro/flows/02-setup-validation.yaml
@@ -0,0 +1,25 @@
+appId: com.gricha.perry
+name: Setup Screen - Form Validation
+tags:
+ - setup
+ - validation
+---
+- launchApp:
+ clearState: true
+
+# Test empty hostname validation
+- tapOn:
+ id: "connect-button"
+- assertVisible: "Please enter a hostname"
+
+# Test port validation
+- tapOn:
+ id: "hostname-input"
+- inputText: "test-server.local"
+- tapOn:
+ id: "port-input"
+- eraseText
+- inputText: "0"
+- tapOn:
+ id: "connect-button"
+- assertVisible: "Please enter a valid port number"
diff --git a/mobile/app.json b/mobile/app.json
index a6fa7e78..079ff9e3 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -14,7 +14,7 @@
},
"ios": {
"supportsTablet": true,
- "bundleIdentifier": "com.subroutine.workspace"
+ "bundleIdentifier": "com.gricha.perry"
},
"android": {
"package": "com.subroutine.workspace",
diff --git a/mobile/src/screens/SetupScreen.tsx b/mobile/src/screens/SetupScreen.tsx
index 681f8daa..2aadd40e 100644
--- a/mobile/src/screens/SetupScreen.tsx
+++ b/mobile/src/screens/SetupScreen.tsx
@@ -79,6 +79,7 @@ export function SetupScreen({ onComplete }: SetupScreenProps) {
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
+ testID="hostname-input"
/>
Your Tailscale hostname or IP address
@@ -92,6 +93,7 @@ export function SetupScreen({ onComplete }: SetupScreenProps) {
placeholder="7391"
placeholderTextColor="#666"
keyboardType="number-pad"
+ testID="port-input"
/>
@@ -105,6 +107,7 @@ export function SetupScreen({ onComplete }: SetupScreenProps) {
style={[styles.button, isConnecting && styles.buttonDisabled]}
onPress={handleConnect}
disabled={isConnecting}
+ testID="connect-button"
>
{isConnecting ? (
diff --git a/mobile/src/screens/WorkspaceDetailScreen.tsx b/mobile/src/screens/WorkspaceDetailScreen.tsx
index 667a660b..e0accdb9 100644
--- a/mobile/src/screens/WorkspaceDetailScreen.tsx
+++ b/mobile/src/screens/WorkspaceDetailScreen.tsx
@@ -212,6 +212,7 @@ export function WorkspaceDetailScreen({ route, navigation }: any) {
style={styles.terminalBtn}
onPress={() => navigation.navigate('Terminal', { name })}
disabled={!isRunning}
+ testID="terminal-button"
>
Terminal
@@ -219,6 +220,7 @@ export function WorkspaceDetailScreen({ route, navigation }: any) {
style={styles.newChatBtn}
onPress={() => setShowNewChatPicker(!showNewChatPicker)}
disabled={!isRunning}
+ testID="new-chat-button"
>
New Chat ▼