diff --git a/.github/actions/argent-server/action.yml b/.github/actions/argent-server/action.yml new file mode 100644 index 0000000000..8bd714290a --- /dev/null +++ b/.github/actions/argent-server/action.yml @@ -0,0 +1,68 @@ +name: Start Argent tool-server +description: >- + Installs the Argent CLI and starts a tool-server for later steps and + ARGENT_SERVER_LOG for the caller to collect on failure. + +inputs: + port: + description: Port to serve the tool-server on. + required: false + default: '3001' + simulator-server-log: + description: >- + SIMSERVER_LOG level for the simulator-server backend the tool-server + spawns, e.g. `debug`. Left unset when empty. + required: false + default: '' + +runs: + using: composite + steps: + - name: Install Argent + shell: bash + run: npx @swmansion/argent init --yes + + # Start the server here and export its URL, so `flow run` reuses it instead + # of starting one in-process, which has to come up within a fixed window a + # cold runner can miss - the first run downloads the simulator-server binary. + # + # Not `--detach`: it gives the server a hard 15s to come up and then kills + # its whole process group, and backgrounding the CLI does not help because + # the timeout lives in the process we backgrounded. In the foreground there + # is no deadline and the server's own output lands in our log. + - name: Start tool-server + shell: bash + env: + TOOL_SERVER_PORT: ${{ inputs.port }} + SIMULATOR_SERVER_LOG: ${{ inputs.simulator-server-log }} + run: | + LOG="$RUNNER_TEMP/argent-server.log" + # Exported before anything can fail, so the caller's log-collecting step + # can find it even when startup is what went wrong. + echo "ARGENT_SERVER_LOG=$LOG" >> "$GITHUB_ENV" + + if [ -n "$SIMULATOR_SERVER_LOG" ]; then + export SIMSERVER_LOG="$SIMULATOR_SERVER_LOG" + fi + + nohup argent server start --no-auth --port "$TOOL_SERVER_PORT" > "$LOG" 2>&1 & + SERVER_PID=$! + + echo "Waiting for Argent tool-server on :$TOOL_SERVER_PORT..." + # 3 min budget. Poll liveness too, so a server that dies on startup + # fails right away with its log instead of at the end of the budget. + for _ in $(seq 1 90); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Argent tool-server exited during startup" >&2 + cat "$LOG" >&2 + exit 1 + fi + if curl -fsS "http://127.0.0.1:$TOOL_SERVER_PORT/tools" >/dev/null 2>&1; then + echo "tool-server is ready" + exit 0 + fi + sleep 2 + done + echo "Argent tool-server failed to become ready in time" >&2 + cat "$LOG" >&2 + exit 1 diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml new file mode 100644 index 0000000000..efe9aaf765 --- /dev/null +++ b/.github/workflows/android-e2e.yml @@ -0,0 +1,197 @@ +name: Run Android e2e tests + +# Reusable: runs the Argent flows against a prebuilt APK. Called by `android.yml`. +on: + workflow_call: + inputs: + artifact-name: + description: Artifact holding the APK to test. + required: true + type: string + abi: + description: Emulator ABI. Must match the ABI the APK was built for. + required: true + type: string + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 60 + + env: + # The emulator advertises its gRPC console in + # $XDG_RUNTIME_DIR/avd/running/pid_*.ini, which is where Argent's Android + # backend looks for it. The variable is unset on runners and the two fall + # back to different dirs, so pin it to a path they both check first. + XDG_RUNTIME_DIR: /tmp/xdg-runtime + AVD_NAME: test + SYSTEM_IMAGE: system-images;android-34;google_apis;${{ inputs.abi }} + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Use Java 17 + uses: actions/setup-java@v4 + with: + distribution: oracle + java-version: 17 + + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Download APK built by the build workflow + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/apk + + - name: Enable KVM + 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 + + # The emulator ignores XDG_RUNTIME_DIR if it does not exist, so create it + # before booting. 0700 per the XDG spec. + - name: Prepare shared XDG_RUNTIME_DIR + run: | + mkdir -p "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" + + # The runner's preinstalled SDK has neither the emulator nor a system + # image (asking for platform-tools on top of it is a no-op). + - name: Install Android emulator and system image + run: | + CMDLINE_BIN=$(dirname "$(command -v sdkmanager || echo "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager")") + yes | "$CMDLINE_BIN/sdkmanager" --licenses > /dev/null || true + "$CMDLINE_BIN/sdkmanager" --install "emulator" "platform-tools" "$SYSTEM_IMAGE" > /dev/null + { + echo "$CMDLINE_BIN" + echo "$ANDROID_HOME/emulator" + echo "$ANDROID_HOME/platform-tools" + } >> "$GITHUB_PATH" + # By default avdmanager writes to $ANDROID_SDK_HOME/.android/avd while + # the emulator looks in $ANDROID_SDK_HOME/avd, which fails the boot + # with "Unknown AVD name". Both check $ANDROID_AVD_HOME first. + echo "ANDROID_AVD_HOME=$HOME/.android/avd" >> "$GITHUB_ENV" + + - name: Create AVD + run: | + mkdir -p "$ANDROID_AVD_HOME" + echo no | avdmanager create avd --name "$AVD_NAME" --package "$SYSTEM_IMAGE" --force + test -f "$ANDROID_AVD_HOME/$AVD_NAME.ini" || { + echo "avdmanager did not create $AVD_NAME.ini in $ANDROID_AVD_HOME; found instead:" >&2 + find "$HOME" "$ANDROID_HOME" -name "$AVD_NAME.ini" 2>/dev/null >&2 + exit 1 + } + + # `-grpc` makes the emulator write grpc.port to the discovery ini Argent + # reads. `-accel on` fails the boot when KVM didn't take, instead of + # falling back to a software boot slow enough to hit the job timeout. + - name: Boot emulator + run: | + nohup emulator -avd "$AVD_NAME" -no-snapshot -accel on \ + -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim \ + -grpc 8554 -camera-back none > emulator.log 2>&1 & + EMULATOR_PID=$! + adb start-server + # Poll boot state and process liveness (10 min budget): a backgrounded + # emulator that dies cannot fail the step on its own, and + # `adb wait-for-device` would just hang without saying why. + for _ in $(seq 1 120); do + if ! kill -0 "$EMULATOR_PID" 2>/dev/null; then + echo "Emulator process exited during boot:" >&2 + cat emulator.log >&2 + exit 1 + fi + if [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ]; then + BOOTED=1 + break + fi + sleep 5 + done + if [ -z "${BOOTED:-}" ]; then + echo "Emulator did not finish booting within 10 minutes" >&2 + adb devices >&2 + cat emulator.log >&2 + exit 1 + fi + adb shell input keyevent 82 + adb shell settings put global window_animation_scale 0.0 + adb shell settings put global animator_duration_scale 0.0 + # transition_animation_scale is left alone on purpose: Reanimated + # reports reduced motion when it is 0, which finishes every withTiming + # in a single frame. + adb shell settings put system show_touches 1 + SERIAL=$(adb devices | awk 'NR>1 && $2=="device"{print $1; exit}') + echo "Emulator ready: $SERIAL" + echo "ANDROID_SERIAL=$SERIAL" >> "$GITHUB_ENV" + + - name: Install app on emulator + run: | + APK=$(find "$RUNNER_TEMP/apk" -name '*.apk' | head -1) + if [ -z "$APK" ]; then + echo "Downloaded artifact does not contain an APK" >&2 + ls -la "$RUNNER_TEMP/apk" >&2 + exit 1 + fi + echo "Installing $APK on $ANDROID_SERIAL" + adb install -r "$APK" + + - name: Install Argent and start the tool-server + uses: ./.github/actions/argent-server + with: + simulator-server-log: debug + + # `screenrecord` stops on its own after 3 minutes, so record in segments + # until the stop step interrupts it. + - name: Start screen recording + run: | + adb shell 'rm -f /sdcard/rec-*.mp4' + nohup bash -c ' + i=0 + while adb shell screenrecord --bit-rate 2000000 --time-limit 180 \ + "/sdcard/rec-$(printf %03d "$i").mp4"; do + i=$((i + 1)) + done + ' > screenrecord.log 2>&1 & + echo "SCREENRECORD_PID=$!" >> "$GITHUB_ENV" + + - name: Run E2E tests + run: argent flow run ./.argent/flows --device "$ANDROID_SERIAL" + + - name: Stop screen recording + if: ${{ always() }} + run: | + kill "${SCREENRECORD_PID:-}" 2>/dev/null || true + # SIGINT is what makes screenrecord write the mp4 trailer; killed any + # other way, the last segment is unplayable. + adb shell pkill -INT screenrecord || true + for _ in $(seq 1 15); do + adb shell pgrep screenrecord > /dev/null 2>&1 || break + sleep 1 + done + mkdir -p "$RUNNER_TEMP/e2e-debug/recordings" + for f in $(adb shell 'ls /sdcard/rec-*.mp4' 2>/dev/null | tr -d '\r'); do + adb pull "$f" "$RUNNER_TEMP/e2e-debug/recordings" || true + done + + - name: Collect logs + if: ${{ always() }} + run: | + mkdir -p "$RUNNER_TEMP/e2e-debug/logs" + for f in emulator.log screenrecord.log "${ARGENT_SERVER_LOG:-}"; do + cp "$f" "$RUNNER_TEMP/e2e-debug/logs" 2>/dev/null || true + done + ls -lR "$RUNNER_TEMP/e2e-debug" + + - name: Upload logs and screen recording + if: ${{ !success() }} + uses: actions/upload-artifact@v4 + with: + name: e2e-android-debug-${{ inputs.artifact-name }} + path: ${{ runner.temp }}/e2e-debug/ + if-no-files-found: ignore diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6ad16bb9b2..4c9dc45027 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -3,8 +3,11 @@ name: Build Android (expo-example) on: pull_request: paths: + - .argent/flows/* + - .github/actions/argent-server/action.yml - .github/workflows/android.yml - .github/workflows/android-build.yml + - .github/workflows/android-e2e.yml - packages/react-native-gesture-handler/package.json - packages/react-native-gesture-handler/android/** - packages/react-native-gesture-handler/shared/** @@ -13,6 +16,7 @@ on: - apps/common-app/** - rnrepo.config.json - yarn.lock + - '!packages/react-native-gesture-handler/src/**/*.test.*' push: branches: - main @@ -31,3 +35,12 @@ jobs: app: expo-example artifact-name: android-apk-expo-example abi: x86_64 + + e2e: + if: github.repository == 'software-mansion/react-native-gesture-handler' + needs: expo-example + + uses: ./.github/workflows/android-e2e.yml + with: + artifact-name: android-apk-expo-example + abi: x86_64 diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml new file mode 100644 index 0000000000..7eb1ab1cd9 --- /dev/null +++ b/.github/workflows/ios-e2e.yml @@ -0,0 +1,151 @@ +name: Run iOS e2e tests + +# Reusable: runs the Argent flows against a prebuilt app bundle. Called by `ios.yml`. +on: + workflow_call: + inputs: + artifact-name: + description: Artifact holding the packaged .app bundle to test. + required: true + type: string + xcode-version: + description: >- + Xcode to run on. Must match the version the app was built with, so the + simulator runtime matches the SDK. + required: true + type: string + +jobs: + e2e: + runs-on: macos-26 + timeout-minutes: 60 + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Select Xcode + env: + XCODE_VERSION: ${{ inputs.xcode-version }} + run: | + XCODE_APP="/Applications/Xcode_${XCODE_VERSION}.app" + if [ ! -d "$XCODE_APP" ]; then + echo "Xcode ${XCODE_VERSION} is not installed on this runner. Available:" >&2 + ls -d /Applications/Xcode*.app >&2 + exit 1 + fi + echo "Using $XCODE_APP" + echo "DEVELOPER_DIR=$XCODE_APP/Contents/Developer" >> "$GITHUB_ENV" + DEVELOPER_DIR="$XCODE_APP/Contents/Developer" xcodebuild -version + + # Only needed to run the Argent CLI — the app is downloaded, not built. + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Download app built by the build workflow + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/app + + - name: Unpack app bundle + run: | + tar -xzf "$RUNNER_TEMP/app/app.tar.gz" -C "$RUNNER_TEMP/app" + APP_PATH=$(find "$RUNNER_TEMP/app" -maxdepth 1 -name '*.app' -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "Downloaded artifact does not contain an .app" >&2 + ls -la "$RUNNER_TEMP/app" >&2 + exit 1 + fi + echo "APP_PATH=$APP_PATH" >> "$GITHUB_ENV" + + - name: Set up iPhone 17 Pro simulator + run: | + UDID=$(xcrun simctl list devices available | grep -E "iPhone 17 Pro \(" | head -1 | grep -oE "[0-9A-Fa-f-]{36}" || true) + if [ -z "$UDID" ]; then + echo "iPhone 17 Pro not found, creating one" + RUNTIME=$(xcrun simctl list runtimes ios -j | jq -r '.runtimes | map(select(.isAvailable)) | last | .identifier') + UDID=$(xcrun simctl create "iPhone 17 Pro" "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro" "$RUNTIME") + fi + echo "Using simulator $UDID" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" + echo "SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV" + + - name: Disable keyboard intelligence on the simulator + run: | + KEYS=( + KeyboardPrediction + KeyboardAutocorrection + KeyboardCheckSpelling + KeyboardAutocapitalization + KeyboardPeriodShortcut + KeyboardCapsLock + KeyboardContinuousPathEnabled + ) + for key in "${KEYS[@]}"; do + xcrun simctl spawn "$SIMULATOR_UDID" defaults write "Apple Global Domain" "$key" -bool false + xcrun simctl spawn "$SIMULATOR_UDID" defaults write com.apple.Preferences "$key" -bool false + done + # Suppress the slide-to-type intro overlay a fresh sim shows on first keyboard use + xcrun simctl spawn "$SIMULATOR_UDID" defaults write com.apple.keyboard.preferences \ + DidShowContinuousPathIntroduction -bool true + xcrun simctl spawn "$SIMULATOR_UDID" defaults read "Apple Global Domain" | grep Keyboard || true + + - name: Install app on simulator + run: | + echo "Installing $APP_PATH on $SIMULATOR_UDID" + xcrun simctl install "$SIMULATOR_UDID" "$APP_PATH" + + - name: Install Argent and start the tool-server + uses: ./.github/actions/argent-server + + # h264 rather than the default hevc, which most browsers cannot play. + - name: Start screen recording + run: | + mkdir -p "$RUNNER_TEMP/e2e-debug/recordings" + nohup xcrun simctl io "$SIMULATOR_UDID" recordVideo --codec h264 --force \ + "$RUNNER_TEMP/e2e-debug/recordings/rec.mp4" > recordvideo.log 2>&1 & + echo "RECORDING_PID=$!" >> "$GITHUB_ENV" + # simctl only records from the first processed frame, which it + # announces; without the wait the tests start before the recording. + for _ in $(seq 1 30); do + if grep -q "Recording started" recordvideo.log 2>/dev/null; then break; fi + sleep 1 + done + + - name: Run E2E tests + run: argent flow run ./.argent/flows --device $SIMULATOR_UDID + + - name: Stop screen recording + if: ${{ always() }} + run: | + # SIGINT is what makes recordVideo write the mp4 trailer; killed any + # other way, the file is unplayable. + kill -INT "${RECORDING_PID:-}" 2>/dev/null || true + for _ in $(seq 1 15); do + kill -0 "${RECORDING_PID:-}" 2>/dev/null || break + sleep 1 + done + + # The tool-server log also carries the device backend's stderr + # (`[sim ]` lines), where a failed connection to the simulator is + # explained. + - name: Collect logs + if: ${{ always() }} + run: | + mkdir -p "$RUNNER_TEMP/e2e-debug/logs" + for f in recordvideo.log "${ARGENT_SERVER_LOG:-}"; do + cp "$f" "$RUNNER_TEMP/e2e-debug/logs" 2>/dev/null || true + done + ls -lR "$RUNNER_TEMP/e2e-debug" + + - name: Upload logs and screen recording + if: ${{ !success() }} + uses: actions/upload-artifact@v4 + with: + name: e2e-ios-debug-${{ inputs.artifact-name }} + path: ${{ runner.temp }}/e2e-debug/ + if-no-files-found: ignore diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index a40c21d9ac..4e0f57b474 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -3,8 +3,11 @@ name: Build iOS (expo-example) on: pull_request: paths: + - .argent/flows/* + - .github/actions/argent-server/action.yml - .github/workflows/ios.yml - .github/workflows/ios-build.yml + - .github/workflows/ios-e2e.yml - packages/react-native-gesture-handler/package.json - packages/react-native-gesture-handler/RNGestureHandler.podspec - packages/react-native-gesture-handler/apple/** @@ -14,6 +17,7 @@ on: - apps/common-app/** - rnrepo.config.json - yarn.lock + - '!packages/react-native-gesture-handler/src/**/*.test.*' push: branches: - main @@ -24,12 +28,39 @@ concurrency: cancel-in-progress: true jobs: + # Single source of truth for versions shared by the build and e2e workflows. + # A job output is the only way to reach them both: `jobs..with` resolves + # `needs`, `matrix` and `vars`, but not a workflow-level `env`. + config: + if: github.repository == 'software-mansion/react-native-gesture-handler' + + runs-on: ubuntu-latest + outputs: + xcode-version: ${{ steps.versions.outputs.xcode }} + + steps: + # The e2e job has to run the same Xcode the app was built with, so its + # simulator runtime matches the SDK. + - name: Pin versions + id: versions + run: echo "xcode=26.6" >> "$GITHUB_OUTPUT" + expo-example: if: github.repository == 'software-mansion/react-native-gesture-handler' + needs: config uses: ./.github/workflows/ios-build.yml with: app: expo-example scheme: ExpoExample artifact-name: ios-app-expo-example - xcode-version: '26.4.1' + xcode-version: ${{ needs.config.outputs.xcode-version }} + + e2e: + if: github.repository == 'software-mansion/react-native-gesture-handler' + needs: [config, expo-example] + + uses: ./.github/workflows/ios-e2e.yml + with: + artifact-name: ios-app-expo-example + xcode-version: ${{ needs.config.outputs.xcode-version }} diff --git a/apps/common-app/src/common.tsx b/apps/common-app/src/common.tsx index fd12711d98..da9e92967d 100644 --- a/apps/common-app/src/common.tsx +++ b/apps/common-app/src/common.tsx @@ -144,17 +144,20 @@ type Props = { export function useIndexedLogger() { const messageCounter = useRef(0); - const logMessage = (message: string) => { + const logMessage = useCallback((message: string) => { messageCounter.current += 1; const indexedMessage = `${messageCounter.current}. ${message}`; console.log(indexedMessage); - }; + }, []); - const logMessageWorklet = (message: string) => { - 'worklet'; - // Schedule log on the JS thread so the console interceptor can pick it up - scheduleOnRN(logMessage, message); - }; + const logMessageWorklet = useCallback( + (message: string) => { + 'worklet'; + // Schedule log on the JS thread so the console interceptor can pick it up + scheduleOnRN(logMessage, message); + }, + [logMessage] + ); return logMessageWorklet; }