diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml new file mode 100644 index 0000000000..7db793a452 --- /dev/null +++ b/.github/workflows/ios-sim.yml @@ -0,0 +1,161 @@ +name: ios-sim + +on: + push: + branches: + - f/ci-ios-sim + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ios-sim-${{ github.ref }} + cancel-in-progress: true + +env: + GHC_VER: 9.6.3 + CABAL_VER: 3.10.2.0 + CORE_PKG: pkg-ios-aarch64-swift-json.zip + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_INSTALL_CLEANUP: 1 + +jobs: + core: + name: core library for the iOS simulator + runs-on: macos-latest + timeout-minutes: 240 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Show runner + run: | + sw_vers + sysctl -n hw.ncpu hw.memsize + df -h / + xcodebuild -version + + - name: Restore core library + id: core-cache + uses: actions/cache/restore@v4 + with: + path: ~/Downloads/${{ env.CORE_PKG }} + key: ios-sim-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-core.sh') }} + + - name: Setup Haskell + if: steps.core-cache.outputs.cache-hit != 'true' + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ env.GHC_VER }} + cabal-version: ${{ env.CABAL_VER }} + + - name: Restore cabal store + id: cabal-cache + if: steps.core-cache.outputs.cache-hit != 'true' + uses: actions/cache/restore@v4 + with: + path: | + ~/.cabal/store + dist-newstyle + key: ios-sim-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} + restore-keys: | + ios-sim-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- + ios-sim-ghc${{ env.GHC_VER }}- + macos-latest-ghc${{ env.GHC_VER }}- + + - name: Install libraries + if: steps.core-cache.outputs.cache-hit != 'true' + run: brew install openssl@3 libffi gmp + + - name: Build core library + id: build + if: steps.core-cache.outputs.cache-hit != 'true' + run: scripts/ci/ios-sim/build-core.sh ~/Downloads/${{ env.CORE_PKG }} + + - name: Save cabal store + if: ${{ !cancelled() && steps.core-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.cabal/store + dist-newstyle + key: ${{ steps.cabal-cache.outputs.cache-primary-key }} + + - name: Save core library + if: steps.core-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/Downloads/${{ env.CORE_PKG }} + key: ${{ steps.core-cache.outputs.cache-primary-key }} + + - name: Patch core library for the simulator + run: scripts/ci/ios-sim/prepare-libs.sh + + - name: Upload core library + uses: actions/upload-artifact@v4 + with: + name: ios-core-aarch64 + path: ~/Downloads/${{ env.CORE_PKG }} + compression-level: 0 + retention-days: 3 + if-no-files-found: error + + app: + name: app in the iOS simulator + needs: core + runs-on: macos-latest + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Show runner + run: | + sw_vers + df -h / + xcodebuild -version + xcrun simctl list runtimes + + - name: Download core library + uses: actions/download-artifact@v4 + with: + name: ios-core-aarch64 + path: ~/Downloads + + - name: Prepare libraries + run: | + scripts/ci/ios-sim/prepare-libs.sh + scripts/ios/update-pbxproj.sh + + - name: Build app + run: | + xcodebuild build -quiet \ + -project apps/ios/SimpleX.xcodeproj \ + -scheme "SimpleX (iOS)" \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ + -onlyUsePackageVersionsFromResolvedFile \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + ARCHS=arm64 \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= \ + PROVISIONING_PROFILE_SPECIFIER= + + - name: Install AXe + run: brew install cameroncooke/axe/axe + + - name: Run smoke test + run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test" + + - name: Upload smoke test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: ios-sim-smoke-test + path: ${{ runner.temp }}/smoke-test + retention-days: 3 + if-no-files-found: warn diff --git a/scripts/ci/ios-sim/build-core.sh b/scripts/ci/ios-sim/build-core.sh new file mode 100755 index 0000000000..c34a9306f9 --- /dev/null +++ b/scripts/ci/ios-sim/build-core.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Builds the iOS core library package with cabal, as flake.nix builds aarch64-darwin-ios:lib:simplex-chat. +# Usage: scripts/ci/ios-sim/build-core.sh +# The zip has the same files as the Hydra package that scripts/ios/prepare.sh expects. + +set -euo pipefail + +mkdir -p "$(dirname "$1")" +out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +ghc_ver=$(ghc --numeric-version) +version=$(sed -n 's/^version: *//p' simplex-chat.cabal) +openssl_dir=$(brew --prefix openssl@3) +ffi_dir=$(brew --prefix libffi) +gmp_dir=$(brew --prefix gmp) + +ghc --version +cabal --version +echo "rts extra-libraries: $(ghc-pkg field rts extra-libraries --simple-output)" +echo "ghc-bignum extra-libraries: $(ghc-pkg field ghc-bignum extra-libraries --simple-output)" +find "$(ghc --print-libdir)" -name 'lib*ffi*.a' -o -name 'libgmp*.a' -o -name 'libHSrts*_thr.a' +du -sh ~/.cabal/store dist-newstyle || true + +cat > cabal.project.local </dev/null || true; } \ + | awk '$NF ~ /^_(ffi_call|__gmpz_init|hs_init_with_rtsopts|chat_migrate_init_key|EVP_CIPHER_CTX_new|sqlite3_key)$/ {print " " $NF}' \ + | sort -u + +pkg=$(mktemp -d) +cp "$build_dir"/libHSsimplex-chat-*.a "$ffi_dir"/lib/libffi.a "$gmp_dir"/lib/libgmp.a "$gmp_dir"/lib/libgmpxx.a "$pkg" +chmod u+w "$pkg"/*.a +for f in "$pkg"/libHSsimplex-chat-*.a; do + strip -x "$f" +done +ls -la "$pkg" + +rm -f "$out" +(cd "$pkg" && zip -9 "$out" *.a) +ls -la "$out" diff --git a/scripts/ci/ios-sim/prepare-libs.sh b/scripts/ci/ios-sim/prepare-libs.sh new file mode 100755 index 0000000000..78287b5ccf --- /dev/null +++ b/scripts/ci/ios-sim/prepare-libs.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Builds mac2ios, runs scripts/ios/prepare.sh on ~/Downloads/pkg-ios-aarch64-swift-json.zip, +# and fails unless every object in apps/ios/Libraries/sim is marked for the iOS simulator. + +set -euo pipefail + +mac2ios_rev=4dcb77d5ea896d749381806dfab5358851b08951 +tools=$(mktemp -d) +for f in mac2ios.c ar.h macho.h; do + curl --proto '=https' --tlsv1.2 -sSfL -o "$tools/$f" "https://raw.githubusercontent.com/zw3rk/mobile-core-tools/$mac2ios_rev/$f" +done +cc -O2 -o "$tools/mac2ios" "$tools/mac2ios.c" + +PATH="$tools:$PATH" scripts/ios/prepare.sh + +for f in apps/ios/Libraries/sim/*.a; do + echo "$f: $(du -h "$f" | cut -f1), $(lipo -info "$f")" + platforms=$({ otool -l "$f" || true; } | awk '$1 == "platform" {print $2}' | sort | uniq -c) + echo "${platforms:- no LC_BUILD_VERSION found}" + if echo "$platforms" | awk 'NF {print $2}' | grep -vE '^(7|IOSSIMULATOR)$' > /dev/null; then + echo "error: $f has objects for a platform other than the iOS simulator" + exit 1 + fi +done diff --git a/scripts/ci/ios-sim/smoke-test.sh b/scripts/ci/ios-sim/smoke-test.sh new file mode 100755 index 0000000000..2ab51756d9 --- /dev/null +++ b/scripts/ci/ios-sim/smoke-test.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# Installs the app in a new iPhone simulator and drives it with AXe: onboarding, chat list, typing in the search field. +# Usage: scripts/ci/ios-sim/smoke-test.sh +# Each step saves a screenshot and an accessibility tree to the output dir and prints the tree to the log. + +set -euo pipefail + +app=$1 +out=$2 +bundle_id=chat.simplex.app +search_text="ci smoke" +mkdir -p "$out" + +axe --version +runtime=com.apple.CoreSimulator.SimRuntime.iOS-$(xcrun --sdk iphonesimulator --show-sdk-version | tr . -) +udid=$(xcrun simctl create ci-iphone com.apple.CoreSimulator.SimDeviceType.iPhone-17 "$runtime") +echo "simulator $udid, runtime $runtime" +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b +open -a Simulator --args -CurrentDeviceUDID "$udid" + +xcrun simctl spawn "$udid" log stream --level debug --style compact --predicate 'process == "SimpleX"' > "$out/app.log" 2>&1 & +log_pid=$! + +step=0 +snap() { + step=$((step + 1)) + local f + f=$(printf '%s/%02d-%s' "$out" "$step" "$1") + xcrun simctl io "$udid" screenshot "$f.png" > /dev/null + axe describe-ui --udid "$udid" > "$f.json" + echo "::group::accessibility tree: $1" + cat "$f.json" + echo "::endgroup::" +} + +wait_for() { + local deadline=$((SECONDS + $2)) + local tree="$out/wait.json" + until axe describe-ui --udid "$udid" > "$tree" && grep -qF "$1" "$tree"; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: \"$1\" not shown after $2 s" + return 1 + fi + sleep 2 + done +} + +tap_label() { + axe tap --label "$1" --wait-timeout "$2" --post-delay 2 --udid "$udid" +} + +finish() { + local status=$? + snap final || true + kill "$log_pid" || true + echo "::group::core start in the app log" + grep -E 'chatMigrateInit|startChat' "$out/app.log" || true + echo "::endgroup::" + echo "::group::app log, last 300 lines" + tail -n 300 "$out/app.log" + echo "::endgroup::" + find ~/Library/Logs/DiagnosticReports -name 'SimpleX*' -newer "$app" 2>/dev/null | while read -r report; do + echo "::group::crash report $report" + head -n 200 "$report" + echo "::endgroup::" + done || true + exit "$status" +} +trap finish EXIT + +xcrun simctl install "$udid" "$app" +xcrun simctl launch "$udid" "$bundle_id" + +wait_for "Get started" 180 +snap first-screen +tap_label "Get started" 10 + +wait_for "Create profile" 30 +tap_label "Enter profile name..." 10 || echo "warning: name field not found by label, typing into the focused field" +axe type 'citester' --udid "$udid" +snap profile-name +tap_label "Create profile" 10 + +wait_for "Continue" 60 +snap your-network +tap_label "Continue" 10 + +wait_for "Accept" 30 +snap network-commitments +tap_label "Accept" 10 + +sleep 5 +snap after-onboarding +tap_label "Allow" 20 || echo "no notifications alert to allow" + +wait_for "Search or paste SimpleX link" 60 +snap chat-list +tap_label "Search or paste SimpleX link" 10 || axe tap --value "Search or paste SimpleX link" --post-delay 2 --udid "$udid" +axe type "$search_text" --udid "$udid" +sleep 2 +snap search-typed + +tree=$(ls "$out"/*-search-typed.json) +if ! grep -qF "$search_text" "$tree"; then + echo "error: \"$search_text\" is not in the accessibility tree after typing" + exit 1 +fi +if ! grep -qF "Search in Directory" "$tree"; then + echo "error: no \"Search in Directory\" row after typing" + exit 1 +fi +echo "smoke test passed: the chat list search field shows \"$search_text\""