diff --git a/.github/workflows/browserstack-e2e-android.yml b/.github/workflows/browserstack-e2e-android.yml new file mode 100644 index 00000000..ee6e967a --- /dev/null +++ b/.github/workflows/browserstack-e2e-android.yml @@ -0,0 +1,233 @@ +# +# Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE file for details. +# +# TODO: This workflow is not enforced right now and is currently failing. +# We have an open ticket with BrowserStack waiting for them to release a new version +# of the cloud driver based on Detox version 20.43+. +name: BrowserStack E2E — Android + +on: + workflow_call: + inputs: + browserstack_project_name: + description: BrowserStack project name used for Android E2E runs + required: false + type: string + default: rn-sdk-test + browserstack_session_name: + description: BrowserStack session name used by Detox + required: false + type: string + default: android-detox-e2e + secrets: + BROWSERSTACK_USERNAME: + description: BrowserStack account username + required: true + BROWSERSTACK_ACCESS_KEY: + description: BrowserStack account access key + required: true + PING_SERVER_URL: + description: Ping server base URL for Tier 2 E2E coverage + required: false + PING_REALM_PATH: + description: Ping realm path for journey and OIDC E2E coverage + required: false + PING_COOKIE_NAME: + description: Ping cookie name used by the test environment + required: false + PING_JOURNEY_NAME: + description: Ping journey name exercised by BrowserStack E2E tests + required: false + PING_TEST_USERNAME: + description: Test account username for server-backed BrowserStack scenarios + required: false + PING_TEST_PASSWORD: + description: Test account password for server-backed BrowserStack scenarios + required: false + PING_DISCOVERY_ENDPOINT: + description: OIDC discovery endpoint used by BrowserStack E2E tests + required: false + PING_CLIENT_ID: + description: OIDC client identifier used by BrowserStack E2E tests + required: false + PING_REDIRECT_URI: + description: OIDC redirect URI used by BrowserStack E2E tests + required: false + PING_CALLBACK_TREES_ENABLED: + description: Flag enabling callback tree coverage in BrowserStack E2E tests + required: false + +permissions: + contents: read + +jobs: + e2e-android-browserstack: + name: E2E — Android BrowserStack + runs-on: ubuntu-latest + timeout-minutes: 60 + defaults: + run: + working-directory: PingTestRunner + shell: bash + + env: + # APK paths — defined once, referenced in upload steps + APP_APK_PATH: android/app/build/outputs/apk/release/app-release.apk + TEST_APK_PATH: android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk + # BrowserStack credentials available to all steps + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + # Ping server configuration available to all steps + PING_SERVER_URL: ${{ secrets.PING_SERVER_URL }} + PING_REALM_PATH: ${{ secrets.PING_REALM_PATH }} + PING_COOKIE_NAME: ${{ secrets.PING_COOKIE_NAME }} + PING_JOURNEY_NAME: ${{ secrets.PING_JOURNEY_NAME }} + PING_TEST_USERNAME: ${{ secrets.PING_TEST_USERNAME }} + PING_TEST_PASSWORD: ${{ secrets.PING_TEST_PASSWORD }} + PING_DISCOVERY_ENDPOINT: ${{ secrets.PING_DISCOVERY_ENDPOINT }} + PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} + PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} + PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js and install dependencies + uses: ./.github/actions/setup-proj + + - name: Install global Jest and Detox CLI + run: | + npm install -g jest + npm install -g detox-cli + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Compute BrowserStack identifiers + # Naming convention: + # Project : rn--test → rn-sdk-test + # Build : --- + # Session : -- + run: | + set -euo pipefail + SHORT_SHA="${GITHUB_SHA:0:7}" + case "${{ github.event_name }}" in + pull_request) + BUILD_PREFIX="pr" + ;; + push) + BUILD_PREFIX="push" + ;; + *) + BUILD_PREFIX="run" + ;; + esac + echo "BROWSERSTACK_BUILD_ID=${BUILD_PREFIX}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${SHORT_SHA}" >> "$GITHUB_ENV" + echo "BROWSERSTACK_PROJECT_NAME=${{ inputs.browserstack_project_name }}" >> "$GITHUB_ENV" + echo "BROWSERSTACK_SESSION_NAME=${{ inputs.browserstack_session_name }}" >> "$GITHUB_ENV" + + - name: Build SDK packages + # Metro bundles the JS at release build time and resolves the Ping SDK + # packages via their compiled lib/ output. Build all packages first so + # lib/module/index.js exists for every dependency. + run: yarn packages:build + working-directory: ${{ github.workspace }} + + - name: Generate codegen artifacts for all SDK modules + # React Native New Architecture requires each library's codegen JNI + # directories to exist before CMake configures the app. Run codegen + # across all included modules so the directories are present. + run: | + cd android && ./gradlew \ + :ping-identity_rn-browser:generateCodegenArtifactsFromSchema \ + :ping-identity_rn-device-id:generateCodegenArtifactsFromSchema \ + :ping-identity_rn-device-profile:generateCodegenArtifactsFromSchema \ + :ping-identity_rn-journey:generateCodegenArtifactsFromSchema \ + :ping-identity_rn-logger:generateCodegenArtifactsFromSchema \ + :ping-identity_rn-oidc:generateCodegenArtifactsFromSchema \ + :ping-identity_rn-storage:generateCodegenArtifactsFromSchema \ + :react-native-async-storage_async-storage:generateCodegenArtifactsFromSchema + + - name: Build Release APK and androidTest APK + run: yarn build:runner:bs:android + working-directory: ${{ github.workspace }} + + - name: Upload app to BrowserStack + id: upload-app + run: | + response=$(curl --silent --fail-with-body \ + -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/detox/v2/android/app" \ + -F "file=@$APP_APK_PATH") + app_url=$(echo "$response" | jq -r '.app_url') + if [[ "$app_url" == "null" || -z "$app_url" ]]; then + echo "::error::Failed to get app_url from BrowserStack response: $response" + exit 1 + fi + echo "app_url=$app_url" >> "$GITHUB_OUTPUT" + + - name: Upload app client to BrowserStack + id: upload-test + run: | + response=$(curl --silent --fail-with-body \ + -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/detox/v2/android/app-client" \ + -F "file=@$TEST_APK_PATH") + test_url=$(echo "$response" | jq -r '.app_client_url') + if [[ "$test_url" == "null" || -z "$test_url" ]]; then + echo "::error::Failed to get app_client_url from BrowserStack response: $response" + exit 1 + fi + echo "test_url=$test_url" >> "$GITHUB_OUTPUT" + + - name: Run E2E tests on BrowserStack real devices + id: run-tests + run: yarn test:runner:bs:android 2>&1 | tee /tmp/detox-bs.log; exit "${PIPESTATUS[0]}" + working-directory: ${{ github.workspace }} + env: + BROWSERSTACK_APP_URL: ${{ steps.upload-app.outputs.app_url }} + BROWSERSTACK_TEST_URL: ${{ steps.upload-test.outputs.test_url }} + + - name: Prepare test summary report + if: always() + shell: bash + run: | + set -euo pipefail + + overall_status="${{ steps.run-tests.outcome }}" + build_id="${BROWSERSTACK_BUILD_ID}" + + if [ "$overall_status" = "success" ]; then + result_label="✅ Passed" + else + result_label="❌ Failed" + fi + + # Extract Jest-style test counts written by Detox to stdout + test_detail="" + if [ -f /tmp/detox-bs.log ]; then + suites_line="$(grep -E "^Test Suites:" /tmp/detox-bs.log | tail -1 || true)" + tests_line="$(grep -E "^Tests:" /tmp/detox-bs.log | tail -1 || true)" + if [ -n "$suites_line" ] || [ -n "$tests_line" ]; then + test_detail=$'\n### Test Results\n' + [ -n "$suites_line" ] && test_detail+=$'\n'"- ${suites_line}" + [ -n "$tests_line" ] && test_detail+=$'\n'"- ${tests_line}" + fi + fi + + { + echo "## BrowserStack Android Detox Summary" + echo + echo "- Build ID: \`${build_id}\`" + echo "- Overall status: ${overall_status}" + echo "- Result: ${result_label}" + echo "- BrowserStack dashboard: https://app-automate.browserstack.com/dashboard/v2" + echo -e "$test_detail" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/browserstack-e2e-ios.yml b/.github/workflows/browserstack-e2e-ios.yml new file mode 100644 index 00000000..b9952ee1 --- /dev/null +++ b/.github/workflows/browserstack-e2e-ios.yml @@ -0,0 +1,264 @@ +# +# Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE file for details. +# +name: BrowserStack E2E — iOS + +on: + workflow_call: + secrets: + BROWSERSTACK_USERNAME: + description: BrowserStack account username + required: true + BROWSERSTACK_ACCESS_KEY: + description: BrowserStack account access key + required: true + PING_SERVER_URL: + description: Ping server base URL for Tier 2 E2E coverage + required: false + PING_REALM_PATH: + description: Ping realm path for journey and OIDC E2E coverage + required: false + PING_COOKIE_NAME: + description: Ping cookie name used by the test environment + required: false + PING_JOURNEY_NAME: + description: Ping journey name exercised by BrowserStack E2E tests + required: false + PING_TEST_USERNAME: + description: Test account username for server-backed BrowserStack scenarios + required: false + PING_TEST_PASSWORD: + description: Test account password for server-backed BrowserStack scenarios + required: false + PING_DISCOVERY_ENDPOINT: + description: OIDC discovery endpoint used by BrowserStack E2E tests + required: false + PING_CLIENT_ID: + description: OIDC client identifier used by BrowserStack E2E tests + required: false + PING_REDIRECT_URI: + description: OIDC redirect URI used by BrowserStack E2E tests + required: false + PING_CALLBACK_TREES_ENABLED: + description: Flag enabling callback tree coverage in BrowserStack E2E tests + required: false + CERTIFICATES_FILE_BASE64: + description: Apple signing certificate exported as base64 encoded .p12 + required: true + CERTIFICATES_PASSWORD: + description: Password for the Apple signing certificate .p12 + required: true + KEYCHAIN_PASSWORD: + description: Password for the temporary CI keychain + required: true + BUILD_PROVISION_PROFILE: + description: Base64 encoded zip containing the iOS build provisioning profile + required: true + UI_TEST_PROVISION_PROFILE: + description: Base64 encoded zip containing the iOS UI test provisioning profile + required: true + APPLE_TEAM_ID: + description: Apple Developer team identifier used for signing + required: true + +jobs: + prepare-ios-artifacts: + name: Prepare iOS BrowserStack artifacts + uses: ./.github/workflows/browserstack-prep-ios-artifacts.yml + secrets: + CERTIFICATES_FILE_BASE64: ${{ secrets.CERTIFICATES_FILE_BASE64 }} + CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + BUILD_PROVISION_PROFILE: ${{ secrets.BUILD_PROVISION_PROFILE }} + UI_TEST_PROVISION_PROFILE: ${{ secrets.UI_TEST_PROVISION_PROFILE }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + e2e-ios-browserstack: + name: E2E — iOS BrowserStack + runs-on: ubuntu-latest + needs: prepare-ios-artifacts + timeout-minutes: 60 + outputs: + build_id: ${{ steps.start-build.outputs.build_id }} + + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + BROWSERSTACK_IOS_DEVICES: ${{ vars.BROWSERSTACK_IOS_DEVICES }} + PING_SERVER_URL: ${{ secrets.PING_SERVER_URL }} + PING_REALM_PATH: ${{ secrets.PING_REALM_PATH }} + PING_COOKIE_NAME: ${{ secrets.PING_COOKIE_NAME }} + PING_JOURNEY_NAME: ${{ secrets.PING_JOURNEY_NAME }} + PING_TEST_USERNAME: ${{ secrets.PING_TEST_USERNAME }} + PING_TEST_PASSWORD: ${{ secrets.PING_TEST_PASSWORD }} + PING_DISCOVERY_ENDPOINT: ${{ secrets.PING_DISCOVERY_ENDPOINT }} + PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} + PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} + PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} + + steps: + - name: Download IPA artifact + uses: actions/download-artifact@v4 + with: + name: pingtestrunner-ios-bs-app + path: /tmp/pingtestrunner-ios-bs-app + + - name: Download XCUITest suite artifact + uses: actions/download-artifact@v4 + with: + name: pingtestrunner-ios-bs-test-suite + path: /tmp/pingtestrunner-ios-bs-test-suite + + - name: Compute BrowserStack identifiers + run: | + set -euo pipefail + + SHORT_SHA="${GITHUB_SHA:0:7}" + echo "BROWSERSTACK_BUILD_TAG=pr_${{ github.event.pull_request.number }}_${SHORT_SHA}" >> "$GITHUB_ENV" + echo "BROWSERSTACK_PROJECT_NAME=rn-sdk-test" >> "$GITHUB_ENV" + echo "BROWSERSTACK_BUILD_NAME=ios_xcuitest_${SHORT_SHA}" >> "$GITHUB_ENV" + + - name: Validate BrowserStack device configuration + run: | + set -euo pipefail + + if [ -z "$BROWSERSTACK_IOS_DEVICES" ]; then + echo "::error::Set repository variable BROWSERSTACK_IOS_DEVICES to a JSON array of iOS devices." + exit 1 + fi + + echo "$BROWSERSTACK_IOS_DEVICES" | jq -e '. | arrays and length > 0' > /dev/null + + - name: Upload app to BrowserStack + id: upload-app + run: | + set -euo pipefail + + IPA_PATH="$(find /tmp/pingtestrunner-ios-bs-app -name '*.ipa' | head -n 1)" + response="$(curl --silent --fail-with-body \ + -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app" \ + -F "file=@${IPA_PATH}")" + echo "$response" > /tmp/bs-ios-app-upload.json + + app_url="$(echo "$response" | jq -r '.app_url')" + if [ "$app_url" = "null" ] || [ -z "$app_url" ]; then + echo "::error::Failed to get app_url from BrowserStack response: $response" + exit 1 + fi + + echo "app_url=$app_url" >> "$GITHUB_OUTPUT" + + - name: Upload XCUITest suite to BrowserStack + id: upload-test-suite + run: | + set -euo pipefail + + TEST_SUITE_PATH="$(find /tmp/pingtestrunner-ios-bs-test-suite -name 'PingTestRunnerUITests-Runner.zip' | head -n 1)" + response="$(curl --silent --fail-with-body \ + -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite" \ + -F "file=@${TEST_SUITE_PATH}")" + echo "$response" > /tmp/bs-ios-test-suite-upload.json + + test_suite_url="$(echo "$response" | jq -r '.test_suite_url')" + if [ "$test_suite_url" = "null" ] || [ -z "$test_suite_url" ]; then + echo "::error::Failed to get test_suite_url from BrowserStack response: $response" + exit 1 + fi + + echo "test_suite_url=$test_suite_url" >> "$GITHUB_OUTPUT" + + - name: Start BrowserStack XCUITest build + id: start-build + run: | + set -euo pipefail + + payload="$( + jq -cn \ + --arg app "${{ steps.upload-app.outputs.app_url }}" \ + --arg testSuite "${{ steps.upload-test-suite.outputs.test_suite_url }}" \ + --arg project "$BROWSERSTACK_PROJECT_NAME" \ + --arg buildTag "$BROWSERSTACK_BUILD_TAG" \ + --arg buildName "$BROWSERSTACK_BUILD_NAME" \ + --argjson devices "$BROWSERSTACK_IOS_DEVICES" \ + --arg pingServerUrl "$PING_SERVER_URL" \ + --arg pingRealmPath "$PING_REALM_PATH" \ + --arg pingCookieName "$PING_COOKIE_NAME" \ + --arg pingJourneyName "$PING_JOURNEY_NAME" \ + --arg pingTestUsername "$PING_TEST_USERNAME" \ + --arg pingTestPassword "$PING_TEST_PASSWORD" \ + --arg pingDiscoveryEndpoint "$PING_DISCOVERY_ENDPOINT" \ + --arg pingClientId "$PING_CLIENT_ID" \ + --arg pingRedirectUri "$PING_REDIRECT_URI" \ + --arg pingCallbackTreesEnabled "$PING_CALLBACK_TREES_ENABLED" \ + '{ + app: $app, + testSuite: $testSuite, + project: $project, + buildTag: $buildTag, + build: $buildName, + devices: $devices, + deviceLogs: true, + networkLogs: true, + video: true, + setEnvVariables: { + PING_SERVER_URL: $pingServerUrl, + PING_REALM_PATH: $pingRealmPath, + PING_COOKIE_NAME: $pingCookieName, + PING_JOURNEY_NAME: $pingJourneyName, + PING_TEST_USERNAME: $pingTestUsername, + PING_TEST_PASSWORD: $pingTestPassword, + PING_DISCOVERY_ENDPOINT: $pingDiscoveryEndpoint, + PING_CLIENT_ID: $pingClientId, + PING_REDIRECT_URI: $pingRedirectUri, + PING_CALLBACK_TREES_ENABLED: $pingCallbackTreesEnabled + } + }' + )" + + http_code="$(echo "$payload" | \ + curl --silent \ + --write-out "%{http_code}" \ + --output /tmp/bs-ios-build-start.json \ + -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build" \ + -H "Content-Type: application/json" \ + --data-binary @-)" + + response="$(cat /tmp/bs-ios-build-start.json)" + echo "BrowserStack HTTP status: $http_code" + echo "BrowserStack response: $response" + + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "::error::BrowserStack API returned HTTP $http_code: $response" + exit 1 + fi + + message="$(echo "$response" | jq -r '.message')" + if [ "$message" != "Success" ]; then + echo "::error::BrowserStack did not confirm build start (message: $message): $response" + exit 1 + fi + + build_id="$(echo "$response" | jq -r '.build_id')" + if [ "$build_id" = "null" ] || [ -z "$build_id" ]; then + echo "::error::Failed to get build_id from BrowserStack response: $response" + exit 1 + fi + + echo "build_id=$build_id" >> "$GITHUB_OUTPUT" + + parse-ios-results: + name: Parse iOS BrowserStack results + needs: e2e-ios-browserstack + if: always() && needs.e2e-ios-browserstack.result != 'skipped' && needs.e2e-ios-browserstack.outputs.build_id != '' + uses: ./.github/workflows/browserstack-parse-results-ios.yml + with: + browserstack-build-id: ${{ needs.e2e-ios-browserstack.outputs.build_id }} + secrets: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} diff --git a/.github/workflows/browserstack-parse-results-ios.yml b/.github/workflows/browserstack-parse-results-ios.yml new file mode 100644 index 00000000..6a5a65a4 --- /dev/null +++ b/.github/workflows/browserstack-parse-results-ios.yml @@ -0,0 +1,121 @@ +# +# Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE file for details. +# +name: BrowserStack — Parse iOS Test Results + +on: + workflow_call: + inputs: + browserstack-build-id: + description: BrowserStack XCUITest build ID to poll and report on + type: string + required: true + + secrets: + BROWSERSTACK_USERNAME: + description: BrowserStack account username + required: true + BROWSERSTACK_ACCESS_KEY: + description: BrowserStack account access key + required: true + +jobs: + parse-results: + name: Parse iOS BrowserStack results + runs-on: ubuntu-latest + + steps: + - name: Wait for BrowserStack XCUITest build to finish + timeout-minutes: 40 + shell: bash + run: | + set -euo pipefail + + build_id="${{ inputs.browserstack-build-id }}" + echo "Polling BrowserStack build: $build_id" + + for attempt in $(seq 1 120); do + response="$(curl --silent --fail-with-body \ + -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + -X GET "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/builds/${build_id}")" + + status="$(echo "$response" | jq -r '.status')" + echo "Attempt $attempt — status: $status" + + case "$status" in + passed) + echo "✅ BrowserStack build passed." + exit 0 + ;; + failed|error|timedout) + echo "::error::BrowserStack XCUITest build finished with status: $status" + exit 1 + ;; + null) + echo "::error::Unexpected response (status is null): $response" + exit 1 + ;; + esac + + sleep 15 + done + + echo "::error::Timed out waiting for BrowserStack XCUITest build to complete." + exit 1 + + - name: Prepare test summary report + if: always() + shell: bash + run: | + set -euo pipefail + + build_id="${{ inputs.browserstack-build-id }}" + + response="$(curl --silent --fail-with-body \ + -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + -X GET "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/builds/${build_id}")" + + echo "$response" > /tmp/bs-ios-results.json + + total_devices="$(jq '.devices | length' /tmp/bs-ios-results.json)" + overall_status="$(jq -r '.status' /tmp/bs-ios-results.json)" + summary="" + failed_count=0 + + for (( i=0; i> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/browserstack-prep-ios-artifacts.yml b/.github/workflows/browserstack-prep-ios-artifacts.yml new file mode 100644 index 00000000..6b684502 --- /dev/null +++ b/.github/workflows/browserstack-prep-ios-artifacts.yml @@ -0,0 +1,190 @@ +# +# Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +# +# This software may be modified and distributed under the terms +# of the MIT license. See the LICENSE file for details. +# +name: Prepare iOS BrowserStack artifacts + +on: + workflow_call: + secrets: + CERTIFICATES_FILE_BASE64: + description: Apple signing certificate exported as base64 encoded .p12 + required: true + CERTIFICATES_PASSWORD: + description: Password for the Apple signing certificate .p12 + required: true + KEYCHAIN_PASSWORD: + description: Password for the temporary CI keychain + required: true + BUILD_PROVISION_PROFILE: + description: Base64 encoded zip containing the iOS build provisioning profile + required: true + # XCUITest builds sign a separate UI test runner product in addition to the host app, + # so CI needs a second provisioning profile unless one wildcard profile covers both IDs. + # BrowserStack also asks for two separately-signed products — the host app + # and test suite — each needing its own valid profile. + UI_TEST_PROVISION_PROFILE: + description: Base64 encoded zip containing the iOS UI test provisioning profile + required: true + APPLE_TEAM_ID: + description: Apple Developer team identifier used for signing + required: true + +jobs: + prepare-ios-bs-artifacts: + name: Prepare iOS BrowserStack artifacts + runs-on: macos-26-xlarge + timeout-minutes: 60 + + env: + XCODE_VERSION: ${{ vars.XCODE_VERSION || '26.1.1' }} + RUBY_VERSION: '2.6.10' + BUNDLE_GEMFILE: ${{ github.workspace }}/PingTestRunner/Gemfile + PING_TEST_RUNNER_DIR: PingTestRunner + PING_TEST_RUNNER_IOS_DIR: PingTestRunner/ios + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup project dependencies + uses: ./.github/actions/setup-proj + + - name: Select Xcode version + run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" + + - name: Show Xcode version + run: xcodebuild -version + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + + - name: Install CocoaPods + working-directory: ${{ env.PING_TEST_RUNNER_DIR }} + run: bundle exec pod install --project-directory=ios + + - name: Install Apple certificate and provisioning profile + env: + CERTIFICATES_FILE_BASE64: ${{ secrets.CERTIFICATES_FILE_BASE64 }} + CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + BUILD_PROVISION_PROFILE: ${{ secrets.BUILD_PROVISION_PROFILE }} + UI_TEST_PROVISION_PROFILE: ${{ secrets.UI_TEST_PROVISION_PROFILE }} + run: | + set -euo pipefail + + CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12" + APP_PROFILE_ZIP_PATH="$RUNNER_TEMP/build_profile.zip" + APP_PROFILE_EXTRACT_DIR="$RUNNER_TEMP/build-profile" + UI_TEST_PROFILE_ZIP_PATH="$RUNNER_TEMP/ui_test_profile.zip" + UI_TEST_PROFILE_EXTRACT_DIR="$RUNNER_TEMP/ui-test-profile" + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" + APP_PROFILE_PLIST="$RUNNER_TEMP/build-profile.plist" + UI_TEST_PROFILE_PLIST="$RUNNER_TEMP/ui-test-profile.plist" + + echo -n "$CERTIFICATES_FILE_BASE64" | base64 -D -o "$CERTIFICATE_PATH" + echo -n "$BUILD_PROVISION_PROFILE" | base64 -D -o "$APP_PROFILE_ZIP_PATH" + echo -n "$UI_TEST_PROVISION_PROFILE" | base64 -D -o "$UI_TEST_PROFILE_ZIP_PATH" + unzip -o "$APP_PROFILE_ZIP_PATH" -d "$APP_PROFILE_EXTRACT_DIR" + unzip -o "$UI_TEST_PROFILE_ZIP_PATH" -d "$UI_TEST_PROFILE_EXTRACT_DIR" + + APP_PROFILE_PATH="$(find "$APP_PROFILE_EXTRACT_DIR" -name '*.mobileprovision' | head -n 1)" + UI_TEST_PROFILE_PATH="$(find "$UI_TEST_PROFILE_EXTRACT_DIR" -name '*.mobileprovision' | head -n 1)" + if [ -z "$APP_PROFILE_PATH" ]; then + echo "::error::No .mobileprovision file found in BUILD_PROVISION_PROFILE" + exit 1 + fi + if [ -z "$UI_TEST_PROFILE_PATH" ]; then + echo "::error::No .mobileprovision file found in UI_TEST_PROVISION_PROFILE" + exit 1 + fi + + security cms -D -i "$APP_PROFILE_PATH" > "$APP_PROFILE_PLIST" + BUILD_PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$APP_PROFILE_PLIST")" + BUILD_PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$APP_PROFILE_PLIST")" + + security cms -D -i "$UI_TEST_PROFILE_PATH" > "$UI_TEST_PROFILE_PLIST" + UI_TEST_PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$UI_TEST_PROFILE_PLIST")" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "$CERTIFICATES_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + security default-keychain -d user -s "$KEYCHAIN_PATH" + + mkdir -p "$PROFILES_DIR" + cp "$APP_PROFILE_PATH" "$PROFILES_DIR/${BUILD_PROFILE_UUID}.mobileprovision" + cp "$UI_TEST_PROFILE_PATH" "$PROFILES_DIR/${UI_TEST_PROFILE_UUID}.mobileprovision" + + echo "BUILD_PROFILE_NAME=$BUILD_PROFILE_NAME" >> "$GITHUB_ENV" + echo "BUILD_PROFILE_UUID=$BUILD_PROFILE_UUID" >> "$GITHUB_ENV" + echo "UI_TEST_PROFILE_UUID=$UI_TEST_PROFILE_UUID" >> "$GITHUB_ENV" + + - name: Build SDK packages + run: yarn packages:build + + - name: Generate export options plist for CI signing + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + + cat > "$RUNNER_TEMP/exportOptions.plist" < + + + + destination + export + method + development + provisioningProfiles + + com.pingidentity.rntestrunner + ${BUILD_PROFILE_NAME} + + signingStyle + manual + stripSwiftSymbols + + teamID + ${APPLE_TEAM_ID} + thinning + <none> + + + EOF + + plutil -lint "$RUNNER_TEMP/exportOptions.plist" + echo "BS_IOS_EXPORT_OPTIONS_PLIST=$RUNNER_TEMP/exportOptions.plist" >> "$GITHUB_ENV" + + - name: Archive app and build test suite + run: | + export BS_IOS_ARCHIVE_XCODEBUILD_ARGS="DEVELOPMENT_TEAM=${{ secrets.APPLE_TEAM_ID }} CODE_SIGN_STYLE=Manual PROVISIONING_PROFILE=$BUILD_PROFILE_UUID -allowProvisioningUpdates" + export BS_IOS_TEAM_ID="${{ secrets.APPLE_TEAM_ID }}" + export BS_IOS_APP_PROFILE_UUID="$BUILD_PROFILE_UUID" + export BS_IOS_UI_TEST_PROFILE_UUID="$UI_TEST_PROFILE_UUID" + export BS_IOS_TEST_SUITE_XCODEBUILD_ARGS="-allowProvisioningUpdates" + yarn build:runner:bs:ios + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + with: + name: pingtestrunner-ios-bs-app + path: PingTestRunner/ios/build/browserstack/export/*.ipa + if-no-files-found: error + + - name: Upload XCUITest suite artifact + uses: actions/upload-artifact@v4 + with: + name: pingtestrunner-ios-bs-test-suite + path: PingTestRunner/ios/build/browserstack-derived-data/Build/Products/Debug-iphoneos/PingTestRunnerUITests-Runner.zip + if-no-files-found: error diff --git a/.github/workflows/build-and-test-android.yml b/.github/workflows/build-and-test-android.yml index b64329d9..84f37291 100644 --- a/.github/workflows/build-and-test-android.yml +++ b/.github/workflows/build-and-test-android.yml @@ -35,10 +35,10 @@ jobs: java-version: '17' cache: gradle - # Run Android unit tests from all packages using Gradle. + # Run Android unit tests and generate JaCoCo coverage report from all packages. - name: Run Android unit tests working-directory: PingSampleApp/android - run: ./gradlew --no-daemon --stacktrace testDebugUnitTest + run: ./gradlew --no-daemon --stacktrace testDebugUnitTest createDebugUnitTestCoverageReport # Publish test results as GitHub check runs. - name: Publish test report @@ -51,13 +51,24 @@ jobs: list-suites: 'all' list-tests: 'all' fail-on-error: 'false' - + - name: Upload Android unit tests HTML report if: success() || failure() uses: actions/upload-artifact@v7 with: name: Android Unit Test Report path: '**/build/reports/tests/testDebugUnitTest/**' + + # Upload JaCoCo coverage reports to Codecov. + - name: Upload coverage to Codecov + if: success() || failure() + continue-on-error: true + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: 'packages/*/android/build/reports/coverage/test/debug/report.xml' + flags: android + fail_ci_if_error: false diff --git a/.github/workflows/build-and-test-ios.yml b/.github/workflows/build-and-test-ios.yml index a1cd98b6..242aceaa 100644 --- a/.github/workflows/build-and-test-ios.yml +++ b/.github/workflows/build-and-test-ios.yml @@ -5,151 +5,168 @@ # of the MIT license. See the LICENSE file for details. # name: Build and Test iOS -on: - workflow_call: +on: + workflow_call: env: - # Define environment variables for the workflow. - # These can be overridden by the caller if needed. - WORKSPACE: PingTestRunner/ios/PingTestRunner.xcworkspace - SCHEME: RNPackagesTests - CONFIGURATION: Debug - DESTINATION: platform=iOS Simulator,name=iPhone 16e,OS=latest - DERIVED_DATA_PATH: DerivedData - XCODE_VERSION: '26.1.1' - RUBY_VERSION: '2.6.10' - BUNDLE_GEMFILE: ${{ github.workspace }}/PingTestRunner/Gemfile - IOS_DIRECTORY: PingTestRunner/ios + # Define environment variables for the workflow. + # These can be overridden by the caller if needed. + WORKSPACE: PingTestRunner/ios/PingTestRunner.xcworkspace + SCHEME: RNPackagesTests + CONFIGURATION: Debug + DESTINATION: platform=iOS Simulator,name=iPhone 17,OS=26.4 + DERIVED_DATA_PATH: DerivedData + XCODE_VERSION: '26.1.1' + RUBY_VERSION: '2.6.10' + BUNDLE_GEMFILE: ${{ github.workspace }}/PingTestRunner/Gemfile + IOS_DIRECTORY: PingTestRunner/ios jobs: - build-and-test-ios: - runs-on: macos-15-large - timeout-minutes: 35 + build-and-test-ios: + runs-on: macos-26-xlarge + timeout-minutes: 35 - # TODO: remove continue-on-error after all build failures are fixed. + # TODO: remove continue-on-error after all build failures are fixed. + continue-on-error: true + + steps: + # Checkout with full history for git-based affected detection. + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # Show versions of key tools for debugging and verification purposes. + - name: Show tool versions + run: | + xcodebuild -version + swift --version || true + node --version || true + ruby --version || true + + # Enable Corepack to manage yarn ensuring consistent dependency management across environments. + - name: Enable Corepack + run: corepack enable + + # Setup Ruby for managing CocoaPods dependencies. + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + + - name: Cache CocoaPods + uses: actions/cache@v3 + with: + path: | + ${{ env.IOS_DIRECTORY }}/Pods + ~/Library/Caches/CocoaPods + ~/.cocoapods + key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods- + + # Cache DerivedData + # Note: restore-keys is intentionally omitted. A partial/stale DerivedData cache + # causes Clang module mtime mismatch errors when SDK framework files on the runner + # are newer than the cached .pcm files. Only an exact cache hit is safe to restore. + - name: Cache DerivedData + uses: actions/cache@v3 + with: + path: ${{ env.DERIVED_DATA_PATH }} + key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-deriveddata-${{ hashFiles('PingTestRunner/ios/Podfile.lock', 'PingTestRunner/ios/PingTestRunner.xcodeproj/project.pbxproj', 'PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme', 'packages/*/*.podspec', 'packages/*/ios/**/*.swift', 'packages/*/ios/**/*.{m,mm,h,cpp}') }} + # Setup Node.js environment and install dependencies. + - name: Setup project dependencies + uses: ./.github/actions/setup-proj + + #Install CocoaPods + - name: Install CocoaPods + working-directory: ${{ env.IOS_DIRECTORY }} + run: bundle exec pod install --repo-update + + # Select Xcode version + - name: Select Xcode version + run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app && /usr/bin/xcodebuild -version + + # Clang module caches are sensitive to SDK header mtimes on the runner image. + # This must run AFTER Xcode is selected so the active SDK is finalised. + # Keep the broader DerivedData cache for build products, but always force module + # and SDK stat caches to be rebuilt to avoid stale .pcm restore failures. + - name: Reset Xcode module caches + run: | + rm -rf "${{ github.workspace }}/${{ env.DERIVED_DATA_PATH }}/ModuleCache.noindex" + rm -rf "${{ github.workspace }}/${{ env.DERIVED_DATA_PATH }}/SDKStatCaches.noindex" + + # Install xcresultparser to .xcresult files generated by xcodebuild for test reporting. + - name: Install xcresultparser + run: | + curl -LO https://github.com/a7ex/xcresultparser/releases/download/1.9.3/xcresultparser.zip + unzip xcresultparser.zip + # Fix: The binary is inside the 'product' folder + chmod +x product/xcresultparser + sudo mv product/xcresultparser /usr/local/bin/ + + # Reset only the target simulator to reduce state leakage without erasing all devices. + - name: Prepare target simulator + run: | + xcrun simctl shutdown all || true + xcrun simctl erase "iPhone 17" || true + xcrun simctl boot "iPhone 17" || true + xcrun simctl bootstatus "iPhone 17" -b || true + + # Run iOS unit tests using xcodebuild. + - name: Run iOS unit tests + run: | + set -o pipefail + xcodebuild \ + -workspace ${{ env.WORKSPACE }} \ + -scheme ${{ env.SCHEME }} \ + -destination "${{ env.DESTINATION }}" \ + -derivedDataPath ${{ env.DERIVED_DATA_PATH }} \ + -resultBundlePath TestResults.xcresult \ + -enableCodeCoverage YES \ + -parallel-testing-enabled YES \ + -parallel-testing-worker-count 2 \ + test \ + | xcbeautify \ + | tee xcodebuild.log + + # Upload xcresult for debugging purposes + - name: Upload xcresult (debug) + if: always() + uses: actions/upload-artifact@v7 + with: + name: TestResults.xcresult + path: TestResults.xcresult + + # Convert test results to JUnit format + - name: Convert Test Results to JUnit + if: always() + run: xcresultparser -o junit TestResults.xcresult > test-report.xml + + # Convert coverage data to Cobertura format for reporting + - name: Generate coverage report (Cobertura) + if: always() + run: xcresultparser -o cobertura TestResults.xcresult > coverage.xml + + # Upload coverage report to Codecov + - name: Upload coverage to Codecov + if: always() continue-on-error: true + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + flags: ios + fail_ci_if_error: false - steps: - # Checkout with full history for git-based affected detection. - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - # Show versions of key tools for debugging and verification purposes. - - name: Show tool versions - run: | - xcodebuild -version - swift --version || true - node --version || true - ruby --version || true - - # Enable Corepack to manage yarn ensuring consistent dependency management across environments. - - name: Enable Corepack - run: corepack enable - - # Setup Ruby for managing CocoaPods dependencies. - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ env.RUBY_VERSION }} - bundler-cache: true - - - name: Cache CocoaPods - uses: actions/cache@v3 - with: - path: | - ${{ env.IOS_DIRECTORY }}/Pods - ~/Library/Caches/CocoaPods - ~/.cocoapods - key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} - restore-keys: | - ${{ runner.os }}-pods- - - # Cache DerivedData - # Note: restore-keys is intentionally omitted. A partial/stale DerivedData cache - # causes Clang module mtime mismatch errors when SDK framework files on the runner - # are newer than the cached .pcm files. Only an exact cache hit is safe to restore. - - name: Cache DerivedData - uses: actions/cache@v3 - with: - path: ${{ env.DERIVED_DATA_PATH }} - key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-deriveddata-${{ hashFiles('PingTestRunner/ios/Podfile.lock', 'PingTestRunner/ios/PingTestRunner.xcodeproj/project.pbxproj', 'PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme', 'packages/*/*.podspec', 'packages/*/ios/**/*.swift', 'packages/*/ios/**/*.{m,mm,h,cpp}') }} - # Setup Node.js environment and install dependencies. - - name: Setup project dependencies - uses: ./.github/actions/setup-proj - - #Install CocoaPods - - name: Install CocoaPods - working-directory: ${{ env.IOS_DIRECTORY }} - run: bundle exec pod install --repo-update - - # Select Xcode version - - name: Select Xcode version - run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app && /usr/bin/xcodebuild -version - - # Clang module caches are sensitive to SDK header mtimes on the runner image. - # This must run AFTER Xcode is selected so the active SDK is finalised. - # Keep the broader DerivedData cache for build products, but always force module - # and SDK stat caches to be rebuilt to avoid stale .pcm restore failures. - - name: Reset Xcode module caches - run: | - rm -rf "${{ github.workspace }}/${{ env.DERIVED_DATA_PATH }}/ModuleCache.noindex" - rm -rf "${{ github.workspace }}/${{ env.DERIVED_DATA_PATH }}/SDKStatCaches.noindex" - - # Install xcresultparser to .xcresult files generated by xcodebuild for test reporting. - - name: Install xcresultparser - run: | - curl -LO https://github.com/a7ex/xcresultparser/releases/download/1.9.3/xcresultparser.zip - unzip xcresultparser.zip - # Fix: The binary is inside the 'product' folder - chmod +x product/xcresultparser - sudo mv product/xcresultparser /usr/local/bin/ - - # Reset only the target simulator to reduce state leakage without erasing all devices. - - name: Prepare target simulator - run: | - xcrun simctl shutdown all || true - xcrun simctl erase "iPhone 16e" || true - xcrun simctl boot "iPhone 16e" || true - xcrun simctl bootstatus "iPhone 16e" -b || true - - # Run iOS unit tests using xcodebuild. - - name: Run iOS unit tests - run: | - set -o pipefail - xcodebuild \ - -workspace ${{ env.WORKSPACE }} \ - -scheme ${{ env.SCHEME }} \ - -destination "${{ env.DESTINATION }}" \ - -derivedDataPath ${{ env.DERIVED_DATA_PATH }} \ - -resultBundlePath TestResults.xcresult \ - -parallel-testing-enabled YES \ - -parallel-testing-worker-count 2 \ - test \ - | xcbeautify \ - | tee xcodebuild.log - - # Upload xcresult for debugging purposes - - name: Upload xcresult (debug) - if: always() - uses: actions/upload-artifact@v7 - with: - name: TestResults.xcresult - path: TestResults.xcresult - - # Convert test results to JUnit format - - name: Convert Test Results to JUnit - if: always() - run: xcresultparser -o junit TestResults.xcresult > test-report.xml - - # Publish the test results - - name: Publish test results - if: always() - uses: dorny/test-reporter@v2 - with: - name: Unit tests results - path: test-report.xml - reporter: java-junit - list-suites: 'all' - list-tests: 'all' - fail-on-error: 'false' + # Publish the test results + - name: Publish test results + if: always() + uses: dorny/test-reporter@v2 + with: + name: Unit tests results + path: test-report.xml + reporter: java-junit + list-suites: 'all' + list-tests: 'all' + fail-on-error: 'false' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6c526e7..ac008ae8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,16 +27,16 @@ jobs: lint-and-typecheck: uses: ./.github/workflows/lint-and-typecheck.yml with: - turbo_scm_base: ${{ github.event.pull_request.base.sha }} - turbo_scm_head: ${{ github.event.pull_request.head.sha }} + turbo_scm_base: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + turbo_scm_head: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} # Build affected packages to verify compilation succeeds. build-packages-check: needs: lint-and-typecheck uses: ./.github/workflows/build-packages.yml with: - turbo_scm_base: ${{ github.event.pull_request.base.sha }} - turbo_scm_head: ${{ github.event.pull_request.head.sha }} + turbo_scm_base: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + turbo_scm_head: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} # Execute Jest unit tests across affected packages. js-unit-tests: @@ -46,8 +46,9 @@ jobs: checks: write uses: ./.github/workflows/js-unit-tests.yml with: - turbo_scm_base: ${{ github.event.pull_request.base.sha }} - turbo_scm_head: ${{ github.event.pull_request.head.sha }} + turbo_scm_base: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + turbo_scm_head: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + secrets: inherit # Execute Android unit tests across affected packages. android-unit-tests: @@ -56,6 +57,7 @@ jobs: contents: read checks: write uses: ./.github/workflows/build-and-test-android.yml + secrets: inherit # Execute iOS unit tests across affected packages. ios-unit-tests: @@ -64,6 +66,56 @@ jobs: contents: read checks: write uses: ./.github/workflows/build-and-test-ios.yml + secrets: inherit + + # Run Android E2E tests on BrowserStack real devices after Android unit tests pass. + # Blocked: BrowserStack cloud driver does not yet support Detox 20.43+. + # Remove the `if false` guard once the upstream fix is released. + browserstack-android: + if: false + needs: android-unit-tests + permissions: + contents: read + uses: ./.github/workflows/browserstack-e2e-android.yml + secrets: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + PING_SERVER_URL: ${{ secrets.PING_SERVER_URL }} + PING_REALM_PATH: ${{ secrets.PING_REALM_PATH }} + PING_COOKIE_NAME: ${{ secrets.PING_COOKIE_NAME }} + PING_JOURNEY_NAME: ${{ secrets.PING_JOURNEY_NAME }} + PING_TEST_USERNAME: ${{ secrets.PING_TEST_USERNAME }} + PING_TEST_PASSWORD: ${{ secrets.PING_TEST_PASSWORD }} + PING_DISCOVERY_ENDPOINT: ${{ secrets.PING_DISCOVERY_ENDPOINT }} + PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} + PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} + PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} + + # Run iOS E2E tests on BrowserStack real devices after iOS unit tests pass. + browserstack-ios: + needs: ios-unit-tests + permissions: + contents: read + uses: ./.github/workflows/browserstack-e2e-ios.yml + secrets: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + PING_SERVER_URL: ${{ secrets.PING_SERVER_URL }} + PING_REALM_PATH: ${{ secrets.PING_REALM_PATH }} + PING_COOKIE_NAME: ${{ secrets.PING_COOKIE_NAME }} + PING_JOURNEY_NAME: ${{ secrets.PING_JOURNEY_NAME }} + PING_TEST_USERNAME: ${{ secrets.PING_TEST_USERNAME }} + PING_TEST_PASSWORD: ${{ secrets.PING_TEST_PASSWORD }} + PING_DISCOVERY_ENDPOINT: ${{ secrets.PING_DISCOVERY_ENDPOINT }} + PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} + PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} + PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} + CERTIFICATES_FILE_BASE64: ${{ secrets.CERTIFICATES_FILE_BASE64 }} + CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + BUILD_PROVISION_PROFILE: ${{ secrets.BUILD_PROVISION_PROFILE }} + UI_TEST_PROVISION_PROFILE: ${{ secrets.UI_TEST_PROVISION_PROFILE }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} # Publish documentation preview to GitHub Pages on every PR for review. # Allows reviewers to preview documentation changes before merging. @@ -76,5 +128,3 @@ jobs: uses: ./.github/workflows/preview-docs.yml with: pr_number: ${{ github.event.pull_request.number }} - - diff --git a/.github/workflows/cleanup-docs-preview.yml b/.github/workflows/cleanup-docs-preview.yml index 75dc7a2d..73f096c8 100644 --- a/.github/workflows/cleanup-docs-preview.yml +++ b/.github/workflows/cleanup-docs-preview.yml @@ -12,6 +12,8 @@ on: permissions: contents: write + pull-requests: write + issues: write concurrency: group: preview-pages-${{ github.ref }} @@ -27,7 +29,6 @@ jobs: uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1.8.1 with: action: remove - pr-number: ${{ github.event.number }} + pr-number: ${{ github.event.pull_request.number }} preview-branch: gh-pages umbrella-dir: docs-preview - pages-base-path: docs diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index ce3aef09..37a7fe67 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -7,11 +7,15 @@ name: E2E Tests on: - push: - branches: [main] - pull_request: - types: [opened, reopened, synchronize] - branches: [main] + # push: + # branches: [main] + # pull_request: + # types: [opened, reopened, synchronize] + # branches: [main] + workflow_call: + # TODO: Once lefthook is set up, this workflow will also be triggered as a + # pre-push hook via the gh CLI, so that e2e tests run in GitHub Actions + # before the push completes. # Cancel in-progress runs for the same ref. concurrency: @@ -26,21 +30,35 @@ jobs: # ── iOS E2E (macOS required for simulator) ──────────────────────────────── e2e-ios: name: E2E — iOS Simulator - runs-on: macos-15 + runs-on: macos-26-xlarge timeout-minutes: 60 defaults: run: working-directory: PingTestRunner + env: + XCODE_VERSION: ${{ vars.XCODE_VERSION || '26.1.1' }} + RUBY_VERSION: '2.6.10' + BUNDLE_GEMFILE: ${{ github.workspace }}/PingTestRunner/Gemfile + steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js and install dependencies uses: ./.github/actions/setup-proj - - name: Install Ruby gems (CocoaPods) - run: bundle install --path vendor/bundle + - name: Select Xcode version + run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" + + - name: Show Xcode version + run: xcodebuild -version + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true - name: Install CocoaPods dependencies run: bundle exec pod install --project-directory=ios @@ -53,15 +71,15 @@ jobs: env: # Tier 1 tests always run — no server required. # Tier 2 journey/OIDC tests self-skip when these are not set. - PING_SERVER_URL: ${{ secrets.PING_SERVER_URL }} - PING_REALM_PATH: ${{ secrets.PING_REALM_PATH }} - PING_COOKIE_NAME: ${{ secrets.PING_COOKIE_NAME }} - PING_JOURNEY_NAME: ${{ secrets.PING_JOURNEY_NAME }} - PING_TEST_USERNAME: ${{ secrets.PING_TEST_USERNAME }} - PING_TEST_PASSWORD: ${{ secrets.PING_TEST_PASSWORD }} - PING_DISCOVERY_ENDPOINT: ${{ secrets.PING_DISCOVERY_ENDPOINT }} - PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} - PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} + PING_SERVER_URL: ${{ secrets.PING_SERVER_URL }} + PING_REALM_PATH: ${{ secrets.PING_REALM_PATH }} + PING_COOKIE_NAME: ${{ secrets.PING_COOKIE_NAME }} + PING_JOURNEY_NAME: ${{ secrets.PING_JOURNEY_NAME }} + PING_TEST_USERNAME: ${{ secrets.PING_TEST_USERNAME }} + PING_TEST_PASSWORD: ${{ secrets.PING_TEST_PASSWORD }} + PING_DISCOVERY_ENDPOINT: ${{ secrets.PING_DISCOVERY_ENDPOINT }} + PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} + PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} - name: Upload Detox artifacts on failure @@ -72,7 +90,13 @@ jobs: path: PingTestRunner/artifacts/ # ── Android E2E (emulator on ubuntu-latest) ─────────────────────────────── + # NOTE: This job will currently fail. The BrowserStack Detox fork we depend on + # for cloud device testing is pinned to a Detox version that is incompatible + # with React Native 0.80. + # TODO: Re-enable and validate once BrowserStack releases an updated version + # of their Detox fork with RN 0.80 support. e2e-android: + if: false name: E2E — Android Emulator runs-on: ubuntu-latest timeout-minutes: 60 @@ -108,15 +132,15 @@ jobs: env: # Tier 1 tests always run — no server required. # Tier 2 journey/OIDC tests self-skip when these are not set. - PING_SERVER_URL: ${{ secrets.PING_SERVER_URL }} - PING_REALM_PATH: ${{ secrets.PING_REALM_PATH }} - PING_COOKIE_NAME: ${{ secrets.PING_COOKIE_NAME }} - PING_JOURNEY_NAME: ${{ secrets.PING_JOURNEY_NAME }} - PING_TEST_USERNAME: ${{ secrets.PING_TEST_USERNAME }} - PING_TEST_PASSWORD: ${{ secrets.PING_TEST_PASSWORD }} - PING_DISCOVERY_ENDPOINT: ${{ secrets.PING_DISCOVERY_ENDPOINT }} - PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} - PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} + PING_SERVER_URL: ${{ secrets.PING_SERVER_URL }} + PING_REALM_PATH: ${{ secrets.PING_REALM_PATH }} + PING_COOKIE_NAME: ${{ secrets.PING_COOKIE_NAME }} + PING_JOURNEY_NAME: ${{ secrets.PING_JOURNEY_NAME }} + PING_TEST_USERNAME: ${{ secrets.PING_TEST_USERNAME }} + PING_TEST_PASSWORD: ${{ secrets.PING_TEST_PASSWORD }} + PING_DISCOVERY_ENDPOINT: ${{ secrets.PING_DISCOVERY_ENDPOINT }} + PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} + PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} - name: Upload Detox artifacts on failure diff --git a/.github/workflows/js-unit-tests.yml b/.github/workflows/js-unit-tests.yml index 600fb7b0..2c76210c 100644 --- a/.github/workflows/js-unit-tests.yml +++ b/.github/workflows/js-unit-tests.yml @@ -41,9 +41,9 @@ jobs: - name: Setup uses: ./.github/actions/setup-proj - # Execute Jest unit tests across affected packages in the monorepo. + # Execute Jest unit tests with coverage across affected packages in the monorepo. - name: Run unit tests - run: yarn test --affected + run: yarn test:affected:coverage # Publish test results as GitHub check runs. - name: Publish test report @@ -55,4 +55,15 @@ jobs: reporter: jest-junit list-suites: 'all' list-tests: 'all' - fail-on-error: 'false' \ No newline at end of file + fail-on-error: 'false' + + # Upload coverage reports to Codecov. + - name: Upload coverage to Codecov + if: always() + continue-on-error: true + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: 'packages/*/build/coverage/lcov.info' + flags: javascript + fail_ci_if_error: false \ No newline at end of file diff --git a/.github/workflows/preview-docs.yml b/.github/workflows/preview-docs.yml index 47715883..22e996ee 100644 --- a/.github/workflows/preview-docs.yml +++ b/.github/workflows/preview-docs.yml @@ -76,6 +76,5 @@ jobs: pr-number: ${{ inputs.pr_number }} preview-branch: gh-pages umbrella-dir: docs-preview - pages-base-path: docs comment: true wait-for-pages-deployment: false diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index ab469124..ecab2ea5 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/PingTestRunner/.detoxrc.js b/PingTestRunner/.detoxrc.js index cc9e2b24..d5a84919 100644 --- a/PingTestRunner/.detoxrc.js +++ b/PingTestRunner/.detoxrc.js @@ -6,16 +6,7 @@ */ /** @type {Detox.DetoxConfig} */ -const detoxPort = Number(process.env.DETOX_SERVER_PORT ?? 8099); - module.exports = { - session: { - server: `ws://127.0.0.1:${detoxPort}`, - autoStart: true, - }, - server: { - port: detoxPort, - }, artifacts: { rootDir: 'artifacts', plugins: { @@ -51,7 +42,12 @@ module.exports = { testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk', build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release', - reversePorts: [detoxPort], + }, + // BrowserStack cloud app — URLs are resolved after uploading binaries to BrowserStack. + 'android.cloud': { + type: 'android.cloud', + app: process.env.BROWSERSTACK_APP_URL, + appClient: process.env.BROWSERSTACK_TEST_URL, }, }, devices: { @@ -67,6 +63,14 @@ module.exports = { avdName: 'Pixel_9', }, }, + // BrowserStack real device — override via env vars to target a different device. + browserstack: { + type: 'android.cloud', + device: { + name: process.env.BROWSERSTACK_DEVICE ?? 'Google Pixel 9', + osVersion: process.env.BROWSERSTACK_OS_VERSION ?? '15.0', + }, + }, }, configurations: { 'ios.sim': { @@ -77,5 +81,31 @@ module.exports = { device: 'emulator', app: 'android.release', }, + // BrowserStack real-device configuration. + 'android.bs': { + device: 'browserstack', + app: 'android.cloud', + artifacts: { + plugins: { + deviceLogs: { + enabled: true, + }, + }, + }, + // cloudAuthentication and session must live inside the configuration + // entry — the BrowserStack detox fork reads localConfig, not the + // top-level config, for these cloud-specific properties. + cloudAuthentication: { + username: process.env.BROWSERSTACK_USERNAME, + accessKey: process.env.BROWSERSTACK_ACCESS_KEY, + }, + session: { + server: 'wss://detox.browserstack.com/init', + name: process.env.BROWSERSTACK_SESSION_NAME, + build: process.env.BROWSERSTACK_BUILD_ID, + project: process.env.BROWSERSTACK_PROJECT_NAME, + local: false, + }, + }, }, }; diff --git a/PingTestRunner/Gemfile b/PingTestRunner/Gemfile index dcda1b88..4e96de64 100644 --- a/PingTestRunner/Gemfile +++ b/PingTestRunner/Gemfile @@ -9,7 +9,7 @@ ruby ">= 2.6.10" gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1' gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' -gem 'xcodeproj', '< 1.26.0' +gem 'xcodeproj', '~> 1.27.0' gem 'concurrent-ruby', '< 1.3.4' gem 'ffi', '< 1.17' diff --git a/PingTestRunner/Gemfile.lock b/PingTestRunner/Gemfile.lock index 817c5c3f..e5ab04cb 100644 --- a/PingTestRunner/Gemfile.lock +++ b/PingTestRunner/Gemfile.lock @@ -73,7 +73,7 @@ GEM minitest (5.25.4) molinillo (0.8.0) mutex_m (0.3.0) - nanaimo (0.3.0) + nanaimo (0.4.0) nap (1.1.0) netrc (0.11.0) public_suffix (4.0.7) @@ -83,12 +83,12 @@ GEM ethon (>= 0.18.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.25.1) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) + nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) zeitwerk (2.6.18) @@ -104,10 +104,10 @@ DEPENDENCIES ffi (< 1.17) logger mutex_m - xcodeproj (< 1.26.0) + xcodeproj (~> 1.27.0) RUBY VERSION ruby 2.6.10p210 BUNDLED WITH - 1.17.2 + 2.2.33 diff --git a/PingTestRunner/android/app/build.gradle b/PingTestRunner/android/app/build.gradle index d84940e0..a1940bb3 100644 --- a/PingTestRunner/android/app/build.gradle +++ b/PingTestRunner/android/app/build.gradle @@ -50,7 +50,10 @@ android { versionName "1.0" manifestPlaceholders["appRedirectUriScheme"] = "org.forgerock.demo" // Enable Detox test orchestration + testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Detox ships a 'detox' flavor dimension; pick 'full' for e2e tests. + missingDimensionStrategy 'detox', 'full' } signingConfigs { debug { @@ -68,6 +71,7 @@ android { signingConfig signingConfigs.debug minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro" } } } @@ -76,9 +80,7 @@ dependencies { implementation("com.facebook.react:react-android") implementation("com.google.android.gms:play-services-location:21.3.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0") - - // Detox test dependency - androidTestImplementation("com.wix:detox:+") + androidTestImplementation project(':detox') if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") diff --git a/PingTestRunner/android/app/src/main/AndroidManifest.xml b/PingTestRunner/android/app/src/main/AndroidManifest.xml index dd57a04e..25418d1c 100644 --- a/PingTestRunner/android/app/src/main/AndroidManifest.xml +++ b/PingTestRunner/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/AppTheme" android:supportsRtl="true"> diff --git a/PingTestRunner/android/app/src/main/res/xml/network_security_config.xml b/PingTestRunner/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..3cac0a15 --- /dev/null +++ b/PingTestRunner/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,21 @@ + + + + + + localhost + 10.0.2.2 + 10.0.3.2 + 127.0.0.1 + + diff --git a/PingTestRunner/android/build.gradle b/PingTestRunner/android/build.gradle index 36a5217a..283459ee 100644 --- a/PingTestRunner/android/build.gradle +++ b/PingTestRunner/android/build.gradle @@ -39,3 +39,14 @@ allprojects { android.defaultConfig.manifestPlaceholders["appRedirectUriScheme"] = "org.forgerock.demo" } } + +// The :detox source module uses @ExperimentalStdlibApi APIs without opt-in +// annotations. Kotlin 2.1 treats missing opt-in as a compile error, so scope +// the compiler flag to that project only. +project(':detox') { + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + freeCompilerArgs += ['-opt-in=kotlin.ExperimentalStdlibApi'] + } + } +} diff --git a/PingTestRunner/android/settings.gradle b/PingTestRunner/android/settings.gradle index e65e68d8..8ecc427f 100644 --- a/PingTestRunner/android/settings.gradle +++ b/PingTestRunner/android/settings.gradle @@ -10,12 +10,16 @@ plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'PingTestRunner' -// Point Gradle at the local Detox AAR so com.wix:detox resolves to the -// correct version bundled with the detox npm package (not the stale Maven artifact). +// Include only the :detox module from node_modules as a regular subproject +// (detox v20+ ships source only; its settings.gradle can't be used as a +// composite build because it re-includes @react-native/gradle-plugin +// under a conflicting build path). +include ':detox' +project(':detox').projectDir = file('../../node_modules/detox/android/detox') + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories { - maven { url "$rootDir/../../node_modules/detox/Detox-android" } google() mavenCentral() } diff --git a/PingTestRunner/e2e/jest.config.js b/PingTestRunner/e2e/jest.config.js index d39c3917..35774997 100644 --- a/PingTestRunner/e2e/jest.config.js +++ b/PingTestRunner/e2e/jest.config.js @@ -10,7 +10,7 @@ module.exports = { maxWorkers: 1, testEnvironment: 'node', testRunner: 'jest-circus/runner', - testTimeout: 120000, + testTimeout: 300000, rootDir: '..', testMatch: ['/e2e/**/*.test.ts'], transform: { diff --git a/PingTestRunner/ios/PingTestRunner.xcodeproj/project.pbxproj b/PingTestRunner/ios/PingTestRunner.xcodeproj/project.pbxproj index 4a2f732e..5c9289af 100644 --- a/PingTestRunner/ios/PingTestRunner.xcodeproj/project.pbxproj +++ b/PingTestRunner/ios/PingTestRunner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -16,12 +16,23 @@ F81FD5E1DCEE2C1F8C2A8E61 /* libPods-PingTestRunner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D63B5B7F0DB91711A6DB0883 /* libPods-PingTestRunner.a */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 342DFE092F8480FE00E081CB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = PingTestRunner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 0B341BD4880DD91608CC85CA /* Pods-RNStorageTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNStorageTests.release.xcconfig"; path = "Target Support Files/Pods-RNStorageTests/Pods-RNStorageTests.release.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* PingTestRunner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PingTestRunner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = PingTestRunner/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = PingTestRunner/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = PingTestRunner/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 342DFE032F8480FE00E081CB /* PingTestRunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PingTestRunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 344692DB2F0EC3FC001EF2C5 /* libRNPingStorage.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libRNPingStorage.a; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4392A12AC88292D35C810B /* Pods-PingTestRunner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PingTestRunner.debug.xcconfig"; path = "Target Support Files/Pods-PingTestRunner/Pods-PingTestRunner.debug.xcconfig"; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-PingTestRunner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PingTestRunner.release.xcconfig"; path = "Target Support Files/Pods-PingTestRunner/Pods-PingTestRunner.release.xcconfig"; sourceTree = ""; }; @@ -35,6 +46,20 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 342DFE042F8480FE00E081CB /* PingTestRunnerUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = PingTestRunnerUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -44,6 +69,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 342DFE002F8480FE00E081CB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -84,6 +116,7 @@ children = ( 13B07FAE1A68108700A75B9A /* PingTestRunner */, 832341AE1AAA6A7D00B99B32 /* Libraries */, + 342DFE042F8480FE00E081CB /* PingTestRunnerUITests */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, BBD78D7AC51CEA395F1C20DB /* Pods */, @@ -97,6 +130,7 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* PingTestRunner.app */, + 342DFE032F8480FE00E081CB /* PingTestRunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -136,6 +170,27 @@ productReference = 13B07F961A680F5B00A75B9A /* PingTestRunner.app */; productType = "com.apple.product-type.application"; }; + 342DFE022F8480FE00E081CB /* PingTestRunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 342DFE0D2F8480FE00E081CB /* Build configuration list for PBXNativeTarget "PingTestRunnerUITests" */; + buildPhases = ( + 342DFDFF2F8480FE00E081CB /* Sources */, + 342DFE002F8480FE00E081CB /* Frameworks */, + 342DFE012F8480FE00E081CB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 342DFE0A2F8480FE00E081CB /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 342DFE042F8480FE00E081CB /* PingTestRunnerUITests */, + ); + name = PingTestRunnerUITests; + productName = PingTestRunnerUITests; + productReference = 342DFE032F8480FE00E081CB /* PingTestRunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -148,6 +203,10 @@ 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1120; }; + 342DFE022F8480FE00E081CB = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "PingTestRunner" */; @@ -164,6 +223,7 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* PingTestRunner */, + 342DFE022F8480FE00E081CB /* PingTestRunnerUITests */, ); }; /* End PBXProject section */ @@ -181,6 +241,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 342DFE012F8480FE00E081CB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -267,8 +334,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 342DFDFF2F8480FE00E081CB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 342DFE0A2F8480FE00E081CB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* PingTestRunner */; + targetProxy = 342DFE092F8480FE00E081CB /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; @@ -279,7 +361,7 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_BITCODE = NO; INFOPLIST_FILE = PingTestRunner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -290,8 +372,9 @@ "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.rntestrunner; PRODUCT_NAME = PingTestRunner; + PROVISIONING_PROFILE = "$(APP_PROVISIONING_PROFILE_UUID)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -306,7 +389,7 @@ CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; INFOPLIST_FILE = PingTestRunner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -317,13 +400,85 @@ "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.rntestrunner; PRODUCT_NAME = PingTestRunner; + PROVISIONING_PROFILE = "$(APP_PROVISIONING_PROFILE_UUID)"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; + 342DFE0B2F8480FE00E081CB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.PingTestRunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = "$(UI_TEST_PROVISIONING_PROFILE_UUID)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = PingTestRunner; + }; + name = Debug; + }; + 342DFE0C2F8480FE00E081CB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.PingTestRunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = "$(UI_TEST_PROVISIONING_PROFILE_UUID)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = PingTestRunner; + }; + name = Release; + }; 83CBBA201A601CBA00E9B192 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -510,6 +665,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 342DFE0D2F8480FE00E081CB /* Build configuration list for PBXNativeTarget "PingTestRunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 342DFE0B2F8480FE00E081CB /* Debug */, + 342DFE0C2F8480FE00E081CB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "PingTestRunner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/PingTestRunner.xcscheme b/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/PingTestRunner.xcscheme index 534f3970..7420a4cf 100644 --- a/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/PingTestRunner.xcscheme +++ b/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/PingTestRunner.xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:PingTestRunner.xcodeproj"> + + + + + + + + @@ -61,6 +72,28 @@ ReferencedContainer = "container:Pods/Pods.xcodeproj"> + + + + + + + + diff --git a/PingTestRunner/ios/PingTestRunnerUITests/AppLaunchUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/AppLaunchUITests.swift new file mode 100644 index 00000000..25496d02 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/AppLaunchUITests.swift @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + + +/// Verifies that PingTestRunner launches successfully, the root view renders, +/// and the expected header/body structure is visible. No scenario arg is passed — +/// the app renders its shell UI by default. +final class AppLaunchUITests: BaseTestCase { + + override func setUp() { + super.setUp() + app.launch() + } + + func testRendersRootContainer() { + assertAppReady() + } + + func testDisplaysTitle() { + XCTAssertTrue( + elementWithTestID("ping-test-runner-title").waitForExistence(timeout: 10), + "Expected 'ping-test-runner-title' to be visible" + ) + } + + func testDisplaysHeader() { + XCTAssertTrue( + elementWithTestID("ping-test-runner-header").waitForExistence(timeout: 10), + "Expected 'ping-test-runner-header' to be visible" + ) + } + + func testDisplaysBody() { + XCTAssertTrue( + elementWithTestID("ping-test-runner-body").waitForExistence(timeout: 10), + "Expected 'ping-test-runner-body' to be visible" + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift b/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift new file mode 100644 index 00000000..54b7ad50 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + +/// Base class for all PingTestRunner XCUITest cases. +/// +/// Responsibilities: +/// - Launches the app with the correct launchArguments for each scenario, +/// mirroring Detox's `device.launchApp({ launchArgs: { ... } })` pattern. +/// React Native reads `-KEY VALUE` pairs from ProcessInfo.processInfo.arguments +/// via react-native-launch-arguments and surfaces them as JS process.env. +/// - Provides `el()` / `waitForEl()` helpers that query by accessibilityIdentifier, +/// which React Native sets from the `testID` prop on every component. +/// - Provides skip helpers that mirror hasJourneyEnv / hasCallbackTreesEnabled / hasLiveAuthEnv +/// from e2e/setup.ts so Tier 2 tests self-skip when server secrets are absent. +class BaseTestCase: XCTestCase { + + var app: XCUIApplication! + let env = TestEnvironment.shared + + /// Timeout for operations that involve a network round-trip to the Ping server. + let netTimeout: TimeInterval = 30 + + override func setUp() { + super.setUp() + continueAfterFailure = false + app = XCUIApplication() + } + + override func tearDown() { + app.terminate() + super.tearDown() + } + + // MARK: - App Launch + + /// Launches the app configured for the given scenario. + /// + /// Passes configuration as `-KEY VALUE` launch arguments. + /// The RN app reads these at startup to select a scenario screen + /// and configure the Ping SDK. + func launchApp(scenario: String, extras: [String: String] = [:]) { + var args = ["-PING_TEST_SCENARIO", scenario] + for (key, value) in extras where !value.isEmpty { + args += ["-\(key)", value] + } + app.launchArguments = args + app.launch() + } + + /// Launches the app in the `journey` scenario with all required Ping server args. + /// Pass `tree` to override the default PING_JOURNEY_NAME from TestEnvironment. + func launchJourneyScenario(tree: String? = nil, noSession: Bool = false) { + var extras: [String: String] = [ + "PING_SERVER_URL": env.serverUrl, + "PING_REALM_PATH": env.realmPath, + "PING_JOURNEY_NAME": tree ?? env.journeyName, + "PING_COOKIE_NAME": env.cookieName, + ] + if noSession { + // noSession tests exercise the pure journey flow without an OIDC session. + // Do NOT pass OIDC credentials — they would trigger an ASWebAuthenticationSession + // browser pop-up after SuccessNode, disabling the app window and breaking XCUITest. + extras["PING_NO_SESSION"] = "true" + } else { + if !env.clientId.isEmpty { extras["PING_CLIENT_ID"] = env.clientId } + if !env.discoveryEndpoint.isEmpty { extras["PING_DISCOVERY_ENDPOINT"] = env.discoveryEndpoint } + if !env.redirectUri.isEmpty { extras["PING_REDIRECT_URI"] = env.redirectUri } + } + launchApp(scenario: "journey", extras: extras) + } + + // MARK: - Root Assertion + + /// Asserts that the app root container is visible, mirroring assertAppReady() + /// from e2e/setup.ts. Allow extra time on real devices for Hermes startup. + func assertAppReady(timeout: TimeInterval = 20) { + let root = elementWithTestID("ping-test-runner-root") + XCTAssertTrue( + root.waitForExistence(timeout: timeout), + "App root 'ping-test-runner-root' did not appear within \(timeout)s" + ) + } + + // MARK: - Element Helpers + + /// Returns the first element with the given accessibilityIdentifier (= RN testID). + /// Uses `.any` type so the query works regardless of the underlying view type + /// (button, text field, static text, other), which can vary across RN versions. + func elementWithTestID(_ testID: String) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: testID).firstMatch + } + + /// Waits for the element with the given testID to exist and fails the test if it doesn't. + @discardableResult + func waitForElementWithTestID( + _ testID: String, + timeout: TimeInterval? = nil, + file: StaticString = #file, + line: UInt = #line + ) -> XCUIElement { + let element = elementWithTestID(testID) + let t = timeout ?? netTimeout + XCTAssertTrue( + element.waitForExistence(timeout: t), + "Element '\(testID)' not found within \(t)s", + file: file, + line: line + ) + return element + } + + /// Reads the visible text content of the element with the given testID. + /// + /// React Native Fabric (New Architecture) sometimes stores the display text in + /// `element.value` rather than `element.label`. This helper checks both. + func textContentOfElement( + withTestID testID: String, + timeout: TimeInterval? = nil, + file: StaticString = #file, + line: UInt = #line + ) -> String { + let element = waitForElementWithTestID(testID, timeout: timeout, file: file, line: line) + return element.label.isEmpty ? (element.value as? String ?? "") : element.label + } + + // MARK: - Skip Helpers + + /// Skips the test if Journey environment variables are not set. + /// Mirrors hasJourneyEnv from e2e/setup.ts. + func skipIfNoJourneyEnv(file: StaticString = #file, line: UInt = #line) throws { + try XCTSkipUnless( + env.hasJourneyEnv, + "Skipping: PING_SERVER_URL, PING_TEST_USERNAME, PING_TEST_PASSWORD not set.", + file: file, + line: line + ) + } + + /// Skips the test if PING_CALLBACK_TREES_ENABLED is false. + /// Mirrors hasCallbackTreesEnabled from e2e/setup.ts. + func skipIfNoCallbackTrees(file: StaticString = #file, line: UInt = #line) throws { + try XCTSkipUnless( + env.callbackTreesEnabled, + "Skipping: PING_CALLBACK_TREES_ENABLED=false.", + file: file, + line: line + ) + } + + /// Skips the test if OIDC env vars (discovery endpoint + client ID) are not set. + /// Mirrors hasLiveAuthEnv() from e2e/setup.ts. + func skipIfNoLiveAuthEnv(file: StaticString = #file, line: UInt = #line) throws { + try XCTSkipUnless( + env.hasLiveAuthEnv, + "Skipping: PING_DISCOVERY_ENDPOINT and PING_CLIENT_ID not set.", + file: file, + line: line + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/BrowserUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/BrowserUITests.swift new file mode 100644 index 00000000..1d81e57e --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/BrowserUITests.swift @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + + +/// Verifies the native browser bridge module loads correctly and that the +/// two configuration APIs do not throw on iOS: +/// configureBrowser({}) — no-op on iOS, applies config on Android +/// resetBrowser() — cancels the active browser session on iOS +final class BrowserUITests: BaseTestCase { + + override func setUp() { + super.setUp() + launchApp(scenario: "browser") + } + + func testAppLaunchesInBrowserScenario() { + assertAppReady() + } + + func testConfigureBrowserCompletesWithoutThrowing() { + elementWithTestID("browser-configure-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("browser-configure-result").waitForExistence(timeout: 5), + "Expected 'browser-configure-result' to appear after configureBrowser({})" + ) + } + + func testResetBrowserCompletesWithoutThrowing() { + elementWithTestID("browser-reset-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("browser-reset-result").waitForExistence(timeout: 5), + "Expected 'browser-reset-result' to appear after resetBrowser()" + ) + } + + func testNoErrorIsShown() { + // Wait for the screen to finish rendering before asserting absence of an error, + // so a late React Native / bridge error cannot slip past an instantaneous check. + _ = elementWithTestID("browser-configure-btn").waitForExistence(timeout: 10) + XCTAssertFalse( + elementWithTestID("browser-error").exists, + "Expected no error element to be present" + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/LoggerUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/LoggerUITests.swift new file mode 100644 index 00000000..de18a4c8 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/LoggerUITests.swift @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + +/// XCUITest for logger scenarios (Tier 1 — no server required). +/// +/// Flow: +/// testConsoleLogger → create debug logger, call all 4 levels +/// testWarnLogger → changeLevel('warn') does not throw +/// testNoneLogger → level 'none' logger creates without throwing +final class LoggerUITests: BaseTestCase { + + override func setUp() { + super.setUp() + launchApp(scenario: "logger") + } + + func testAppLaunchesInLoggerScenario() { + assertAppReady() + } + + func testDebugLoggerCreatesWithoutThrowing() { + elementWithTestID("logger-create-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("logger-ready").waitForExistence(timeout: 10), + "Expected 'logger-ready' after creating a debug logger" + ) + } + + func testAllLogLevelsCallableWithoutThrowing() { + elementWithTestID("logger-create-btn").tapWhenReady() + waitForElementWithTestID("logger-ready") + + elementWithTestID("logger-log-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("logger-logged").waitForExistence(timeout: 10), + "Expected 'logger-logged' after calling debug/info/warn/error" + ) + } + + func testChangeLevelToWarnCompletesWithoutThrowing() { + elementWithTestID("logger-create-btn").tapWhenReady() + waitForElementWithTestID("logger-ready") + + elementWithTestID("logger-change-level-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("logger-level-changed").waitForExistence(timeout: 10), + "Expected 'logger-level-changed' after calling changeLevel('warn')" + ) + } + + func testNoneLevelLoggerCreatesWithoutThrowing() { + elementWithTestID("logger-none-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("logger-none-ready").waitForExistence(timeout: 10), + "Expected 'logger-none-ready' after creating a 'none' level logger" + ) + } + + func testNoErrorIsShown() { + // Wait for the screen to finish rendering before asserting absence of an error, + // so a late React Native / bridge error cannot slip past an instantaneous check. + _ = elementWithTestID("logger-create-btn").waitForExistence(timeout: 10) + XCTAssertFalse( + elementWithTestID("logger-error").exists, + "Expected no error element to be present" + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/OidcFailurePathUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/OidcFailurePathUITests.swift new file mode 100644 index 00000000..f53f3703 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/OidcFailurePathUITests.swift @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + +/// XCUITest OIDC failure path (Tier 1 — no server required). +/// +/// Runs OIDC in deterministic failure mode to validate that an authorize +/// failure surfaces the in-app error UI without relying on external +/// browser automation. No live server is required. +final class OidcFailurePathUITests: BaseTestCase { + + override func setUp() { + super.setUp() + launchApp(scenario: "oidc", extras: ["PING_OIDC_TEST_MODE": "true"]) + } + + func testAppLaunchesInOidcScenario() { + assertAppReady() + } + + func testShowsErrorUIWhenFailureIsForcedInTestMode() { + elementWithTestID("oidc-force-failure-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("oidc-error-message").waitForExistence(timeout: 10), + "Expected oidc-error-message after forced failure in test mode" + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/OidcHappyPathUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/OidcHappyPathUITests.swift new file mode 100644 index 00000000..08b3ffd7 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/OidcHappyPathUITests.swift @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + +/// XCUITest for OIDC happy path (Tier 1 — no server required). +/// +/// Uses PING_OIDC_TEST_MODE so the app handles the OAuth redirect internally +/// without opening an external browser. No live server is required; +/// all tests run unconditionally. +/// +/// Flow: +/// 1. authorize() → oidc-browser-open marker + oidc-token-result visible +/// 2. userinfo() → oidc-userinfo-result visible +/// 3. logout() → oidc-logged-out marker visible +final class OidcHappyPathUITests: BaseTestCase { + + override func setUp() { + super.setUp() + launchApp(scenario: "oidc", extras: ["PING_OIDC_TEST_MODE": "true"]) + } + + func testAppLaunchesInOidcScenario() { + assertAppReady() + } + + func testAuthorizeShowsBrowserOpenMarkerAndTokenResult() { + elementWithTestID("oidc-authorize-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("oidc-browser-open").waitForExistence(timeout: 10), + "Expected oidc-browser-open after authorize()" + ) + XCTAssertTrue( + elementWithTestID("oidc-token-result").waitForExistence(timeout: 10), + "Expected oidc-token-result after authorize()" + ) + } + + func testUserinfoReturnsDeterministicProfile() { + elementWithTestID("oidc-authorize-btn").tapWhenReady() + waitForElementWithTestID("oidc-token-result") + + elementWithTestID("oidc-userinfo-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("oidc-userinfo-result").waitForExistence(timeout: 10), + "Expected oidc-userinfo-result after userinfo()" + ) + } + + func testLogoutClearsSession() { + elementWithTestID("oidc-authorize-btn").tapWhenReady() + waitForElementWithTestID("oidc-token-result") + + elementWithTestID("oidc-logout-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("oidc-logged-out").waitForExistence(timeout: 10), + "Expected oidc-logged-out after logout()" + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/StorageUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/StorageUITests.swift new file mode 100644 index 00000000..0c4d265c --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/StorageUITests.swift @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + +/// XCUITest for storage scenarios (Tier 1 — no server required). +/// +/// Flow: +/// createsStorageWithValidConfig → session + OIDC handle creation succeeds +/// invalid config path → throws OR falls back to native defaults +final class StorageUITests: BaseTestCase { + + override func setUp() { + super.setUp() + launchApp(scenario: "storage") + } + + func testAppLaunchesInStorageScenario() { + assertAppReady() + } + + func testConfigureSessionStorageReturnsSessionHandle() { + elementWithTestID("storage-session-btn").tapWhenReady() + let result = textContentOfElement(withTestID: "storage-session-result", timeout: 10) + XCTAssertTrue( + result.hasPrefix("session:"), + "Expected session handle to start with 'session:', got '\(result)'" + ) + } + + func testConfigureOidcStorageReturnsOidcHandle() { + elementWithTestID("storage-oidc-btn").tapWhenReady() + let result = textContentOfElement(withTestID: "storage-oidc-result", timeout: 10) + XCTAssertTrue( + result.hasPrefix("oidc:"), + "Expected OIDC handle to start with 'oidc:', got '\(result)'" + ) + } + + func testConfigureSessionStorageWithEmptyConfigThrowsOrFallsBackToDefaults() { + elementWithTestID("storage-invalid-btn").tapWhenReady() + let status = textContentOfElement(withTestID: "storage-invalid-status", timeout: 10) + if status == "error" { + // Native bridge threw — error UI must be shown + XCTAssertTrue( + elementWithTestID("storage-error").waitForExistence(timeout: 5), + "Expected 'storage-error' element when status is 'error'" + ) + return + } + + // Reject any unexpected intermediate state (e.g. "pending", "") so the test + // does not silently pass on an unrecognised status value. + XCTAssertEqual( + status, "success", + "Unexpected status '\(status)': expected 'error' (bridge threw) or 'success' (native defaults)" + ) + + // Fell back to defaults — result should still be a session handle + let result = textContentOfElement(withTestID: "storage-invalid-result", timeout: 5) + XCTAssertTrue( + result.hasPrefix("session:"), + "Expected fallback session handle to start with 'session:', got '\(result)'" + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift b/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift new file mode 100644 index 00000000..c978e8c7 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import Foundation + +/// XCUITest environment variables for CI. +/// +/// Values are read from the test-runner process environment. When running on +/// BrowserStack the `environmentVariables` key in the build API payload populates +/// ProcessInfo.processInfo.environment for the XCUITest runner process. + +struct TestEnvironment { + static let shared = TestEnvironment() + + let serverUrl: String + let realmPath: String + let cookieName: String + let journeyName: String + let testUsername: String + let testPassword: String + let discoveryEndpoint: String + let clientId: String + let redirectUri: String + let callbackTreesEnabled: Bool + + private init() { + let e = ProcessInfo.processInfo.environment + serverUrl = e["PING_SERVER_URL"] ?? "" + realmPath = e["PING_REALM_PATH"] ?? "alpha" + cookieName = e["PING_COOKIE_NAME"] ?? "iPlanetDirectoryPro" + journeyName = e["PING_JOURNEY_NAME"] ?? "Login" + testUsername = e["PING_TEST_USERNAME"] ?? "" + testPassword = e["PING_TEST_PASSWORD"] ?? "" + discoveryEndpoint = e["PING_DISCOVERY_ENDPOINT"] ?? "" + clientId = e["PING_CLIENT_ID"] ?? "" + redirectUri = e["PING_REDIRECT_URI"] ?? "org.forgerock.demo://oauth2redirect" + callbackTreesEnabled = e["PING_CALLBACK_TREES_ENABLED"] != "false" + } + + /// True when all vars required for Journey Tier 2 tests are set. + var hasJourneyEnv: Bool { + !serverUrl.isEmpty && !testUsername.isEmpty && !testPassword.isEmpty + } + + /// True when OIDC vars are also set (full live-auth flow). + var hasLiveAuthEnv: Bool { + hasJourneyEnv && !discoveryEndpoint.isEmpty && !clientId.isEmpty + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/UIInteractionHelpers.swift b/PingTestRunner/ios/PingTestRunnerUITests/UIInteractionHelpers.swift new file mode 100644 index 00000000..3b41cdc6 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/UIInteractionHelpers.swift @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + +extension XCUIElement { + + /// Polls until the element is hittable (on-screen, not covered, interactable) + /// or the timeout expires. Returns `true` if hittable within the window. + @discardableResult + func waitForHittability(timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "isHittable == true") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + + /// Asserts the element exists and is hittable within `timeout` seconds, then taps it. + /// + /// Waiting for hittability (not just existence) prevents races against React Native + /// rendering where an element may be in the tree but still off-screen or covered. + func tapWhenReady( + timeout: TimeInterval = 10, + file: StaticString = #file, + line: UInt = #line + ) { + XCTAssertTrue( + waitForExistence(timeout: timeout), + "Expected element to exist before tap: \(self)", + file: file, + line: line + ) + XCTAssertTrue( + waitForHittability(timeout: timeout), + "Expected element to be hittable before tap: \(self)", + file: file, + line: line + ) + tap() + } + + /// Asserts the element exists and is hittable within `timeout` seconds, taps it, then types `text`. + func typeTextWhenReady( + _ text: String, + timeout: TimeInterval = 10, + file: StaticString = #file, + line: UInt = #line + ) { + XCTAssertTrue( + waitForExistence(timeout: timeout), + "Expected element to exist before typeText: \(self)", + file: file, + line: line + ) + XCTAssertTrue( + waitForHittability(timeout: timeout), + "Expected element to be hittable before typeText: \(self)", + file: file, + line: line + ) + tap() + typeText(text) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/UseJourneyUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/UseJourneyUITests.swift new file mode 100644 index 00000000..4f1584ca --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/UseJourneyUITests.swift @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + +/// XCUITest equivalent of use-journey.test.ts (Tier 2 — server required). +/// +/// Verifies hook-API state transitions for the useJourney + useJourneyForm +/// scenario: field rendering, next(), and post-login actions +/// (token, userinfo, refresh, revoke, logout). +final class UseJourneyUITests: BaseTestCase { + + override func setUp() { + super.setUp() + launchApp(scenario: "use-journey", extras: [ + "PING_SERVER_URL": env.serverUrl, + "PING_REALM_PATH": env.realmPath, + "PING_JOURNEY_NAME": env.journeyName, + "PING_COOKIE_NAME": env.cookieName, + "PING_CLIENT_ID": env.clientId, + "PING_DISCOVERY_ENDPOINT": env.discoveryEndpoint, + "PING_REDIRECT_URI": env.redirectUri, + ]) + } + + // MARK: - Helpers + + private func loginWithValidCredentials() { + elementWithTestID("use-journey-start-btn").tapWhenReady() + waitForElementWithTestID("use-journey-field-NameCallback:0", timeout: netTimeout) + elementWithTestID("use-journey-field-NameCallback:0").typeTextWhenReady(env.testUsername) + elementWithTestID("use-journey-field-PasswordCallback:0").typeTextWhenReady(env.testPassword) + elementWithTestID("use-journey-submit-btn").tapWhenReady() + waitForElementWithTestID("use-journey-success", timeout: netTimeout) + } + + // MARK: - Tests + + func testAppLaunchesInUseJourneyScenario() { + assertAppReady() + } + + /// Verifies form rendering (useJourneyForm fields), successful login via next(), + /// and that the token is available after SuccessNode — one login round-trip. + func testFormRendersAndLoginSucceeds() throws { + try skipIfNoJourneyEnv() + elementWithTestID("use-journey-start-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-journey-field-NameCallback:0").waitForExistence(timeout: netTimeout), + "Expected NameCallback field via useJourneyForm" + ) + XCTAssertTrue( + elementWithTestID("use-journey-field-PasswordCallback:0").waitForExistence(timeout: netTimeout), + "Expected PasswordCallback field via useJourneyForm" + ) + elementWithTestID("use-journey-field-NameCallback:0").typeTextWhenReady(env.testUsername) + elementWithTestID("use-journey-field-PasswordCallback:0").typeTextWhenReady(env.testPassword) + elementWithTestID("use-journey-submit-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-journey-success").waitForExistence(timeout: netTimeout), + "Expected use-journey-success after valid credentials" + ) + XCTAssertTrue( + elementWithTestID("use-journey-token-result").waitForExistence(timeout: netTimeout), + "Expected use-journey-token-result after success" + ) + } + + /// Verifies userinfo(), refresh(), and revoke() hook actions — one login round-trip. + func testUserinfoRefreshAndRevoke() throws { + try skipIfNoLiveAuthEnv() + loginWithValidCredentials() + + elementWithTestID("use-journey-userinfo-btn").tapWhenReady() + let userinfo = textContentOfElement(withTestID: "use-journey-userinfo-result", timeout: netTimeout) + guard + let data = userinfo.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let sub = json["sub"] as? String, + !sub.isEmpty + else { + XCTFail("Expected userinfo payload with a non-empty string 'sub', got '\(userinfo)'") + return + } + _ = sub + + elementWithTestID("use-journey-refresh-btn").tapWhenReady() + waitForElementWithTestID("use-journey-refreshed", timeout: netTimeout) + let token = textContentOfElement(withTestID: "use-journey-refreshed-token-result", timeout: netTimeout) + XCTAssertFalse(token.isEmpty, "Expected a non-empty access token from refresh()") + XCTAssertNotEqual(token, "null", "Refreshed token must not be the string 'null'") + XCTAssertNotEqual(token, "undefined", "Refreshed token must not be the string 'undefined'") + + elementWithTestID("use-journey-revoke-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-journey-revoked").waitForExistence(timeout: netTimeout), + "Expected use-journey-revoked after revoke()" + ) + } + + func testLogoutCompletesViaHookActions() throws { + try skipIfNoJourneyEnv() + loginWithValidCredentials() + elementWithTestID("use-journey-logout-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-journey-logged-out").waitForExistence(timeout: netTimeout), + "Expected use-journey-logged-out after logoutUser()" + ) + } +} + +/// XCUITest equivalent of the 'useJourney — FailureNode on wrong credentials' +/// describe block in use-journey.test.ts. +final class UseJourneyFailureUITests: BaseTestCase { + + override func setUp() { + super.setUp() + // No OIDC config — avoids discovery requests that can interfere with + // failure-path timing, mirroring JOURNEY_NO_OIDC_ARGS in use-journey.test.ts. + launchApp(scenario: "use-journey", extras: [ + "PING_SERVER_URL": env.serverUrl, + "PING_REALM_PATH": env.realmPath, + "PING_JOURNEY_NAME": env.journeyName, + "PING_COOKIE_NAME": env.cookieName, + ]) + } + + func testAppLaunchesInUseJourneyScenario() { + assertAppReady() + } + + func testNextWithWrongPasswordReachesFailureNode() throws { + try skipIfNoJourneyEnv() + elementWithTestID("use-journey-start-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-journey-field-NameCallback:0").waitForExistence(timeout: netTimeout), + "Expected username field after start()" + ) + elementWithTestID("use-journey-field-NameCallback:0").typeTextWhenReady(env.testUsername) + elementWithTestID("use-journey-field-PasswordCallback:0").typeTextWhenReady("wrong_password") + elementWithTestID("use-journey-submit-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-journey-failure").waitForExistence(timeout: netTimeout), + "Expected use-journey-failure after wrong password" + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/UseOidcUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/UseOidcUITests.swift new file mode 100644 index 00000000..a24a5865 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/UseOidcUITests.swift @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest + +/// XCUITest OIDC tests (Tier 1 — no server required). +/// +/// Uses a JS-only mock OidcWebClient, so no native OIDC calls are made. +/// Verifies that useOidc correctly manages hook state transitions: +/// - isAuthenticated set after authorize() +/// - user / tokens / userInfo / refreshed populated after respective actions +/// - isAuthenticated cleared after revoke() / logout() +final class UseOidcUITests: BaseTestCase { + + private let actionTimeout: TimeInterval = 5 + + override func setUp() { + super.setUp() + launchApp(scenario: "use-oidc") + } + + func testAppLaunchesInUseOidcScenario() { + assertAppReady() + } + + func testAuthorizeSetsIsAuthenticatedAndUser() { + elementWithTestID("use-oidc-authorize-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-oidc-authenticated").waitForExistence(timeout: actionTimeout), + "Expected use-oidc-authenticated after authorize()" + ) + XCTAssertTrue( + elementWithTestID("use-oidc-user-result").waitForExistence(timeout: actionTimeout), + "Expected use-oidc-user-result after authorize()" + ) + } + + func testTokenPopulatesTokensState() { + elementWithTestID("use-oidc-authorize-btn").tapWhenReady() + waitForElementWithTestID("use-oidc-authenticated", timeout: actionTimeout) + + elementWithTestID("use-oidc-token-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-oidc-token-result").waitForExistence(timeout: actionTimeout), + "Expected use-oidc-token-result after token()" + ) + } + + func testRefreshUpdatesHookTokenState() { + elementWithTestID("use-oidc-authorize-btn").tapWhenReady() + waitForElementWithTestID("use-oidc-authenticated", timeout: actionTimeout) + elementWithTestID("use-oidc-token-btn").tapWhenReady() + waitForElementWithTestID("use-oidc-token-result", timeout: actionTimeout) + + elementWithTestID("use-oidc-refresh-btn").tapWhenReady() + waitForElementWithTestID("use-oidc-refreshed", timeout: actionTimeout) + // Assert the refreshed accessToken value from hook state (state.tokens.accessToken), + // not just the local flag. This fails if refresh() resolves but the hook does not + // update state.tokens (e.g. a no-op refresh). + let token = textContentOfElement(withTestID: "use-oidc-refreshed-token-result", timeout: actionTimeout) + XCTAssertEqual( + token, "hook-test-refreshed-token", + "Expected state.tokens.accessToken to reflect the refreshed value after refresh()" + ) + } + + func testUserinfoPopulatesUserInfoState() { + elementWithTestID("use-oidc-authorize-btn").tapWhenReady() + waitForElementWithTestID("use-oidc-authenticated", timeout: actionTimeout) + + elementWithTestID("use-oidc-userinfo-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-oidc-userinfo-result").waitForExistence(timeout: actionTimeout), + "Expected use-oidc-userinfo-result after userinfo()" + ) + } + + func testRevokeClearsIsAuthenticated() { + elementWithTestID("use-oidc-authorize-btn").tapWhenReady() + waitForElementWithTestID("use-oidc-authenticated", timeout: actionTimeout) + + elementWithTestID("use-oidc-revoke-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-oidc-logged-out").waitForExistence(timeout: actionTimeout), + "Expected use-oidc-logged-out after revoke()" + ) + } + + func testAuthorizeFollowedByLogoutClearsIsAuthenticated() { + elementWithTestID("use-oidc-authorize-btn").tapWhenReady() + waitForElementWithTestID("use-oidc-authenticated", timeout: actionTimeout) + + elementWithTestID("use-oidc-logout-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-oidc-logged-out").waitForExistence(timeout: actionTimeout), + "Expected use-oidc-logged-out after logout()" + ) + } +} + +/// XCUITest equivalent of the 'useOidc — state.error on authorize failure' +/// describe block in use-oidc.test.ts. +final class UseOidcErrorUITests: BaseTestCase { + + private let actionTimeout: TimeInterval = 5 + + override func setUp() { + super.setUp() + launchApp(scenario: "use-oidc-error") + } + + func testAppLaunchesInUseOidcErrorScenario() { + assertAppReady() + } + + func testAuthorizeFailureSetsStateError() { + elementWithTestID("use-oidc-authorize-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-oidc-error").waitForExistence(timeout: actionTimeout), + "Expected use-oidc-error after authorize() failure" + ) + } +} diff --git a/PingTestRunner/ios/Podfile b/PingTestRunner/ios/Podfile index b053f815..a6f5bfbd 100644 --- a/PingTestRunner/ios/Podfile +++ b/PingTestRunner/ios/Podfile @@ -46,12 +46,18 @@ target 'PingTestRunner' do pod 'PingOidc', :git => ping_ios_sdk_git, :branch => ping_ios_sdk_branch # RN bridge pods — all with :testspecs to pick up ios/Tests/**/*.swift + pod 'RNPingBrowser', + :path => '../../node_modules/@ping-identity/rn-browser', + :testspecs => ['Tests'] pod 'RNPingCore', :path => '../../packages/core', :testspecs => ['Tests'] pod 'RNPingDeviceId', :path => '../../node_modules/@ping-identity/rn-device-id', :testspecs => ['Tests'] + pod 'RNPingDeviceProfile', + :path => '../../node_modules/@ping-identity/rn-device-profile', + :testspecs => ['Tests'] pod 'RNPingJourney', :path => '../../node_modules/@ping-identity/rn-journey', :testspecs => ['Tests'] diff --git a/PingTestRunner/ios/Podfile.lock b/PingTestRunner/ios/Podfile.lock index d054260f..7981255a 100644 --- a/PingTestRunner/ios/Podfile.lock +++ b/PingTestRunner/ios/Podfile.lock @@ -2216,6 +2216,38 @@ PODS: - RNPingLogger - SocketRocket - Yoga + - RNPingBrowser/Tests (0.1.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - PingBrowser (= 1.3.1) + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNPingCore + - RNPingLogger + - SocketRocket + - Yoga - RNPingCore (0.1.0): - boost - DoubleConversion @@ -2367,6 +2399,37 @@ PODS: - RNPingCore - SocketRocket - Yoga + - RNPingDeviceProfile/Tests (0.1.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - PingDeviceProfile + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNPingCore + - SocketRocket + - Yoga - RNPingJourney (0.1.0): - boost - DoubleConversion @@ -2708,11 +2771,13 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../../node_modules/react-native/ReactCommon`) - "RNCAsyncStorage (from `../../node_modules/@react-native-async-storage/async-storage`)" - "RNPingBrowser (from `../../node_modules/@ping-identity/rn-browser`)" + - "RNPingBrowser/Tests (from `../../node_modules/@ping-identity/rn-browser`)" - RNPingCore (from `../../packages/core`) - RNPingCore/Tests (from `../../packages/core`) - "RNPingDeviceId (from `../../node_modules/@ping-identity/rn-device-id`)" - "RNPingDeviceId/Tests (from `../../node_modules/@ping-identity/rn-device-id`)" - "RNPingDeviceProfile (from `../../node_modules/@ping-identity/rn-device-profile`)" + - "RNPingDeviceProfile/Tests (from `../../node_modules/@ping-identity/rn-device-profile`)" - "RNPingJourney (from `../../node_modules/@ping-identity/rn-journey`)" - "RNPingJourney/Tests (from `../../node_modules/@ping-identity/rn-journey`)" - "RNPingLogger (from `../../node_modules/@ping-identity/rn-logger`)" @@ -3055,10 +3120,10 @@ SPEC CHECKSUMS: ReactCodegen: 1e9f3e8a3f56fa25fbf39ecd37b708a4838d9032 ReactCommon: 96684b90b235d6ae340d126141edd4563b7a446a RNCAsyncStorage: 767abb068db6ad28b5f59a129fbc9fab18b377e2 - RNPingBrowser: ee98bfb8ab8f803dd29deb04ae74f64d0dc9f911 + RNPingBrowser: f6a00b6cabf89afc70a196e0a8bbce210e6f858a RNPingCore: f41b28de7c94d2e2af7fae663531d0d0341d2775 RNPingDeviceId: fd74c58f4eb179de3052adad1c1345bd402c10f5 - RNPingDeviceProfile: 4f7d48210c1f7929db4f0a15b1bd091d4e5c64c9 + RNPingDeviceProfile: 89f1598c3d64d0209a7c22ef776a6f78485a3f44 RNPingJourney: 6f38c0a4ae2ca523541ce1950bd40440f085f2a7 RNPingLogger: 92fd0e87739f7fabf9c16883827b450b13a512f2 RNPingOidc: e522fcc12a6ed3e6e4ed8688db18e191aa1e1db1 @@ -3066,6 +3131,6 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 703055a9f39562521cdb8657162dfd80f8c174c3 -PODFILE CHECKSUM: 2ec3648cfe2d0057c7e2ef26c5d28528364804e7 +PODFILE CHECKSUM: 735d5585ceafce431efeca006a56d8949a1d901f COCOAPODS: 1.15.2 diff --git a/PingTestRunner/package.json b/PingTestRunner/package.json index ed5fda9d..f2eb00cd 100644 --- a/PingTestRunner/package.json +++ b/PingTestRunner/package.json @@ -18,9 +18,15 @@ "e2e:android:free-detox-port": "sh -c 'port=${DETOX_SERVER_PORT:-8099}; pids=$(lsof -tiTCP:$port -sTCP:LISTEN 2>/dev/null || true); [ -n \"$pids\" ] && kill -9 $pids || true'", "e2e:android:kill-emulators": "sh -c 'command -v adb >/dev/null 2>&1 || exit 0; adb devices | awk \"/^emulator-/{print \\$1}\" | while read -r id; do [ -n \"$id\" ] && adb -s \"$id\" emu kill || true; done'", "test:e2e:android": "sh -c 'export DETOX_SERVER_PORT=${DETOX_SERVER_PORT:-8100}; export NODE_NO_WARNINGS=1; yarn e2e:android:free-detox-port && yarn e2e:android:kill-emulators && detox test --configuration android.emu --cleanup \"$@\"' --", + "test:bs:android": "NODE_NO_WARNINGS=1 detox test --configuration android.bs --loglevel trace", "test:e2e:ios": "sh -c 'export DETOX_SERVER_PORT=${DETOX_SERVER_PORT:-8099}; export NODE_NO_WARNINGS=1; yarn e2e:android:free-detox-port && detox test --configuration ios.sim \"$@\"' --", "build:e2e:android": "detox build --configuration android.emu", + "build:bs:android": "cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release", "build:e2e:ios": "detox build --configuration ios.sim", + "build:bs:ios:archive": "sh ./scripts/build-bs-ios-archive.sh", + "build:bs:ios:ipa": "sh ./scripts/build-bs-ios-ipa.sh", + "build:bs:ios:test-suite": "sh ./scripts/build-bs-ios-test-suite.sh", + "build:bs:ios": "yarn build:bs:ios:archive && yarn build:bs:ios:ipa && yarn build:bs:ios:test-suite", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { @@ -51,7 +57,7 @@ "@testing-library/react-native": "^13.3.3", "@types/jest": "^29.5.14", "@types/react": "^19.1.0", - "detox": "^20.47.0", + "detox": "npm:@browserstack/detox@20.38.0-cloud.1", "dotenv": "^16.4.7", "eslint": "^8.19.0", "jest": "^29.7.0", diff --git a/PingTestRunner/scenarios/DeviceProfileScenario.tsx b/PingTestRunner/scenarios/DeviceProfileScenario.tsx index 005d46d7..1a2d9c8c 100644 --- a/PingTestRunner/scenarios/DeviceProfileScenario.tsx +++ b/PingTestRunner/scenarios/DeviceProfileScenario.tsx @@ -8,17 +8,13 @@ /** * DeviceProfileScenario — headless test screen for device-profile E2E tests. * - * Provides two collection buttons: + * Provides collection buttons for: * - Empty collectors [] (matches Android testDeviceProfileCallbackWithDefaultCollectors) * - Named collectors [platform, hardware] (matches Android testDeviceProfileCallbackWithCustomCollectors) */ import React, { useCallback, useState } from 'react'; -import { - Button, - Text, - View, -} from 'react-native'; +import { Button, Text, View } from 'react-native'; import { collectDeviceProfile } from '@ping-identity/rn-device-profile'; import type { DeviceProfileCollector } from '@ping-identity/rn-device-profile'; @@ -26,16 +22,19 @@ export default function DeviceProfileScenario(): React.JSX.Element { const [result, setResult] = useState(null); const [errorMessage, setErrorMessage] = useState(null); - const runCollect = useCallback(async (collectors: DeviceProfileCollector[]) => { - setResult(null); - setErrorMessage(null); - try { - const profile = await collectDeviceProfile(collectors); - setResult(JSON.stringify(profile)); - } catch (e) { - setErrorMessage(e instanceof Error ? e.message : String(e)); - } - }, []); + const runCollect = useCallback( + async (collectors: DeviceProfileCollector[]) => { + setResult(null); + setErrorMessage(null); + try { + const profile = await collectDeviceProfile(collectors); + setResult(JSON.stringify(profile)); + } catch (e) { + setErrorMessage(e instanceof Error ? e.message : String(e)); + } + }, + [], + ); return ( @@ -47,12 +46,12 @@ export default function DeviceProfileScenario(): React.JSX.Element {