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 ▼