diff --git a/.github/workflows/browserstack-e2e-android-journey.yml b/.github/workflows/browserstack-e2e-android-journey.yml index da00588d5..d5cbd0752 100644 --- a/.github/workflows/browserstack-e2e-android-journey.yml +++ b/.github/workflows/browserstack-e2e-android-journey.yml @@ -88,7 +88,7 @@ jobs: e2e-android-journey-davinci: name: E2E — Android BrowserStack (journey & davinci) runs-on: ubuntu-latest - timeout-minutes: 55 + timeout-minutes: 60 defaults: run: working-directory: PingTestRunner diff --git a/.github/workflows/browserstack-e2e-ios.yml b/.github/workflows/browserstack-e2e-ios.yml index b9952ee12..bc8f5a43a 100644 --- a/.github/workflows/browserstack-e2e-ios.yml +++ b/.github/workflows/browserstack-e2e-ios.yml @@ -45,6 +45,24 @@ on: PING_CALLBACK_TREES_ENABLED: description: Flag enabling callback tree coverage in BrowserStack E2E tests required: false + PINGONE_DISCOVERY_ENDPOINT: + description: PingOne DaVinci OIDC discovery endpoint + required: false + PINGONE_CLIENT_ID: + description: PingOne DaVinci OIDC client identifier + required: false + PINGONE_REDIRECT_URI: + description: PingOne DaVinci OIDC redirect URI + required: false + PINGONE_ACR_VALUES: + description: ACR values to route to the correct DaVinci flow + required: false + PINGONE_USERNAME: + description: PingOne DaVinci test account username + required: false + PINGONE_PASSWORD: + description: PingOne DaVinci test account password + required: false CERTIFICATES_FILE_BASE64: description: Apple signing certificate exported as base64 encoded .p12 required: true @@ -80,7 +98,7 @@ jobs: name: E2E — iOS BrowserStack runs-on: ubuntu-latest needs: prepare-ios-artifacts - timeout-minutes: 60 + timeout-minutes: 70 outputs: build_id: ${{ steps.start-build.outputs.build_id }} @@ -98,6 +116,12 @@ jobs: PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} + PINGONE_DISCOVERY_ENDPOINT: ${{ secrets.PINGONE_DISCOVERY_ENDPOINT }} + PINGONE_CLIENT_ID: ${{ secrets.PINGONE_CLIENT_ID }} + PINGONE_REDIRECT_URI: ${{ secrets.PINGONE_REDIRECT_URI }} + PINGONE_ACR_VALUES: ${{ secrets.PINGONE_ACR_VALUES }} + PINGONE_USERNAME: ${{ secrets.PINGONE_USERNAME }} + PINGONE_PASSWORD: ${{ secrets.PINGONE_PASSWORD }} steps: - name: Download IPA artifact @@ -195,6 +219,12 @@ jobs: --arg pingClientId "$PING_CLIENT_ID" \ --arg pingRedirectUri "$PING_REDIRECT_URI" \ --arg pingCallbackTreesEnabled "$PING_CALLBACK_TREES_ENABLED" \ + --arg pingoneDiscoveryEndpoint "$PINGONE_DISCOVERY_ENDPOINT" \ + --arg pingoneClientId "$PINGONE_CLIENT_ID" \ + --arg pingoneRedirectUri "$PINGONE_REDIRECT_URI" \ + --arg pingoneAcrValues "$PINGONE_ACR_VALUES" \ + --arg pingoneUsername "$PINGONE_USERNAME" \ + --arg pingonePassword "$PINGONE_PASSWORD" \ '{ app: $app, testSuite: $testSuite, @@ -215,7 +245,13 @@ jobs: PING_DISCOVERY_ENDPOINT: $pingDiscoveryEndpoint, PING_CLIENT_ID: $pingClientId, PING_REDIRECT_URI: $pingRedirectUri, - PING_CALLBACK_TREES_ENABLED: $pingCallbackTreesEnabled + PING_CALLBACK_TREES_ENABLED: $pingCallbackTreesEnabled, + PINGONE_DISCOVERY_ENDPOINT: $pingoneDiscoveryEndpoint, + PINGONE_CLIENT_ID: $pingoneClientId, + PINGONE_REDIRECT_URI: $pingoneRedirectUri, + PINGONE_ACR_VALUES: $pingoneAcrValues, + PINGONE_USERNAME: $pingoneUsername, + PINGONE_PASSWORD: $pingonePassword } }' )" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 559eb3931..403082865 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,12 @@ jobs: PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} + PINGONE_DISCOVERY_ENDPOINT: ${{ secrets.PINGONE_DISCOVERY_ENDPOINT }} + PINGONE_CLIENT_ID: ${{ secrets.PINGONE_CLIENT_ID }} + PINGONE_REDIRECT_URI: ${{ secrets.PINGONE_REDIRECT_URI }} + PINGONE_ACR_VALUES: ${{ secrets.PINGONE_ACR_VALUES }} + PINGONE_USERNAME: ${{ secrets.PINGONE_USERNAME }} + PINGONE_PASSWORD: ${{ secrets.PINGONE_PASSWORD }} CERTIFICATES_FILE_BASE64: ${{ secrets.CERTIFICATES_FILE_BASE64 }} CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} @@ -202,3 +208,35 @@ jobs: uses: ./.github/workflows/preview-docs.yml with: pr_number: ${{ github.event.pull_request.number }} + + # Mend security scans replace the retired Mend GitHub App integration. + # All three jobs are skipped for fork PRs: repository secrets (MEND_EMAIL, + # MEND_USER_KEY) are never exposed to workflows from forks, so the scans would + # either fail or run unauthenticated - same guard the reference ping-android-sdk + # ci.yaml applies to its Mend jobs. Scan credentials are passed explicitly, + # never via secrets: inherit, so the exposure surface stays visible here. + mend-prepare-workspace: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/mend-prepare-workspace.yml + + mend-sca-scan: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + needs: mend-prepare-workspace + permissions: + contents: read + checks: write + uses: ./.github/workflows/mend-sca-scan.yml + secrets: + MEND_EMAIL: ${{ secrets.MEND_EMAIL }} + MEND_USER_KEY: ${{ secrets.MEND_USER_KEY }} + + mend-sast-scan: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + needs: mend-prepare-workspace + permissions: + contents: read + checks: write + uses: ./.github/workflows/mend-sast-scan.yml + secrets: + MEND_EMAIL: ${{ secrets.MEND_EMAIL }} + MEND_USER_KEY: ${{ secrets.MEND_USER_KEY }} diff --git a/.github/workflows/mend-prepare-workspace.yml b/.github/workflows/mend-prepare-workspace.yml new file mode 100644 index 000000000..2f38b3523 --- /dev/null +++ b/.github/workflows/mend-prepare-workspace.yml @@ -0,0 +1,89 @@ +# +# 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: Mend Pruned Workspace + +# Builds the pruned workspace scanned by mend-sca-scan.yml and publishes it as +# the mend-scan-workspace artifact. +# +# SCA scans a pruned workspace containing only packages/* (the published SDKs), NOT the +# full monorepo. The sample app and test runner are build/test tooling: their runtime +# dependency tree (react-native, metro, and everything transitively reachable from them, +# e.g. image-size) does not ship to consumers, so it is out of scope for the SDK's +# dependency inventory. The pruned workspace is generated from the real yarn.lock so +# resolved versions stay identical to what CI tests. +on: + workflow_call: + +permissions: + contents: read + +jobs: + prepare-workspace: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set SCAN_DIR + run: echo "SCAN_DIR=${RUNNER_TEMP}/mend-scan" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + # Build the pruned workspace: packages/* only, with a generated root manifest that + # preserves packageManager, engines, and resolutions from the real root. The real + # yarn.lock is copied over and pruned by the install below, keeping the exact + # resolved versions CI tests against — no version drift between the two graphs. + # The yarn release binary is symlinked onto PATH because the Mend CLI shells out + # to `yarn` to enumerate the dependency graph: hosted runners preinstall yarn 1.x, + # which ignores .yarnrc.yml's yarnPath and does not support the install flags used + # here; containers used for local workflow runs (act) may have no yarn at all. + - name: Prepare pruned workspace + run: | + mkdir -p "${SCAN_DIR}/.yarn/releases" + cp -R packages "${SCAN_DIR}/packages" + cp .yarnrc.yml "${SCAN_DIR}/.yarnrc.yml" + cp .yarn/releases/*.cjs "${SCAN_DIR}/.yarn/releases/" + cp yarn.lock "${SCAN_DIR}/yarn.lock" + chmod +x "${SCAN_DIR}/.yarn/releases/"*.cjs + ln -sf "${SCAN_DIR}/.yarn/releases/yarn-4.11.0.cjs" /usr/local/bin/yarn + node -e ' + const fs = require("fs"); + const root = JSON.parse(fs.readFileSync("package.json", "utf8")); + const scan = { + name: "ping-sdk-mend-scan", + private: true, + packageManager: root.packageManager, + engines: root.engines, + resolutions: root.resolutions, + workspaces: { packages: ["packages/*"] }, + }; + fs.writeFileSync(process.env.SCAN_DIR + "/package.json", JSON.stringify(scan, null, 2) + "\n"); + ' + + # --mode=update-lockfile resolves the pruned workspace and garbage-collects the + # lockfile entries no longer referenced (the entire app/test-runner tree) without + # fetching or linking packages. + - name: Resolve pruned lockfile + run: | + cd "${SCAN_DIR}" + yarn install --mode=update-lockfile + + # upload-artifact v4 zips the tree without preserving symlinks, so the consumer + # (mend-sca-scan.yml) recreates the yarn shim from the bundled release binary. + - name: Upload pruned workspace + uses: actions/upload-artifact@v4 + with: + name: mend-scan-workspace + path: ${{ env.SCAN_DIR }} + retention-days: 1 diff --git a/.github/workflows/mend-sast-scan.yml b/.github/workflows/mend-sast-scan.yml new file mode 100644 index 000000000..480969a57 --- /dev/null +++ b/.github/workflows/mend-sast-scan.yml @@ -0,0 +1,254 @@ +# +# 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: Mend SAST Scan + +# SAST + secrets scanning of the pruned workspace produced by +# mend-prepare-workspace.yml (packages/* only - same scope as mend-sca-scan.yml). +# Replaces the Mend GitHub App's .whitesource scanSettingsSAST (enableScan / +# enableSecretsScan / scanPullRequests). The standalone CLI does not read +# .mendsastcli-config.json (that file is an SCM-integration artifact), so path +# exclusions (e.g. test sources within packages/*) are passed via the +# MEND_SAST_PATH_EXCLUSIONS repo variable. +# +# Raw scan output is never rendered in the job summary or Check run text: with +# --secrets-detection enabled, the CLI output can contain the matched secret +# value/snippet, and both the summary and Check output are readable by anyone +# with repo read access. Only severity counts and the Mend report link are +# published there; the full output is uploaded as a short-retention artifact. +on: + workflow_call: + secrets: + MEND_EMAIL: + description: Mend account email used for CLI authentication + required: true + MEND_USER_KEY: + description: Mend user key (Organization > User Settings in the Mend UI) + required: true + +permissions: + contents: read + checks: write + +jobs: + mend-sast-scan: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Set SCAN_DIR + run: echo "SCAN_DIR=${RUNNER_TEMP}/mend-scan" >> "$GITHUB_ENV" + + - name: Download pruned workspace + uses: actions/download-artifact@v4 + with: + name: mend-scan-workspace + path: ${{ env.SCAN_DIR }} + + # No Setup Node step: this workflow never checks out the repo (the pruned + # workspace arrives as an artifact, so node-version-file's package.json is + # not in the runner workspace) and nothing here needs Node - the CLI is a + # self-contained binary and parsing uses jq/grep. + + # Fetch just the checksum first (a few bytes) so the cache key reflects the + # CLI version Mend is currently serving. Keying on hashFiles of the + # executable itself doesn't work: that file doesn't exist yet on a fresh + # runner, so hashFiles() always resolved to the same empty hash, the cache + # key never changed, and the first binary ever cached was reused forever - + # a Mend CLI release would never actually get picked up. + - name: Fetch Mend CLI checksum + run: curl -fsSL https://downloads.mend.io/cli/linux_amd64/mend.sha256 -o mend.sha256 + + # Install to /usr/local/bin (already on PATH, writable on hosted runners), matching + # the reference ping-android-sdk workflows. Cached on the checksum so a new Mend + # CLI release busts the cache and re-downloads. + - name: Download and cache the Mend CLI executable + id: cache-mend + uses: actions/cache@v4 + env: + mend-cache-name: cache-mend-executable + with: + path: /usr/local/bin/mend + key: ${{ runner.os }}-${{ env.mend-cache-name }}-${{ hashFiles('mend.sha256') }} + + - if: ${{ steps.cache-mend.outputs.cache-hit != 'true' }} + name: Download Mend CLI executable (cache miss...) + shell: bash + run: | + curl -fsSL https://downloads.mend.io/cli/linux_amd64/mend -o /usr/local/bin/mend + expected="$(awk '{print $1}' mend.sha256)" + actual="$(sha256sum /usr/local/bin/mend | awk '{print $1}')" + if [ "${actual}" != "${expected}" ]; then + echo "::error::Mend CLI checksum mismatch: expected ${expected}, got ${actual}" + exit 1 + fi + chmod +x /usr/local/bin/mend + + # SAST + secrets on the pruned workspace (packages/* only - matches + # mend-sca-scan.yml's scope, so sample-app/test-runner code never enters the + # SDK's SAST inventory). Path exclusions come from the + # MEND_SAST_PATH_EXCLUSIONS repository variable (comma-separated regex list, + # single line, no spaces) and target test sources within packages/*. + # --no-default-exclusions is required because Mend's built-in list drops any + # directory named "packages", which is exactly where the pruned workspace's + # source lives. Legacy command alias: mend sast. --report with --formats + # json produces the machine-readable report parsed below; stdout is + # redirected to a file for counting only - see the summary/Check steps + # below for why its contents are never rendered in either. + - name: Scan code and secrets + env: + MEND_URL: ${{ vars.MEND_SERVER_URL }} + MEND_EMAIL: ${{ secrets.MEND_EMAIL }} + MEND_USER_KEY: ${{ secrets.MEND_USER_KEY }} + MEND_PRODUCT_NAME: ${{ vars.MEND_PRODUCT_NAME }} + MEND_PROJECT_NAME: ${{ vars.MEND_PROJECT_NAME }} + MEND_SAST_PATH_EXCLUSIONS: ${{ vars.MEND_SAST_PATH_EXCLUSIONS }} + continue-on-error: true + run: | + if [ -z "${MEND_PRODUCT_NAME}" ] || [ -z "${MEND_PROJECT_NAME}" ]; then + echo "::error::Repository variables MEND_PRODUCT_NAME and MEND_PROJECT_NAME are not set. Set them to the Mend product and project names (scope: product//project)." + exit 1 + fi + if [ -z "${MEND_SAST_PATH_EXCLUSIONS}" ]; then + echo "::error::Repository variable MEND_SAST_PATH_EXCLUSIONS is not set. Set it to a comma-separated regex list of SAST path exclusions." + exit 1 + fi + mend code --report --filename mend-sast-report --formats json --non-interactive --no-default-exclusions --secrets-detection --dir "${SCAN_DIR}" -s "${MEND_PRODUCT_NAME}//${MEND_PROJECT_NAME}" > mend-sast-scan-result.txt + + # Extract severity counts from the JSON report (jq, as in the reference + # ping-android-sdk mend-sast-scan.yaml) and the report URL from stdout. + - name: Parse SAST scan results + if: always() + shell: bash + run: | + MEND_SAST_SCAN_URL=$(grep -Eo '(http|https)://saas\.whitesourcesoftware\.com[^ ]+' mend-sast-scan-result.txt | head -n 1) + + # The report is a top-level array; element [0] carries the run stats. + if [ -f mend-sast-report.json ]; then + MEND_SAST_TOTAL_VULNERABILITIES_COUNT=$(jq '.[0].stats.totalVulnerabilities' mend-sast-report.json) + MEND_SAST_CRITICAL_COUNT=$(jq '.[0].stats.critical' mend-sast-report.json) + MEND_SAST_HIGH_COUNT=$(jq '.[0].stats.high' mend-sast-report.json) + MEND_SAST_MEDIUM_COUNT=$(jq '.[0].stats.medium' mend-sast-report.json) + MEND_SAST_LOW_COUNT=$(jq '.[0].stats.low' mend-sast-report.json) + else + echo "::warning::mend-sast-report.json not found; counts default to 0 (scan step may have failed - check its output above)" + MEND_SAST_TOTAL_VULNERABILITIES_COUNT=0 + MEND_SAST_CRITICAL_COUNT=0 + MEND_SAST_HIGH_COUNT=0 + MEND_SAST_MEDIUM_COUNT=0 + MEND_SAST_LOW_COUNT=0 + fi + + if [ ! -z "$MEND_SAST_SCAN_URL" ]; then + echo "MEND_SAST_SCAN_URL=$MEND_SAST_SCAN_URL" >> $GITHUB_ENV + fi + echo "MEND_SAST_TOTAL_VULNERABILITIES_COUNT=$MEND_SAST_TOTAL_VULNERABILITIES_COUNT" >> $GITHUB_ENV + echo "MEND_SAST_CRITICAL_COUNT=$MEND_SAST_CRITICAL_COUNT" >> $GITHUB_ENV + echo "MEND_SAST_HIGH_COUNT=$MEND_SAST_HIGH_COUNT" >> $GITHUB_ENV + echo "MEND_SAST_MEDIUM_COUNT=$MEND_SAST_MEDIUM_COUNT" >> $GITHUB_ENV + echo "MEND_SAST_LOW_COUNT=$MEND_SAST_LOW_COUNT" >> $GITHUB_ENV + + # Badge + severity table + report link into the job summary. Deliberately + # omits the raw CLI output (see the note at the top of this file) - this is + # an intentional deviation from the reference ping-android-sdk + # mend-sast-scan.yaml's summary block, which does include it. + - name: Show the SAST Report in Summary + if: always() + shell: bash + run: | + # Pick badge color based on severity counts + if [ "$MEND_SAST_CRITICAL_COUNT" -gt 0 ]; then + BADGE_COLOR="red" + BADGE_TEXT="${MEND_SAST_CRITICAL_COUNT}_critical" + elif [ "$MEND_SAST_HIGH_COUNT" -gt 0 ]; then + BADGE_COLOR="orange" + BADGE_TEXT="${MEND_SAST_HIGH_COUNT}_high" + elif [ "$MEND_SAST_MEDIUM_COUNT" -gt 0 ]; then + BADGE_COLOR="yellow" + BADGE_TEXT="${MEND_SAST_MEDIUM_COUNT}_medium" + elif [ "$MEND_SAST_LOW_COUNT" -gt 0 ]; then + BADGE_COLOR="blue" + BADGE_TEXT="${MEND_SAST_LOW_COUNT}_low" + else + BADGE_COLOR="brightgreen" + BADGE_TEXT="clean" + fi + + BADGE_URL="https://img.shields.io/badge/SAST-${BADGE_TEXT}-${BADGE_COLOR}" + + echo "## Mend SAST Report ![SAST Badge](${BADGE_URL})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Show counts in a table + echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| 🔴 Critical | $MEND_SAST_CRITICAL_COUNT |" >> $GITHUB_STEP_SUMMARY + echo "| 🟠 High | $MEND_SAST_HIGH_COUNT |" >> $GITHUB_STEP_SUMMARY + echo "| 🟡 Medium | $MEND_SAST_MEDIUM_COUNT |" >> $GITHUB_STEP_SUMMARY + echo "| 🔵 Low | $MEND_SAST_LOW_COUNT |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Add Mend report link if available + if [ -n "$MEND_SAST_SCAN_URL" ]; then + echo "**🔗 [View Full Mend SAST Report]($MEND_SAST_SCAN_URL)**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + # Raw CLI output is deliberately not rendered here: with + # --secrets-detection enabled it can contain the matched secret + # value/snippet, and this summary is readable by anyone with repo read + # access. See "Upload SAST scan report" for the full output, kept as a + # short-retention artifact instead. + echo "Full CLI output is available in the mend-sast-report artifact for this run (1-day retention)." >> $GITHUB_STEP_SUMMARY + + # Step 1: Decide Check Conclusion + - name: Determine Mend SAST Check Conclusion + id: sast_conclusion + if: always() + shell: bash + run: | + if [ "$MEND_SAST_CRITICAL_COUNT" -gt 0 ] || [ "$MEND_SAST_HIGH_COUNT" -gt 0 ]; then + echo "conclusion=failure" >> $GITHUB_OUTPUT + else + echo "conclusion=success" >> $GITHUB_OUTPUT + fi + + # Step 2: Publish Mend SAST Check + # Deliberately does not attach the raw CLI output as Check text: with + # --secrets-detection enabled that output can contain the matched secret + # value/snippet, and Check run output is readable by anyone with repo read + # access. Counts only; the full output lives in the short-retention + # artifact uploaded below. + - name: Publish Mend SAST Check + uses: actions/github-script@v7 + if: always() + with: + script: | + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: "Mend SAST Report", + head_sha: '${{ github.event.pull_request.head.sha || github.sha }}', + status: "completed", + conclusion: "${{ steps.sast_conclusion.outputs.conclusion }}", + output: { + title: "Mend SAST Report", + summary: `Critical: ${process.env.MEND_SAST_CRITICAL_COUNT}, High: ${process.env.MEND_SAST_HIGH_COUNT}, Medium: ${process.env.MEND_SAST_MEDIUM_COUNT}, Low: ${process.env.MEND_SAST_LOW_COUNT}` + } + }); + + # Short retention: this artifact can contain matched secret values/snippets + # when --secrets-detection fires, so it should not linger past what's + # needed to investigate the current run. + - name: Upload SAST scan report + uses: actions/upload-artifact@v4 + if: always() + with: + name: mend-sast-report + path: | + mend-sast-scan-result.txt + mend-sast-report.json + retention-days: 1 diff --git a/.github/workflows/mend-sca-scan.yml b/.github/workflows/mend-sca-scan.yml new file mode 100644 index 000000000..edb1427ec --- /dev/null +++ b/.github/workflows/mend-sca-scan.yml @@ -0,0 +1,242 @@ +# +# 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: Mend SCA Scan + +# Dependency (SCA) scan of the pruned workspace produced by mend-prepare-workspace.yml. +# Command shape and report parsing follow the reference ping-android-sdk +# mend-sca-scan.yaml workflow: MEND_URL/MEND_EMAIL/MEND_USER_KEY auth with the target +# expressed as MEND_PRODUCT_NAME//MEND_PROJECT_NAME. Results are parsed into severity +# counts, rendered in the job summary, published as the "Mend SCA Report" check run, +# and uploaded as an artifact. +on: + workflow_call: + secrets: + MEND_EMAIL: + description: Mend account email used for CLI authentication + required: true + MEND_USER_KEY: + description: Mend user key (Organization > User Settings in the Mend UI) + required: true + +permissions: + contents: read + checks: write + +jobs: + mend-sca-scan: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Set SCAN_DIR + run: echo "SCAN_DIR=${RUNNER_TEMP}/mend-scan" >> "$GITHUB_ENV" + + # No Setup Node step: this workflow never checks out the repo (the pruned + # workspace arrives as an artifact, so node-version-file's package.json is + # not in the runner workspace) and nothing here needs Node - the CLI is a + # self-contained binary and parsing uses jq/grep. + + # Fetch just the checksum first (a few bytes) so the cache key reflects the + # CLI version Mend is currently serving. Keying on hashFiles of the + # executable itself doesn't work: that file doesn't exist yet on a fresh + # runner, so hashFiles() always resolved to the same empty hash, the cache + # key never changed, and the first binary ever cached was reused forever - + # a Mend CLI release would never actually get picked up. + - name: Fetch Mend CLI checksum + run: curl -fsSL https://downloads.mend.io/cli/linux_amd64/mend.sha256 -o mend.sha256 + + # Install to /usr/local/bin (already on PATH, writable on hosted runners), matching + # the reference ping-android-sdk workflows. Cached on the checksum so a new Mend + # CLI release busts the cache and re-downloads. + - name: Download and cache the Mend CLI executable + id: cache-mend + uses: actions/cache@v4 + env: + mend-cache-name: cache-mend-executable + with: + path: /usr/local/bin/mend + key: ${{ runner.os }}-${{ env.mend-cache-name }}-${{ hashFiles('mend.sha256') }} + + - if: ${{ steps.cache-mend.outputs.cache-hit != 'true' }} + name: Download Mend CLI executable (cache miss...) + shell: bash + run: | + curl -fsSL https://downloads.mend.io/cli/linux_amd64/mend -o /usr/local/bin/mend + expected="$(awk '{print $1}' mend.sha256)" + actual="$(sha256sum /usr/local/bin/mend | awk '{print $1}')" + if [ "${actual}" != "${expected}" ]; then + echo "::error::Mend CLI checksum mismatch: expected ${expected}, got ${actual}" + exit 1 + fi + chmod +x /usr/local/bin/mend + + - name: Download pruned workspace + uses: actions/download-artifact@v4 + with: + name: mend-scan-workspace + path: ${{ env.SCAN_DIR }} + + # Recreate the yarn shim lost in artifact compression (see mend-prepare-workspace.yml). + - name: Restore yarn shim + run: ln -sf "${SCAN_DIR}/.yarn/releases/yarn-4.11.0.cjs" /usr/local/bin/yarn + + # --no-default-exclusions is required: Mend's built-in exclusions drop any + # directory named "packages", which is exactly where our workspaces live. + # Scoped to production dependencies only (no --dev), matching the reference + # ping-android-sdk workflow's scan scope. Output is teed to a file so the + # report/summary steps below can parse severities even if the scan exits + # non-zero; the step itself is continue-on-error so a policy violation does + # not mask the parsed summary (conclusion is decided in its own step). + - name: Scan dependencies + env: + MEND_URL: ${{ vars.MEND_SERVER_URL }} + MEND_EMAIL: ${{ secrets.MEND_EMAIL }} + MEND_USER_KEY: ${{ secrets.MEND_USER_KEY }} + MEND_PRODUCT_NAME: ${{ vars.MEND_PRODUCT_NAME }} + MEND_PROJECT_NAME: ${{ vars.MEND_PROJECT_NAME }} + continue-on-error: true + run: | + if [ -z "${MEND_PRODUCT_NAME}" ] || [ -z "${MEND_PROJECT_NAME}" ]; then + echo "::error::Repository variables MEND_PRODUCT_NAME and MEND_PROJECT_NAME are not set. Set them to the Mend product and project names (scope: product//project)." + exit 1 + fi + mend dep --no-color -s "${MEND_PRODUCT_NAME}//${MEND_PROJECT_NAME}" -u --dir "${SCAN_DIR}" --no-default-exclusions 2>&1 | tee mend-sca-scan-result.txt + + # Extract severity counts and the report URL from the teed scan output into + # env vars consumed by the summary/check steps below. Mirrors the parsing + # block of the reference ping-android-sdk mend-sca-scan.yaml. + - name: Parse SCA scan results + if: always() + shell: bash + run: | + # Extract scan results with more robust parsing + MEND_SCA_SCAN_URL=$(grep -Eo '(http|https)://saas\.whitesourcesoftware\.com[^ ]+' mend-sca-scan-result.txt | head -n 1) + MEND_SCA_SCAN_SUMMARY=$(grep -E "Detected.*vulnerabilities" mend-sca-scan-result.txt || echo 'No vulnerabilities detected') + + # Initialize counts with default value of 0 + MEND_SCA_CRITICAL_COUNT=0 + MEND_SCA_HIGH_COUNT=0 + MEND_SCA_MEDIUM_COUNT=0 + MEND_SCA_LOW_COUNT=0 + + # Extract vulnerability counts with error handling + if grep -q "Critical" mend-sca-scan-result.txt; then + MEND_SCA_CRITICAL_COUNT=$(grep -Eo '[0-9]+ Critical' mend-sca-scan-result.txt | awk '{print $1}') + fi + if grep -q "High" mend-sca-scan-result.txt; then + MEND_SCA_HIGH_COUNT=$(grep -Eo '[0-9]+ High' mend-sca-scan-result.txt | awk '{print $1}') + fi + if grep -q "Medium" mend-sca-scan-result.txt; then + MEND_SCA_MEDIUM_COUNT=$(grep -Eo '[0-9]+ Medium' mend-sca-scan-result.txt | awk '{print $1}') + fi + if grep -q "Low" mend-sca-scan-result.txt; then + MEND_SCA_LOW_COUNT=$(grep -Eo '[0-9]+ Low' mend-sca-scan-result.txt | awk '{print $1}') + fi + + # Set environment variables with error checking + if [ ! -z "$MEND_SCA_SCAN_URL" ]; then + echo "MEND_SCA_SCAN_URL=$MEND_SCA_SCAN_URL" >> $GITHUB_ENV + fi + echo "MEND_SCA_SCAN_SUMMARY=$MEND_SCA_SCAN_SUMMARY" >> $GITHUB_ENV + echo "MEND_SCA_CRITICAL_COUNT=$MEND_SCA_CRITICAL_COUNT" >> $GITHUB_ENV + echo "MEND_SCA_HIGH_COUNT=$MEND_SCA_HIGH_COUNT" >> $GITHUB_ENV + echo "MEND_SCA_MEDIUM_COUNT=$MEND_SCA_MEDIUM_COUNT" >> $GITHUB_ENV + echo "MEND_SCA_LOW_COUNT=$MEND_SCA_LOW_COUNT" >> $GITHUB_ENV + + # Render a badge + severity table + collapsible raw output into the job + # summary. Mirrors the reference ping-android-sdk mend-sca-scan.yaml. + - name: Show the SCA Report in Summary + if: always() + shell: bash + run: | + # Pick badge color based on severity counts + if [ "$MEND_SCA_CRITICAL_COUNT" -gt 0 ]; then + BADGE_COLOR="red" + BADGE_TEXT="${MEND_SCA_CRITICAL_COUNT}_critical" + elif [ "$MEND_SCA_HIGH_COUNT" -gt 0 ]; then + BADGE_COLOR="orange" + BADGE_TEXT="${MEND_SCA_HIGH_COUNT}_high" + elif [ "$MEND_SCA_MEDIUM_COUNT" -gt 0 ]; then + BADGE_COLOR="yellow" + BADGE_TEXT="${MEND_SCA_MEDIUM_COUNT}_medium" + elif [ "$MEND_SCA_LOW_COUNT" -gt 0 ]; then + BADGE_COLOR="blue" + BADGE_TEXT="${MEND_SCA_LOW_COUNT}_low" + else + BADGE_COLOR="brightgreen" + BADGE_TEXT="clean" + fi + + BADGE_URL="https://img.shields.io/badge/SCA-${BADGE_TEXT}-${BADGE_COLOR}" + + echo "## Mend SCA Report ![SCA Badge](${BADGE_URL})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Show counts in a table + echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| 🔴 Critical | $MEND_SCA_CRITICAL_COUNT |" >> $GITHUB_STEP_SUMMARY + echo "| 🟠 High | $MEND_SCA_HIGH_COUNT |" >> $GITHUB_STEP_SUMMARY + echo "| 🟡 Medium | $MEND_SCA_MEDIUM_COUNT |" >> $GITHUB_STEP_SUMMARY + echo "| 🔵 Low | $MEND_SCA_LOW_COUNT |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Add Mend report link if available + if [ -n "$MEND_SCA_SCAN_URL" ]; then + echo "**🔗 [View Full Mend SCA Report]($MEND_SCA_SCAN_URL)**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + # Expandable section for full output + echo "
Expand for details" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat mend-sca-scan-result.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + + # Step 1: Decide Check Conclusion + - name: Determine Mend SCA Check Conclusion + id: sca_conclusion + if: always() + shell: bash + run: | + if [ "$MEND_SCA_CRITICAL_COUNT" -gt 0 ] || [ "$MEND_SCA_HIGH_COUNT" -gt 0 ]; then + echo "conclusion=failure" >> $GITHUB_OUTPUT + else + echo "conclusion=success" >> $GITHUB_OUTPUT + fi + + # Step 2: Publish Mend SCA Check + - name: Publish Mend SCA Check + uses: actions/github-script@v7 + if: always() + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('mend-sca-scan-result.txt', 'utf8'); + + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: "Mend SCA Report", + head_sha: '${{ github.event.pull_request.head.sha || github.sha }}', + status: "completed", + conclusion: "${{ steps.sca_conclusion.outputs.conclusion }}", + output: { + title: "Mend SCA Report", + summary: `Critical: ${process.env.MEND_SCA_CRITICAL_COUNT}, High: ${process.env.MEND_SCA_HIGH_COUNT}`, + text: "```\n" + body + "\n```" + } + }); + + - name: Upload SCA scan report + uses: actions/upload-artifact@v4 + if: always() + with: + name: mend-sca-report + path: mend-sca-scan-result.txt diff --git a/.whitesource b/.whitesource index f7b454b63..317443577 100644 --- a/.whitesource +++ b/.whitesource @@ -26,7 +26,8 @@ }, "issueSettings": { "minSeverityLevel": "LOW", - "issueType": "DEPENDENCY" + "issueType": "DEPENDENCY", + "reopenClosedIssues": true }, "issueSettingsSAST": { "minSeverityLevel": "high", diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index e1dd99721..50c023c47 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/AGENTS.md b/AGENTS.md index 47aad647a..a95bc6a1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -376,4 +376,4 @@ All CI workflows live under `.github/workflows/`. `ci.yml` is the top-level PR c | `preview-docs.yml` | PR opened / updated | Publishes docs preview to GitHub Pages for the PR | | `publish-docs.yml` | Push to `main` | Publishes final docs to GitHub Pages | | `cleanup-docs-preview.yml` | PR closed | Removes the PR docs preview from GitHub Pages | -| `mend-cli-scan.yml` | Scheduled | Mend (WhiteSource) security and dependency vulnerability scan | +| `mend-cli-scan.yml` | PRs / push to `main` | Mend CLI scan — SCA on pruned `packages/*` workspace + SAST/secrets | diff --git a/PingTestRunner/.detoxrc.js b/PingTestRunner/.detoxrc.js index a94ccc317..fd37dc623 100644 --- a/PingTestRunner/.detoxrc.js +++ b/PingTestRunner/.detoxrc.js @@ -24,6 +24,10 @@ module.exports = { config: 'e2e/jest.config.js', }, retries: process.env.CI ? 4 : 0, + // Without this, Detox CLI's own retry keeps the original --testPathPattern + // alongside the failed-file paths it appends, so every retry re-matches and + // reruns the whole batch instead of just the suites that failed. + noRetryArgs: ['shard', 'testPathPattern'], jest: { setupTimeout: 300000, retries: process.env.CI ? 4 : 0, diff --git a/PingTestRunner/README.md b/PingTestRunner/README.md index e99e95f3c..4c7894066 100644 --- a/PingTestRunner/README.md +++ b/PingTestRunner/README.md @@ -276,6 +276,7 @@ PingTestRunner/ │ ├── use-oidc.test.ts # useOidc hook tests │ ├── oidc-happy-path.test.ts │ ├── davinci.test.ts # DaVinci happy-path flow (PingOne DaVinci) +│ ├── use-davinci.test.ts # useDaVinci + useDaVinciForm hook tests │ ├── device-id.test.ts │ ├── device-profile.test.ts │ ├── storage.test.ts diff --git a/PingTestRunner/android/gradle.properties b/PingTestRunner/android/gradle.properties index 78b9ff8a9..a7f2195ef 100644 --- a/PingTestRunner/android/gradle.properties +++ b/PingTestRunner/android/gradle.properties @@ -13,7 +13,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m # AndroidX package structure android.useAndroidX=true diff --git a/PingTestRunner/e2e/davinci.test.ts b/PingTestRunner/e2e/davinci.test.ts index 9c122ee8f..c27e3488c 100644 --- a/PingTestRunner/e2e/davinci.test.ts +++ b/PingTestRunner/e2e/davinci.test.ts @@ -38,6 +38,19 @@ const DAVINCI_PASSWORD_KEY = const USERNAME_INPUT = by.id(`davinci-field-${DAVINCI_USERNAME_KEY}`); const PASSWORD_INPUT = by.id(`davinci-field-${DAVINCI_PASSWORD_KEY}`); +// Detox runs only the Android e2e path (iOS e2e is XCUITest). Android +// uppercases rendered button text, so by.text('Sign On') never matches; the +// first SUBMIT_BUTTON collector keeps the shared davinci-submit-btn alias, +// which is the Sign On button in this flow. +const SUBMIT_BUTTON = by.id('davinci-submit-btn'); +// Consecutive logins to the shared DaVinci test account spaced seconds apart +// hang the flow with no node rendered; pause before each login the way the +// iOS XCUITest suite does with its loginSpacing delay. +const LOGIN_SPACING_MS = 10000; + +async function pauseBeforeLogin(): Promise { + await new Promise((resolve) => setTimeout(resolve, LOGIN_SPACING_MS)); +} const SKIP_REASON = 'Live DaVinci env vars not set — skipping DaVinci E2E tests. ' + @@ -59,16 +72,16 @@ describe('DaVinci — happy path', () => { launchArgs: { PING_TEST_SCENARIO: 'davinci', ...(DAVINCI_ENV.discoveryEndpoint - ? { PING_DISCOVERY_ENDPOINT: DAVINCI_ENV.discoveryEndpoint } + ? { PINGONE_DISCOVERY_ENDPOINT: DAVINCI_ENV.discoveryEndpoint } : {}), ...(DAVINCI_ENV.clientId - ? { PING_CLIENT_ID: DAVINCI_ENV.clientId } + ? { PINGONE_CLIENT_ID: DAVINCI_ENV.clientId } : {}), ...(DAVINCI_ENV.redirectUri - ? { PING_REDIRECT_URI: DAVINCI_ENV.redirectUri } + ? { PINGONE_REDIRECT_URI: DAVINCI_ENV.redirectUri } : {}), ...(DAVINCI_ENV.acrValues - ? { PING_ACR_VALUES: DAVINCI_ENV.acrValues } + ? { PINGONE_ACR_VALUES: DAVINCI_ENV.acrValues } : {}), }, }); @@ -100,6 +113,8 @@ describe('DaVinci — happy path', () => { await waitFor(element(PASSWORD_INPUT)) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(PASSWORD_INPUT)).toBeVisible(); }); it('next() with valid credentials returns SuccessNode (live)', async () => { @@ -110,10 +125,13 @@ describe('DaVinci — happy path', () => { await element(USERNAME_INPUT).typeText(DAVINCI_ENV.testUsername); await element(PASSWORD_INPUT).typeText(DAVINCI_ENV.testPassword); - await element(by.id('davinci-submit-btn')).tap(); + await pauseBeforeLogin(); + await element(SUBMIT_BUTTON).tap(); await waitFor(element(by.id('davinci-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('davinci-success'))).toBeVisible(); }); it('access token is available and non-empty after successful login (live)', async () => { @@ -125,6 +143,8 @@ describe('DaVinci — happy path', () => { await waitFor(element(by.id('davinci-token-result'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('davinci-token-result'))).toBeVisible(); const attrs = await element(by.id('davinci-token-result')).getAttributes(); const token = (attrs as any).text ?? (attrs as any).label ?? ''; jestExpect(token.length).toBeGreaterThan(0); @@ -142,6 +162,8 @@ describe('DaVinci — happy path', () => { await waitFor(element(by.id('davinci-userinfo-result'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('davinci-userinfo-result'))).toBeVisible(); const attrs = await element( by.id('davinci-userinfo-result'), ).getAttributes(); @@ -159,6 +181,8 @@ describe('DaVinci — happy path', () => { await waitFor(element(by.id('davinci-refreshed'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('davinci-refreshed'))).toBeVisible(); }); it('revoke() invalidates the session (live)', async () => { @@ -171,6 +195,8 @@ describe('DaVinci — happy path', () => { await waitFor(element(by.id('davinci-revoked'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('davinci-revoked'))).toBeVisible(); }); it('logoutUser() clears the session (live)', async () => { @@ -183,5 +209,7 @@ describe('DaVinci — happy path', () => { await waitFor(element(by.id('davinci-logged-out'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('davinci-logged-out'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/device-id.test.ts b/PingTestRunner/e2e/device-id.test.ts index aa2b276e5..b4a25f577 100644 --- a/PingTestRunner/e2e/device-id.test.ts +++ b/PingTestRunner/e2e/device-id.test.ts @@ -17,7 +17,7 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { device, element, by, expect as detoxExpect } from 'detox'; +import { device, element, by, expect as detoxExpect, waitFor } from 'detox'; import { expect as jestExpect } from '@jest/globals'; import { assertAppReady } from './setup'; @@ -39,11 +39,18 @@ describe('Device ID — bridge verification', () => { it('getDeviceId() returns a non-empty string', async () => { await element(by.id('device-id-get-btn')).tap(); - // result element only mounts when getDeviceId() returns a non-empty string + // First getDeviceId() generates a 2048-bit RSA key in KeyStore, and + // BrowserStack runs with synchronization disabled, so wait explicitly. + await waitFor(element(by.id('device-id-result'))) + .toBeVisible() + .withTimeout(10000); await detoxExpect(element(by.id('device-id-result'))).toBeVisible(); }); it('getDeviceId() result is a 64-char hex string (SHA-256 format)', async () => { + await waitFor(element(by.id('device-id-result'))) + .toBeVisible() + .withTimeout(10000); const attrs = await element(by.id('device-id-result')).getAttributes(); const text = (attrs as any).text ?? (attrs as any).label ?? ''; jestExpect(text).toMatch(/^[0-9a-f]{64}$/i); @@ -51,7 +58,13 @@ describe('Device ID — bridge verification', () => { it('getDeviceId() returns the same value on second call (consistency)', async () => { await element(by.id('device-id-get-again-btn')).tap(); + await waitFor(element(by.id('device-id-result-2'))) + .toBeVisible() + .withTimeout(10000); await detoxExpect(element(by.id('device-id-result-2'))).toBeVisible(); + await waitFor(element(by.id('device-id-result'))) + .toBeVisible() + .withTimeout(10000); const attrs1 = await element(by.id('device-id-result')).getAttributes(); const attrs2 = await element(by.id('device-id-result-2')).getAttributes(); const id1 = (attrs1 as any).text ?? (attrs1 as any).label ?? ''; diff --git a/PingTestRunner/e2e/journey-callback-boolean-attr.test.ts b/PingTestRunner/e2e/journey-callback-boolean-attr.test.ts index acdd6a5d5..e47bdf819 100644 --- a/PingTestRunner/e2e/journey-callback-boolean-attr.test.ts +++ b/PingTestRunner/e2e/journey-callback-boolean-attr.test.ts @@ -16,7 +16,7 @@ * 3. Toggle and submit → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -91,6 +91,10 @@ describe('Journey — BooleanAttributeInputCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces BooleanAttributeInputCallback (step 2)', async () => { @@ -110,6 +114,10 @@ describe('Journey — BooleanAttributeInputCallback', () => { ) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-BooleanAttributeInputCallback:0')), + ).toBeVisible(); }); it('toggle switch and submit → reaches SuccessNode (live)', async () => { @@ -123,5 +131,7 @@ describe('Journey — BooleanAttributeInputCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-choice.test.ts b/PingTestRunner/e2e/journey-callback-choice.test.ts index 87e1466ec..e2a2bb995 100644 --- a/PingTestRunner/e2e/journey-callback-choice.test.ts +++ b/PingTestRunner/e2e/journey-callback-choice.test.ts @@ -16,7 +16,7 @@ * 3. Select option 0 → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -87,6 +87,10 @@ describe('Journey — ChoiceCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces ChoiceCallback (step 2)', async () => { @@ -104,6 +108,10 @@ describe('Journey — ChoiceCallback', () => { await waitFor(element(by.id('journey-field-ChoiceCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-ChoiceCallback:0')), + ).toBeVisible(); }); it('select option 0 and submit → reaches SuccessNode (live)', async () => { @@ -117,5 +125,7 @@ describe('Journey — ChoiceCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-confirmation.test.ts b/PingTestRunner/e2e/journey-callback-confirmation.test.ts index 52488ef63..e4ca3b1c5 100644 --- a/PingTestRunner/e2e/journey-callback-confirmation.test.ts +++ b/PingTestRunner/e2e/journey-callback-confirmation.test.ts @@ -16,7 +16,7 @@ * 3. Tap option 0 → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -27,23 +27,18 @@ import { const TREE = 'ConfirmationCallbackTest'; const SKIP_REASON = 'Callback journey tests require callback trees and live Journey env. Set PING_CALLBACK_TREES_ENABLED to not false, plus PING_SERVER_URL, PING_TEST_USERNAME, and PING_TEST_PASSWORD.'; -const NET_TIMEOUT = 30000; +const NET_TIMEOUT = 45000; describe('Journey — ConfirmationCallback', () => { - const ensureConfirmationCallbackVisible = async (): Promise => { - try { - await waitFor(element(by.id('journey-field-ConfirmationCallback:0'))) - .toBeVisible() - .withTimeout(1500); - return; - } catch { - // Continue with start/login flow. - } - - await element(by.id('journey-start-btn')).tap(); + const ensureLoginFormVisible = async (): Promise => { + await element(by.id('journey-start-btn')).atIndex(0).tap(); await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + }; + + const ensureConfirmationCallbackVisible = async (): Promise => { + await ensureLoginFormVisible(); await element(by.id('journey-field-NameCallback:0')).typeText( E2E_ENV.testUsername, ); @@ -70,6 +65,10 @@ describe('Journey — ConfirmationCallback', () => { await device.disableSynchronization(); }); + beforeEach(async () => { + await device.reloadReactNative(); + }); + afterAll(async () => { await device.terminateApp(); }); @@ -83,10 +82,11 @@ describe('Journey — ConfirmationCallback', () => { console.warn(SKIP_REASON); return; } - await element(by.id('journey-start-btn')).tap(); - await waitFor(element(by.id('journey-field-NameCallback:0'))) - .toBeVisible() - .withTimeout(NET_TIMEOUT); + await ensureLoginFormVisible(); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces ConfirmationCallback (step 2)', async () => { @@ -94,16 +94,11 @@ describe('Journey — ConfirmationCallback', () => { console.warn(SKIP_REASON); return; } - await element(by.id('journey-field-NameCallback:0')).typeText( - E2E_ENV.testUsername, - ); - await element(by.id('journey-field-PasswordCallback:0')).typeText( - E2E_ENV.testPassword, - ); - await element(by.id('journey-submit-btn')).tap(); - await waitFor(element(by.id('journey-field-ConfirmationCallback:0'))) - .toBeVisible() - .withTimeout(NET_TIMEOUT); + await ensureConfirmationCallbackVisible(); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-ConfirmationCallback:0')), + ).toBeVisible(); }); it('select option 0 and submit → reaches SuccessNode (live)', async () => { @@ -117,5 +112,7 @@ describe('Journey — ConfirmationCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-consent-mapping.test.ts b/PingTestRunner/e2e/journey-callback-consent-mapping.test.ts index 169e137f0..0d20d36e8 100644 --- a/PingTestRunner/e2e/journey-callback-consent-mapping.test.ts +++ b/PingTestRunner/e2e/journey-callback-consent-mapping.test.ts @@ -16,7 +16,7 @@ * 3. Accept and submit → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -35,6 +35,10 @@ describe('Journey — ConsentMappingCallback', () => { await waitFor(element(by.id('journey-field-ConsentMappingCallback:0'))) .toBeVisible() .withTimeout(1500); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-ConsentMappingCallback:0')), + ).toBeVisible(); return; } catch { // Continue with start/login flow. @@ -54,6 +58,10 @@ describe('Journey — ConsentMappingCallback', () => { await waitFor(element(by.id('journey-field-ConsentMappingCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-ConsentMappingCallback:0')), + ).toBeVisible(); }; beforeAll(async () => { @@ -97,5 +105,7 @@ describe('Journey — ConsentMappingCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-device-profile.test.ts b/PingTestRunner/e2e/journey-callback-device-profile.test.ts index 6fffb1164..0932e2364 100644 --- a/PingTestRunner/e2e/journey-callback-device-profile.test.ts +++ b/PingTestRunner/e2e/journey-callback-device-profile.test.ts @@ -18,7 +18,7 @@ * 5. SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -29,23 +29,18 @@ import { const TREE = 'DeviceProfileCallbackTest'; const SKIP_REASON = 'Callback journey tests require callback trees and live Journey env. Set PING_CALLBACK_TREES_ENABLED to not false, plus PING_SERVER_URL, PING_TEST_USERNAME, and PING_TEST_PASSWORD.'; -const NET_TIMEOUT = 30000; +const NET_TIMEOUT = 45000; describe('Journey — DeviceProfileCallback', () => { - const ensureChoiceCallbackVisible = async (): Promise => { - try { - await waitFor(element(by.id('journey-field-ChoiceCallback:0'))) - .toBeVisible() - .withTimeout(1500); - return; - } catch { - // Continue with start/login flow. - } - - await element(by.id('journey-start-btn')).tap(); + const ensureLoginFormVisible = async (): Promise => { + await element(by.id('journey-start-btn')).atIndex(0).tap(); await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + }; + + const ensureChoiceCallbackVisible = async (): Promise => { + await ensureLoginFormVisible(); await element(by.id('journey-field-NameCallback:0')).typeText( E2E_ENV.testUsername, ); @@ -72,6 +67,10 @@ describe('Journey — DeviceProfileCallback', () => { await device.disableSynchronization(); }); + beforeEach(async () => { + await device.reloadReactNative(); + }); + afterAll(async () => { await device.terminateApp(); }); @@ -85,10 +84,11 @@ describe('Journey — DeviceProfileCallback', () => { console.warn(SKIP_REASON); return; } - await element(by.id('journey-start-btn')).tap(); - await waitFor(element(by.id('journey-field-NameCallback:0'))) - .toBeVisible() - .withTimeout(NET_TIMEOUT); + await ensureLoginFormVisible(); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces ChoiceCallback (step 2)', async () => { @@ -96,16 +96,11 @@ describe('Journey — DeviceProfileCallback', () => { console.warn(SKIP_REASON); return; } - await element(by.id('journey-field-NameCallback:0')).typeText( - E2E_ENV.testUsername, - ); - await element(by.id('journey-field-PasswordCallback:0')).typeText( - E2E_ENV.testPassword, - ); - await element(by.id('journey-submit-btn')).tap(); - await waitFor(element(by.id('journey-field-ChoiceCallback:0'))) - .toBeVisible() - .withTimeout(NET_TIMEOUT); + await ensureChoiceCallbackVisible(); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-ChoiceCallback:0')), + ).toBeVisible(); }); it('select No → auto-collects device profile and reaches SuccessNode (live)', async () => { @@ -124,5 +119,7 @@ describe('Journey — DeviceProfileCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-hidden-value.test.ts b/PingTestRunner/e2e/journey-callback-hidden-value.test.ts index be2e21b2c..a0111796c 100644 --- a/PingTestRunner/e2e/journey-callback-hidden-value.test.ts +++ b/PingTestRunner/e2e/journey-callback-hidden-value.test.ts @@ -17,7 +17,7 @@ * 4. Submit (no user input needed) → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -101,6 +101,10 @@ describe('Journey — HiddenValueCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces HiddenValueCallback field (step 2)', async () => { @@ -119,6 +123,8 @@ describe('Journey — HiddenValueCallback', () => { await waitFor(element(by.id('journey-submit-btn'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-submit-btn'))).toBeVisible(); }); it('submit (no user input) → reaches SuccessNode (live)', async () => { @@ -131,5 +137,7 @@ describe('Journey — HiddenValueCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-kba.test.ts b/PingTestRunner/e2e/journey-callback-kba.test.ts index 30c3b275d..02b6cb14f 100644 --- a/PingTestRunner/e2e/journey-callback-kba.test.ts +++ b/PingTestRunner/e2e/journey-callback-kba.test.ts @@ -20,7 +20,7 @@ * PasswordCallback because ValidatedCreate* extends NameCallback/PasswordCallback in the SDK. */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -93,6 +93,10 @@ describe('Journey — KbaCreateCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces KbaCreateCallback fields (step 2)', async () => { @@ -113,6 +117,10 @@ describe('Journey — KbaCreateCallback', () => { await waitFor(element(by.id('journey-field-KbaCreateCallback:0-answer'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-KbaCreateCallback:0-answer')), + ).toBeVisible(); }); it('fill in both KBA questions and answers and submit → reaches SuccessNode (live)', async () => { @@ -137,5 +145,7 @@ describe('Journey — KbaCreateCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-metadata.test.ts b/PingTestRunner/e2e/journey-callback-metadata.test.ts index 510b4fef8..f8327448c 100644 --- a/PingTestRunner/e2e/journey-callback-metadata.test.ts +++ b/PingTestRunner/e2e/journey-callback-metadata.test.ts @@ -16,7 +16,7 @@ * 3. Select Yes and submit → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -87,6 +87,10 @@ describe('Journey — MetadataCallback', () => { await waitFor(element(by.id('journey-field-output-MetadataCallback:0'))) .toExist() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-output-MetadataCallback:0')), + ).toExist(); }); it('select Yes and submit → reaches SuccessNode (live)', async () => { @@ -100,5 +104,7 @@ describe('Journey — MetadataCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-name-password.test.ts b/PingTestRunner/e2e/journey-callback-name-password.test.ts index e12b59d58..0d8b1f555 100644 --- a/PingTestRunner/e2e/journey-callback-name-password.test.ts +++ b/PingTestRunner/e2e/journey-callback-name-password.test.ts @@ -16,7 +16,7 @@ * 3. Submit password → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -84,6 +84,10 @@ describe('Journey — NamePasswordCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit username → surfaces PasswordCallback', async () => { @@ -98,6 +102,10 @@ describe('Journey — NamePasswordCallback', () => { await waitFor(element(by.id('journey-field-PasswordCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-PasswordCallback:0')), + ).toBeVisible(); }); it('submit password → reaches SuccessNode (live)', async () => { @@ -113,5 +121,7 @@ describe('Journey — NamePasswordCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-number-attr.test.ts b/PingTestRunner/e2e/journey-callback-number-attr.test.ts index 648d12d3f..35a67ad05 100644 --- a/PingTestRunner/e2e/journey-callback-number-attr.test.ts +++ b/PingTestRunner/e2e/journey-callback-number-attr.test.ts @@ -17,7 +17,7 @@ * 4. SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -92,6 +92,10 @@ describe('Journey — NumberAttributeInputCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces NumberAttributeInputCallback field (step 2)', async () => { @@ -111,6 +115,10 @@ describe('Journey — NumberAttributeInputCallback', () => { ) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NumberAttributeInputCallback:0')), + ).toBeVisible(); }); it('enter a number and submit → reaches SuccessNode (live)', async () => { @@ -126,5 +134,7 @@ describe('Journey — NumberAttributeInputCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-polling-wait.test.ts b/PingTestRunner/e2e/journey-callback-polling-wait.test.ts index c7eb96d50..d34c8e3fa 100644 --- a/PingTestRunner/e2e/journey-callback-polling-wait.test.ts +++ b/PingTestRunner/e2e/journey-callback-polling-wait.test.ts @@ -20,7 +20,7 @@ * test block to minimise elapsed time between receiving the PollingWait node and submitting Exit. */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -65,6 +65,10 @@ describe('Journey — PollingWaitCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('login → PollingWait appears → tap Exit immediately → reaches SuccessNode (live)', async () => { @@ -90,5 +94,7 @@ describe('Journey — PollingWaitCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-string-attr.test.ts b/PingTestRunner/e2e/journey-callback-string-attr.test.ts index 35bc6ff1b..a5922fc62 100644 --- a/PingTestRunner/e2e/journey-callback-string-attr.test.ts +++ b/PingTestRunner/e2e/journey-callback-string-attr.test.ts @@ -18,7 +18,7 @@ * 3. Fill all and submit → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -93,6 +93,10 @@ describe('Journey — StringAttributeInputCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces StringAttributeInputCallback fields (step 2)', async () => { @@ -112,6 +116,10 @@ describe('Journey — StringAttributeInputCallback', () => { ) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-StringAttributeInputCallback:0')), + ).toBeVisible(); }); it('fill all string attributes and submit → reaches SuccessNode (live)', async () => { @@ -133,5 +141,7 @@ describe('Journey — StringAttributeInputCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-suspended-text.test.ts b/PingTestRunner/e2e/journey-callback-suspended-text.test.ts index c61e2075c..f0d08279b 100644 --- a/PingTestRunner/e2e/journey-callback-suspended-text.test.ts +++ b/PingTestRunner/e2e/journey-callback-suspended-text.test.ts @@ -16,7 +16,7 @@ * 3. Flow pauses for resume → no submit in this tree */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -73,5 +73,9 @@ describe('Journey — SuspendedTextOutputCallback', () => { ) .toExist() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-output-SuspendedTextOutputCallback:0')), + ).toExist(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-terms.test.ts b/PingTestRunner/e2e/journey-callback-terms.test.ts index d7f9c8163..b07ae4c04 100644 --- a/PingTestRunner/e2e/journey-callback-terms.test.ts +++ b/PingTestRunner/e2e/journey-callback-terms.test.ts @@ -17,7 +17,7 @@ * 4. SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -90,6 +90,10 @@ describe('Journey — TermsAndConditionsCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → surfaces TermsAndConditionsCallback toggle (step 2)', async () => { @@ -107,6 +111,10 @@ describe('Journey — TermsAndConditionsCallback', () => { await waitFor(element(by.id('journey-field-TermsAndConditionsCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-TermsAndConditionsCallback:0')), + ).toBeVisible(); }); it('accept terms and submit → reaches SuccessNode (live)', async () => { @@ -120,5 +128,7 @@ describe('Journey — TermsAndConditionsCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-text-input.test.ts b/PingTestRunner/e2e/journey-callback-text-input.test.ts index dba2df034..9015b4783 100644 --- a/PingTestRunner/e2e/journey-callback-text-input.test.ts +++ b/PingTestRunner/e2e/journey-callback-text-input.test.ts @@ -16,7 +16,7 @@ * 3. MessageNode "Success" → ConfirmationCallback:0 (tap True) → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -84,6 +84,10 @@ describe('Journey — TextInputCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit username → surfaces TextInputCallback field (step 2)', async () => { @@ -98,6 +102,10 @@ describe('Journey — TextInputCallback', () => { await waitFor(element(by.id('journey-field-TextInputCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-TextInputCallback:0')), + ).toBeVisible(); }); it('submit matching text → ConfirmationCallback then SuccessNode (live)', async () => { @@ -119,5 +127,7 @@ describe('Journey — TextInputCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-text-output.test.ts b/PingTestRunner/e2e/journey-callback-text-output.test.ts index 727a4c7c3..f840df1aa 100644 --- a/PingTestRunner/e2e/journey-callback-text-output.test.ts +++ b/PingTestRunner/e2e/journey-callback-text-output.test.ts @@ -19,7 +19,7 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { expect as jestExpect } from '@jest/globals'; import { assertAppReady, @@ -95,6 +95,10 @@ describe('Journey — TextOutputCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit username → surfaces PasswordCallback field (step 2 — separate node)', async () => { @@ -109,6 +113,10 @@ describe('Journey — TextOutputCallback', () => { await waitFor(element(by.id('journey-field-PasswordCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-PasswordCallback:0')), + ).toBeVisible(); }); it('submit password → surfaces TextOutputCallback display text (step 3)', async () => { @@ -123,6 +131,10 @@ describe('Journey — TextOutputCallback', () => { await waitFor(element(by.id('journey-field-output-TextOutputCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-output-TextOutputCallback:0')), + ).toBeVisible(); const attrs = await element( by.id('journey-field-output-TextOutputCallback:0'), ).getAttributes(); @@ -140,5 +152,7 @@ describe('Journey — TextOutputCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-validated-password.test.ts b/PingTestRunner/e2e/journey-callback-validated-password.test.ts index 583dc9d4d..4bc913d52 100644 --- a/PingTestRunner/e2e/journey-callback-validated-password.test.ts +++ b/PingTestRunner/e2e/journey-callback-validated-password.test.ts @@ -16,7 +16,7 @@ * 3. DataStoreDecision → (true) → SuccessNode */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -88,6 +88,10 @@ describe('Journey — ValidatedCreatePasswordCallback', () => { ) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-ValidatedCreatePasswordCallback:0')), + ).toBeVisible(); }); it('submit new password → surfaces login form (step 2)', async () => { @@ -102,6 +106,10 @@ describe('Journey — ValidatedCreatePasswordCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → reaches SuccessNode (live)', async () => { @@ -120,5 +128,7 @@ describe('Journey — ValidatedCreatePasswordCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-callback-validated-username.test.ts b/PingTestRunner/e2e/journey-callback-validated-username.test.ts index d75bbc3dd..33b103792 100644 --- a/PingTestRunner/e2e/journey-callback-validated-username.test.ts +++ b/PingTestRunner/e2e/journey-callback-validated-username.test.ts @@ -19,7 +19,7 @@ * Using testUsername would fail the VALID_USERNAME policy and loop back to step 1. */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasCallbackTreesEnabled, @@ -30,25 +30,21 @@ import { const TREE = 'ValidatedUsernameCallbackTest'; const SKIP_REASON = 'Callback journey tests require callback trees and live Journey env. Set PING_CALLBACK_TREES_ENABLED to not false, plus PING_SERVER_URL, PING_TEST_USERNAME, and PING_TEST_PASSWORD.'; -const NET_TIMEOUT = 30000; +const NET_TIMEOUT = 45000; describe('Journey — ValidatedCreateUsernameCallback', () => { - const ensureLoginFormVisible = async (): Promise => { - try { - await waitFor(element(by.id('journey-field-NameCallback:0'))) - .toBeVisible() - .withTimeout(1500); - return; - } catch { - // Continue with start/validated-username flow. - } - - await element(by.id('journey-start-btn')).tap(); + const ensureUsernameStepVisible = async (): Promise => { + await element(by.id('journey-start-btn')).atIndex(0).tap(); await waitFor( element(by.id('journey-field-ValidatedCreateUsernameCallback:0')), ) .toBeVisible() .withTimeout(NET_TIMEOUT); + }; + + const ensureLoginFormVisible = async (): Promise => { + await ensureUsernameStepVisible(); + // Must be unique — reusing testUsername fails the VALID_USERNAME policy const uniqueUsername = `e2enew${Date.now()}`; await element( by.id('journey-field-ValidatedCreateUsernameCallback:0'), @@ -73,6 +69,10 @@ describe('Journey — ValidatedCreateUsernameCallback', () => { await device.disableSynchronization(); }); + beforeEach(async () => { + await device.reloadReactNative(); + }); + afterAll(async () => { await device.terminateApp(); }); @@ -86,12 +86,11 @@ describe('Journey — ValidatedCreateUsernameCallback', () => { console.warn(SKIP_REASON); return; } - await element(by.id('journey-start-btn')).tap(); - await waitFor( + await ensureUsernameStepVisible(); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( element(by.id('journey-field-ValidatedCreateUsernameCallback:0')), - ) - .toBeVisible() - .withTimeout(NET_TIMEOUT); + ).toBeVisible(); }); it('submit unique new username → surfaces login form (step 2)', async () => { @@ -99,6 +98,7 @@ describe('Journey — ValidatedCreateUsernameCallback', () => { console.warn(SKIP_REASON); return; } + await ensureUsernameStepVisible(); // Must be unique — reusing testUsername fails the VALID_USERNAME policy const uniqueUsername = `e2enew${Date.now()}`; await element( @@ -108,6 +108,10 @@ describe('Journey — ValidatedCreateUsernameCallback', () => { await waitFor(element(by.id('journey-field-NameCallback:0'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect( + element(by.id('journey-field-NameCallback:0')), + ).toBeVisible(); }); it('submit credentials → reaches SuccessNode (live)', async () => { @@ -126,5 +130,7 @@ describe('Journey — ValidatedCreateUsernameCallback', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-failure-path.test.ts b/PingTestRunner/e2e/journey-failure-path.test.ts index 2024f8d65..bcaec4932 100644 --- a/PingTestRunner/e2e/journey-failure-path.test.ts +++ b/PingTestRunner/e2e/journey-failure-path.test.ts @@ -9,7 +9,7 @@ * E2E — Journey invalid-credential and failure handling paths. */ -import { device, element, by, waitFor } from 'detox'; +import { device, element, by, waitFor, expect as detoxExpect } from 'detox'; import { assertAppReady, hasJourneyEnv, E2E_ENV } from './setup'; const NET_TIMEOUT = 30000; // ms to wait for network-dependent elements @@ -68,22 +68,25 @@ describe('Journey — invalid-credential handling', () => { by.id('journey-failure-message'), ]; - let matched = false; + let matchedMatcher: (typeof messageMatchers)[number] | undefined; for (const matcher of messageMatchers) { try { await waitFor(element(matcher)).toBeVisible().withTimeout(5000); - matched = true; + matchedMatcher = matcher; break; } catch { // Try the next acceptable message variant. } } - if (!matched) { + if (!matchedMatcher) { throw new Error( 'Expected a failure message for invalid credentials, but none matched.', ); } + + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(matchedMatcher)).toBeVisible(); }); it('revoke() and re-login succeeds after session expiry (live)', async () => { @@ -130,5 +133,7 @@ describe('Journey — invalid-credential handling', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/journey-happy-path.test.ts b/PingTestRunner/e2e/journey-happy-path.test.ts index e1523475c..b961dc87f 100644 --- a/PingTestRunner/e2e/journey-happy-path.test.ts +++ b/PingTestRunner/e2e/journey-happy-path.test.ts @@ -102,6 +102,8 @@ describe('Journey — happy path', () => { await waitFor(element(PASSWORD_INPUT)) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(PASSWORD_INPUT)).toBeVisible(); }); it('next() with valid credentials returns SuccessNode (live)', async () => { @@ -116,6 +118,8 @@ describe('Journey — happy path', () => { await waitFor(element(by.id('journey-success'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-success'))).toBeVisible(); }); it('access token is available and non-empty after successful login (live)', async () => { @@ -127,6 +131,8 @@ describe('Journey — happy path', () => { await waitFor(element(by.id('journey-token-result'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-token-result'))).toBeVisible(); const attrs = await element(by.id('journey-token-result')).getAttributes(); const token = (attrs as any).text ?? (attrs as any).label ?? ''; jestExpect(token.length).toBeGreaterThan(0); @@ -144,6 +150,8 @@ describe('Journey — happy path', () => { await waitFor(element(by.id('journey-userinfo-result'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-userinfo-result'))).toBeVisible(); const attrs = await element( by.id('journey-userinfo-result'), ).getAttributes(); @@ -161,6 +169,8 @@ describe('Journey — happy path', () => { await waitFor(element(by.id('journey-refreshed'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-refreshed'))).toBeVisible(); }); it('revoke() invalidates the session (live)', async () => { @@ -173,6 +183,8 @@ describe('Journey — happy path', () => { await waitFor(element(by.id('journey-revoked'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-revoked'))).toBeVisible(); }); it('logoutUser() clears the session (live)', async () => { @@ -185,6 +197,8 @@ describe('Journey — happy path', () => { await waitFor(element(by.id('journey-logged-out'))) .toBeVisible() .withTimeout(NET_TIMEOUT); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await detoxExpect(element(by.id('journey-logged-out'))).toBeVisible(); }); }); diff --git a/PingTestRunner/e2e/logger.test.ts b/PingTestRunner/e2e/logger.test.ts index 19b7fccc4..e383d5864 100644 --- a/PingTestRunner/e2e/logger.test.ts +++ b/PingTestRunner/e2e/logger.test.ts @@ -14,7 +14,7 @@ * testNoneLogger → level 'none' logger creates without throwing */ -import { device, element, by, expect as detoxExpect } from 'detox'; +import { device, element, by, expect as detoxExpect, waitFor } from 'detox'; import { assertAppReady } from './setup'; describe('Logger — bridge verification', () => { @@ -35,21 +35,33 @@ describe('Logger — bridge verification', () => { it('logger({ level: debug }) creates without throwing', async () => { await element(by.id('logger-create-btn')).tap(); + await waitFor(element(by.id('logger-ready'))) + .toBeVisible() + .withTimeout(10000); await detoxExpect(element(by.id('logger-ready'))).toBeVisible(); }); it('debug(), info(), warn(), error() all callable without throwing', async () => { await element(by.id('logger-log-btn')).tap(); + await waitFor(element(by.id('logger-logged'))) + .toBeVisible() + .withTimeout(10000); await detoxExpect(element(by.id('logger-logged'))).toBeVisible(); }); it('changeLevel(warn) completes without throwing', async () => { await element(by.id('logger-change-level-btn')).tap(); + await waitFor(element(by.id('logger-level-changed'))) + .toBeVisible() + .withTimeout(10000); await detoxExpect(element(by.id('logger-level-changed'))).toBeVisible(); }); it('logger({ level: none }) creates without throwing', async () => { await element(by.id('logger-none-btn')).tap(); + await waitFor(element(by.id('logger-none-ready'))) + .toBeVisible() + .withTimeout(10000); await detoxExpect(element(by.id('logger-none-ready'))).toBeVisible(); }); diff --git a/PingTestRunner/e2e/storage.test.ts b/PingTestRunner/e2e/storage.test.ts index 69c9553ac..e84265ef9 100644 --- a/PingTestRunner/e2e/storage.test.ts +++ b/PingTestRunner/e2e/storage.test.ts @@ -36,6 +36,10 @@ describe('Storage — bridge verification', () => { it('configureSessionStorage returns a handle with kind=session', async () => { await element(by.id('storage-session-btn')).tap(); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await waitFor(element(by.id('storage-session-result'))) + .toBeVisible() + .withTimeout(10000); await detoxExpect(element(by.id('storage-session-result'))).toBeVisible(); const attrs = await element( by.id('storage-session-result'), @@ -46,6 +50,10 @@ describe('Storage — bridge verification', () => { it('configureOidcStorage returns a handle with kind=oidc', async () => { await element(by.id('storage-oidc-btn')).tap(); + // Re-asserted via detoxExpect: BrowserStack derives the test verdict from explicit expect calls, not waitFor polling. + await waitFor(element(by.id('storage-oidc-result'))) + .toBeVisible() + .withTimeout(10000); await detoxExpect(element(by.id('storage-oidc-result'))).toBeVisible(); const attrs = await element(by.id('storage-oidc-result')).getAttributes(); const text = (attrs as any).text ?? (attrs as any).label ?? ''; diff --git a/PingTestRunner/e2e/use-davinci.test.ts b/PingTestRunner/e2e/use-davinci.test.ts new file mode 100644 index 000000000..6df646fba --- /dev/null +++ b/PingTestRunner/e2e/use-davinci.test.ts @@ -0,0 +1,198 @@ +/* + * 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. + */ + +/** + * E2E — useDaVinci + useDaVinciForm hooks (Tier 2 — server required) + * + * Verifies that the hook API produces correct state transitions against real + * native bridges on device: loading state, node updates, form field rendering + * via useDaVinciForm, and post-login actions (token, refresh, revoke, logout). + */ + +import { device, element, by, waitFor } from 'detox'; +import { assertAppReady, DAVINCI_ENV } from './setup'; + +const SKIP_REASON = + 'useDaVinci hook tests require a live DaVinci env. Set PINGONE_DISCOVERY_ENDPOINT, ' + + 'PINGONE_CLIENT_ID, PINGONE_USERNAME, and PINGONE_PASSWORD.'; + +const DAVINCI_USERNAME_KEY = + process.env['PING_DAVINCI_USERNAME_KEY'] ?? 'username'; +const DAVINCI_PASSWORD_KEY = + process.env['PING_DAVINCI_PASSWORD_KEY'] ?? 'password'; + +const USERNAME_INPUT = by.id(`use-davinci-field-${DAVINCI_USERNAME_KEY}`); +const PASSWORD_INPUT = by.id(`use-davinci-field-${DAVINCI_PASSWORD_KEY}`); +// The login screen carries several submit buttons (Sign On / Register / +// Trouble); target Sign On by its rendered label. Falls back to the shared +// testID for single-button flows. +const SIGNON_BUTTON = by.text('Sign On'); +const SUBMIT_FALLBACK = by.id('use-davinci-submit-btn'); + +async function tapSubmitButton(): Promise { + try { + await element(SIGNON_BUTTON).tap(); + } catch { + await element(SUBMIT_FALLBACK).tap(); + } +} + +function hasDaVinciEnv(): boolean { + return !!( + DAVINCI_ENV.discoveryEndpoint && + DAVINCI_ENV.clientId && + DAVINCI_ENV.testUsername && + DAVINCI_ENV.testPassword + ); +} + +const DAVINCI_LAUNCH_ARGS = { + PING_TEST_SCENARIO: 'use-davinci', + PINGONE_DISCOVERY_ENDPOINT: DAVINCI_ENV.discoveryEndpoint, + PINGONE_CLIENT_ID: DAVINCI_ENV.clientId, + PINGONE_REDIRECT_URI: DAVINCI_ENV.redirectUri, + // Clear a persisted SSO session at mount so start() reaches the login form + // instead of returning SuccessNode from a session left by a previous run. + PING_CLEAR_STORAGE: 'true', + ...(DAVINCI_ENV.acrValues + ? { PINGONE_ACR_VALUES: DAVINCI_ENV.acrValues } + : {}), +}; + +const NET_TIMEOUT = 30000; + +describe('useDaVinci + useDaVinciForm — hook state transitions', () => { + beforeAll(async () => { + await device.launchApp({ + newInstance: true, + launchArgs: DAVINCI_LAUNCH_ARGS, + }); + await device.disableSynchronization(); + }); + + afterAll(async () => { + await device.terminateApp(); + }); + + it('app launches', async () => { + await assertAppReady(); + }); + + it('start() surfaces login form via useDaVinciForm fields', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + await element(by.id('use-davinci-start-btn')).tap(); + await waitFor(element(USERNAME_INPUT)) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + await waitFor(element(PASSWORD_INPUT)).toBeVisible().withTimeout(5000); + }); + + it('next() with valid credentials reaches SuccessNode', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + await element(USERNAME_INPUT).typeText(DAVINCI_ENV.testUsername); + await element(PASSWORD_INPUT).typeText(DAVINCI_ENV.testPassword); + await tapSubmitButton(); + await waitFor(element(by.id('use-davinci-success'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('token is available after success', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + await waitFor(element(by.id('use-davinci-token-result'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('userinfo() returns a payload via hook actions', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + await element(by.id('use-davinci-userinfo-btn')).tap(); + await waitFor(element(by.id('use-davinci-userinfo-result'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('refresh() updates token via hook actions', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + await element(by.id('use-davinci-refresh-btn')).tap(); + await waitFor(element(by.id('use-davinci-refreshed'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('revoke() completes via hook actions', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + await element(by.id('use-davinci-revoke-btn')).tap(); + await waitFor(element(by.id('use-davinci-revoked'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('logoutUser() completes via hook actions', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + await element(by.id('use-davinci-logout-btn')).tap(); + await waitFor(element(by.id('use-davinci-logged-out'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); +}); + +describe('useDaVinci — ErrorNode on wrong credentials', () => { + beforeAll(async () => { + await device.launchApp({ + newInstance: true, + launchArgs: DAVINCI_LAUNCH_ARGS, + }); + await device.disableSynchronization(); + }); + + afterAll(async () => { + await device.terminateApp(); + }); + + it('app launches', async () => { + await assertAppReady(); + }); + + it('next() with wrong password reaches ErrorNode', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + await element(by.id('use-davinci-start-btn')).tap(); + await waitFor(element(USERNAME_INPUT)) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + await element(USERNAME_INPUT).typeText(DAVINCI_ENV.testUsername); + await element(PASSWORD_INPUT).typeText('wrong_password'); + await tapSubmitButton(); + await waitFor(element(by.id('use-davinci-error-node'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); +}); diff --git a/PingTestRunner/ios/PingTestRunner/AppDelegate.swift b/PingTestRunner/ios/PingTestRunner/AppDelegate.swift index 96dd3fea3..d9abea883 100644 --- a/PingTestRunner/ios/PingTestRunner/AppDelegate.swift +++ b/PingTestRunner/ios/PingTestRunner/AppDelegate.swift @@ -9,6 +9,7 @@ import UIKit import React import React_RCTAppDelegate import ReactAppDependencyProvider +import WebKit @main /// UIApplication delegate that bootstraps the React Native sample app. @@ -30,16 +31,68 @@ class AppDelegate: UIResponder, UIApplicationDelegate { reactNativeDelegate = delegate reactNativeFactory = factory - window = UIWindow(frame: UIScreen.main.bounds) + let window = UIWindow(frame: UIScreen.main.bounds) + self.window = window - factory.startReactNative( - withModuleName: "PingTestRunner", - in: window, - launchOptions: launchOptions - ) + // With -PING_CLEAR_STORAGE YES, wipe browser/system cookie stores and the + // app keychain before RN starts. The SDK persists ST/SSO cookies and OIDC + // tokens in keychain-backed storage (PingOrchestrate CookieModule / + // PingOidc config storage); those cookies ride along on every authorize + // request and let the server resume a previous SSO interaction, returning + // SuccessNode instead of the login form. The sign-off path only clears its + // own entries, and uninstalling does not reliably purge keychain items on + // simulators, so test harnesses pass this arg to guarantee each launch + // starts without a live auth session. RN boot is deferred until the wipe + // completes: WKWebsiteDataStore removal is async, and booting first would + // let start() fire while a stale SSO record still exists. + if ProcessInfo.processInfo.arguments.contains("-PING_CLEAR_STORAGE") { + Self.clearAuthStorage { + DispatchQueue.main.async { + factory.startReactNative( + withModuleName: "PingTestRunner", + in: window, + launchOptions: launchOptions + ) + } + } + } else { + factory.startReactNative( + withModuleName: "PingTestRunner", + in: window, + launchOptions: launchOptions + ) + } return true } + + /// Wipes cookie stores and keychain items, invoking `completion` after every + /// store is cleared. HTTPCookieStorage and keychain deletion are synchronous; + /// WKWebsiteDataStore removal is asynchronous and gates the completion. + private static func clearAuthStorage(completion: @escaping () -> Void) { + HTTPCookieStorage.shared.removeCookies(since: Date.distantPast) + for itemClass in [ + kSecClassGenericPassword, + kSecClassInternetPassword, + kSecClassCertificate, + kSecClassKey, + ] { + SecItemDelete([kSecClass: itemClass] as CFDictionary) + } + WKWebsiteDataStore.default().fetchDataRecords( + ofTypes: WKWebsiteDataStore.allWebsiteDataTypes() + ) { records in + guard !records.isEmpty else { + completion() + return + } + WKWebsiteDataStore.default().removeData( + ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), + for: records, + completionHandler: completion + ) + } + } } /// React Native factory delegate that resolves JS bundle locations. diff --git a/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift b/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift index 008be3963..e981d5975 100644 --- a/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift +++ b/PingTestRunner/ios/PingTestRunnerUITests/BaseTestCase.swift @@ -96,6 +96,14 @@ class BaseTestCase: XCTestCase { app.descendants(matching: .any).matching(identifier: testID).firstMatch } + /// Returns the first button with the given rendered label, mirroring the Detox + /// suite's `element(by.text(label))` selector. A DaVinci login screen can render + /// several SUBMIT_BUTTON collectors (Sign On / Register / Trouble signing on) + /// sharing the same testID, so the label is the only reliable way to target one. + func buttonWithLabel(_ label: String) -> XCUIElement { + app.buttons.matching(NSPredicate(format: "label == %@", label)).firstMatch + } + /// Waits for the element with the given testID to exist and fails the test if it doesn't. @discardableResult func waitForElementWithTestID( diff --git a/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift index d246021bb..99d355df2 100644 --- a/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift +++ b/PingTestRunner/ios/PingTestRunnerUITests/DaVinciUITests.swift @@ -16,18 +16,23 @@ final class DaVinciUITests: BaseTestCase { override func setUp() { super.setUp() - var extras: [String: String] = [:] + var extras: [String: String] = [ + // Storage survives app relaunches on the simulator; without this a + // session from a previous test makes start() return SuccessNode + // immediately (server sends authorizeResponse), skipping the form. + "PING_CLEAR_STORAGE": "true", + ] if !env.daVinciDiscoveryEndpoint.isEmpty { - extras["PING_DISCOVERY_ENDPOINT"] = env.daVinciDiscoveryEndpoint + extras["PINGONE_DISCOVERY_ENDPOINT"] = env.daVinciDiscoveryEndpoint } if !env.daVinciClientId.isEmpty { - extras["PING_CLIENT_ID"] = env.daVinciClientId + extras["PINGONE_CLIENT_ID"] = env.daVinciClientId } if !env.daVinciRedirectUri.isEmpty { - extras["PING_REDIRECT_URI"] = env.daVinciRedirectUri + extras["PINGONE_REDIRECT_URI"] = env.daVinciRedirectUri } if !env.daVinciAcrValues.isEmpty { - extras["PING_ACR_VALUES"] = env.daVinciAcrValues + extras["PINGONE_ACR_VALUES"] = env.daVinciAcrValues } launchApp(scenario: "davinci", extras: extras) } @@ -62,7 +67,7 @@ final class DaVinciUITests: BaseTestCase { waitForElementWithTestID("davinci-field-username", timeout: netTimeout) elementWithTestID("davinci-field-username").typeTextWhenReady(env.daVinciUsername) elementWithTestID("davinci-field-password").typeTextWhenReady(env.daVinciPassword) - elementWithTestID("davinci-submit-btn").tapWhenReady() + buttonWithLabel("Sign On").tapWhenReady() XCTAssertTrue( elementWithTestID("davinci-success").waitForExistence(timeout: netTimeout), "Expected davinci-success after valid credentials" @@ -118,12 +123,18 @@ final class DaVinciUITests: BaseTestCase { // MARK: - Helpers + /// Delay before each login so back-to-back tests do not authenticate the + /// shared test account in rapid succession; consecutive logins spaced only + /// seconds apart have hung the flow with no node rendered. + private static let loginSpacing: TimeInterval = 10 + private func loginWithValidCredentials() { + Thread.sleep(forTimeInterval: Self.loginSpacing) elementWithTestID("davinci-start-btn").tapWhenReady() waitForElementWithTestID("davinci-field-username", timeout: netTimeout) elementWithTestID("davinci-field-username").typeTextWhenReady(env.daVinciUsername) elementWithTestID("davinci-field-password").typeTextWhenReady(env.daVinciPassword) - elementWithTestID("davinci-submit-btn").tapWhenReady() + buttonWithLabel("Sign On").tapWhenReady() waitForElementWithTestID("davinci-success", timeout: netTimeout) } } diff --git a/PingTestRunner/ios/PingTestRunnerUITests/EnvFileLoader.swift b/PingTestRunner/ios/PingTestRunnerUITests/EnvFileLoader.swift new file mode 100644 index 000000000..3bc8d1101 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/EnvFileLoader.swift @@ -0,0 +1,103 @@ +/* + * 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 + +/// Minimal `.env` loader for the XCUITest runner process. +/// +/// The runner runs on the macOS host, so it can read the PingTestRunner `.env` +/// file directly from the repository. This mirrors how the Jest/Detox side gets +/// its configuration (Detox injects `.env` values into the app via launchArgs), +/// so local XCUITest runs behave the same without exporting every variable. +/// +/// Lookup order (first existing file wins): +/// 1. `PING_TEST_ENV_FILE` from the process environment (explicit override) +/// 2. `.env` in the PingTestRunner root, derived from this source file's +/// compile-time path (`/PingTestRunner/ios/PingTestRunnerUITests/EnvFileLoader.swift`) +enum EnvFileLoader { + + // MARK: - Loading + + /// Parses the first available `.env` file into a flat key-value dictionary. + /// + /// - Parameters: + /// - fileName: Name of the env file to locate. Defaults to `.env`. + /// - sourceFile: Compile-time path of the caller's source file, used to + /// derive the PingTestRunner root. Callers should leave the default. + /// - Returns: Parsed variables; an empty dictionary when no file exists. + static func load( + fileName: String = ".env", + sourceFile: String = #filePath + ) -> [String: String] { + for candidate in candidateURLs(fileName: fileName, sourceFile: sourceFile) { + if let values = parse(contentsOf: candidate) { + return values + } + } + return [:] + } + + // MARK: - Location + + /// Builds the ordered list of `.env` locations to probe. + private static func candidateURLs( + fileName: String, + sourceFile: String + ) -> [URL] { + var candidates: [URL] = [] + + if let override = ProcessInfo.processInfo.environment["PING_TEST_ENV_FILE"], + !override.isEmpty { + candidates.append(URL(fileURLWithPath: override)) + } + + // EnvFileLoader.swift -> PingTestRunnerUITests -> ios -> PingTestRunner + let runnerRoot = URL(fileURLWithPath: sourceFile) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + candidates.append(runnerRoot.appendingPathComponent(fileName)) + + return candidates + } + + // MARK: - Parsing + + /// Parses env-file contents into key-value pairs. + /// + /// Skips blank lines and `#` comments; splits each line on the first `=`; + /// trims whitespace and strips one layer of surrounding single or double + /// quotes from values. + /// + /// - Parameters: + /// - fileURL: URL of the env file to read. + /// - Returns: Parsed key-value pairs, or `nil` when the file cannot be read. + private static func parse(contentsOf fileURL: URL) -> [String: String]? { + guard let contents = try? String(contentsOf: fileURL, encoding: .utf8) else { + return nil + } + var values: [String: String] = [:] + for rawLine in contents.split(separator: "\n", omittingEmptySubsequences: true) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty, !line.hasPrefix("#"), + let separator = line.firstIndex(of: "=") else { + continue + } + let key = String(line[line.startIndex..= 2, + let first = value.first, let last = value.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") { + value = String(value.dropFirst().dropLast()) + } + if !key.isEmpty { + values[key] = value + } + } + return values + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift b/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift index 7500f2219..be12949ab 100644 --- a/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift +++ b/PingTestRunner/ios/PingTestRunnerUITests/TestEnvironment.swift @@ -7,11 +7,13 @@ import Foundation -/// XCUITest environment variables for CI. -/// +/// XCUITest environment variables for CI and local runs. +/// /// 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. +/// ProcessInfo.processInfo.environment for the XCUITest runner process. For local +/// runs the runner reads the PingTestRunner `.env` file directly (see `EnvFileLoader`); +/// process environment values take precedence over `.env` entries. struct TestEnvironment { static let shared = TestEnvironment() @@ -35,25 +37,34 @@ struct TestEnvironment { let daVinciPassword: String let daVinciAcrValues: String + private static let fileValues: [String: String] = EnvFileLoader.load() + 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" + serverUrl = Self.readValue("PING_SERVER_URL") ?? "" + realmPath = Self.readValue("PING_REALM_PATH") ?? "alpha" + cookieName = Self.readValue("PING_COOKIE_NAME") ?? "iPlanetDirectoryPro" + journeyName = Self.readValue("PING_JOURNEY_NAME") ?? "Login" + testUsername = Self.readValue("PING_TEST_USERNAME") ?? "" + testPassword = Self.readValue("PING_TEST_PASSWORD") ?? "" + discoveryEndpoint = Self.readValue("PING_DISCOVERY_ENDPOINT") ?? "" + clientId = Self.readValue("PING_CLIENT_ID") ?? "" + redirectUri = Self.readValue("PING_REDIRECT_URI") ?? "org.forgerock.demo://oauth2redirect" + callbackTreesEnabled = Self.readValue("PING_CALLBACK_TREES_ENABLED") != "false" + + daVinciDiscoveryEndpoint = Self.readValue("PINGONE_DISCOVERY_ENDPOINT") ?? "" + daVinciClientId = Self.readValue("PINGONE_CLIENT_ID") ?? "" + daVinciRedirectUri = Self.readValue("PINGONE_REDIRECT_URI") ?? "org.forgerock.demo://oauth2redirect" + daVinciUsername = Self.readValue("PINGONE_USERNAME") ?? "" + daVinciPassword = Self.readValue("PINGONE_PASSWORD") ?? "" + daVinciAcrValues = Self.readValue("PINGONE_ACR_VALUES") ?? "" + } - daVinciDiscoveryEndpoint = e["PINGONE_DISCOVERY_ENDPOINT"] ?? "" - daVinciClientId = e["PINGONE_CLIENT_ID"] ?? "" - daVinciRedirectUri = e["PINGONE_REDIRECT_URI"] ?? "org.forgerock.demo://oauth2redirect" - daVinciUsername = e["PINGONE_USERNAME"] ?? "" - daVinciPassword = e["PINGONE_PASSWORD"] ?? "" - daVinciAcrValues = e["PINGONE_ACR_VALUES"] ?? "" + /// Reads a variable from the process environment, falling back to the + /// parsed `.env` file. Static so the initializer can use it before + /// stored properties are fully initialized. + private static func readValue(_ key: String) -> String? { + let processValue = ProcessInfo.processInfo.environment[key] + return processValue ?? fileValues[key] } /// True when all vars required for Journey Tier 2 tests are set. diff --git a/PingTestRunner/ios/PingTestRunnerUITests/UIInteractionHelpers.swift b/PingTestRunner/ios/PingTestRunnerUITests/UIInteractionHelpers.swift index 3b41cdc6b..dce32126e 100644 --- a/PingTestRunner/ios/PingTestRunnerUITests/UIInteractionHelpers.swift +++ b/PingTestRunner/ios/PingTestRunnerUITests/UIInteractionHelpers.swift @@ -42,7 +42,19 @@ extension XCUIElement { tap() } - /// Asserts the element exists and is hittable within `timeout` seconds, taps it, then types `text`. + /// Pacing between tapping a text field and typing into it. Without it the first + /// keystroke can race the field's focus registration and be silently swallowed + /// (observed as "rn-cicd-user" landing as "r-cicd-user"), which the server then + /// rejects with "Invalid username and/or password". + private static let typePacing: TimeInterval = 1.0 + + /// Asserts the element exists and is hittable within `timeout` seconds, taps it, + /// pauses briefly to let focus settle, then types `text`. + /// + /// For non-secure fields the landed value is verified and typing is retried up to + /// 3 times if the first keystroke was swallowed. Secure text fields skip + /// verification: XCUITest reports a SecureTextField's `value` as '' (masked), so + /// verification cannot distinguish a dropped keystroke from a complete value. func typeTextWhenReady( _ text: String, timeout: TimeInterval = 10, @@ -61,7 +73,31 @@ extension XCUIElement { file: file, line: line ) - tap() - typeText(text) + guard elementType != .secureTextField else { + tap() + Thread.sleep(forTimeInterval: Self.typePacing) + typeText(text) + return + } + var landedText = "" + for _ in 1...3 { + tap() + Thread.sleep(forTimeInterval: Self.typePacing) + typeText(text) + landedText = value as? String ?? "" + if landedText == text { + return + } + // First keystroke was swallowed: clear the field and retry. + let backspaces = String(repeating: XCUIKeyboardKey.delete.rawValue, count: landedText.count) + typeText(backspaces) + } + // Report the value as it landed on the final attempt, not the field's + // current state: the retry above has already backspaced the field clear. + XCTFail( + "Failed to type text into element after 3 attempts: value was '\(landedText)', expected '\(text)'", + file: file, + line: line + ) } } diff --git a/PingTestRunner/ios/PingTestRunnerUITests/UseDaVinciUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/UseDaVinciUITests.swift new file mode 100644 index 000000000..e2650fef2 --- /dev/null +++ b/PingTestRunner/ios/PingTestRunnerUITests/UseDaVinciUITests.swift @@ -0,0 +1,154 @@ +/* + * 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-davinci.test.ts (Tier 2 — server required). +/// +/// Verifies hook-API state transitions for the useDaVinci + useDaVinciForm +/// scenario: field rendering, next(), and post-login actions +/// (token, userinfo, refresh, revoke, logout). +final class UseDaVinciUITests: BaseTestCase { + + override func setUp() { + super.setUp() + var extras: [String: String] = [ + "PINGONE_DISCOVERY_ENDPOINT": env.daVinciDiscoveryEndpoint, + "PINGONE_CLIENT_ID": env.daVinciClientId, + "PINGONE_REDIRECT_URI": env.daVinciRedirectUri, + // Storage survives app relaunches on the simulator; without this a + // session from a previous test makes start() return SuccessNode + // immediately (server sends authorizeResponse), skipping the form. + "PING_CLEAR_STORAGE": "true", + ] + if !env.daVinciAcrValues.isEmpty { + extras["PINGONE_ACR_VALUES"] = env.daVinciAcrValues + } + launchApp(scenario: "use-davinci", extras: extras) + } + + // MARK: - Helpers + + private func loginWithValidCredentials() { + elementWithTestID("use-davinci-start-btn").tapWhenReady() + waitForElementWithTestID("use-davinci-field-username", timeout: netTimeout) + elementWithTestID("use-davinci-field-username").typeTextWhenReady(env.daVinciUsername) + elementWithTestID("use-davinci-field-password").typeTextWhenReady(env.daVinciPassword) + buttonWithLabel("Sign On").tapWhenReady() + waitForElementWithTestID("use-davinci-success", timeout: netTimeout) + } + + // MARK: - Tests + + func testAppLaunchesInUseDaVinciScenario() throws { + // A valid launch requires live DaVinci configuration; without it the + // scenario renders its error panel rather than the flow UI. + try skipIfNoDaVinciEnv() + assertAppReady() + } + + /// Verifies form rendering (useDaVinciForm fields), successful login via next(), + /// and that the token is available after SuccessNode — one login round-trip. + func testFormRendersAndLoginSucceeds() throws { + try skipIfNoDaVinciEnv() + elementWithTestID("use-davinci-start-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-davinci-field-username").waitForExistence(timeout: netTimeout), + "Expected username field via useDaVinciForm" + ) + XCTAssertTrue( + elementWithTestID("use-davinci-field-password").waitForExistence(timeout: netTimeout), + "Expected password field via useDaVinciForm" + ) + elementWithTestID("use-davinci-field-username").typeTextWhenReady(env.daVinciUsername) + elementWithTestID("use-davinci-field-password").typeTextWhenReady(env.daVinciPassword) + buttonWithLabel("Sign On").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-davinci-success").waitForExistence(timeout: netTimeout), + "Expected use-davinci-success after valid credentials" + ) + XCTAssertTrue( + elementWithTestID("use-davinci-token-result").waitForExistence(timeout: netTimeout), + "Expected use-davinci-token-result after success" + ) + } + + /// Verifies userinfo(), refresh(), and revoke() hook actions — one login round-trip. + func testUserinfoRefreshAndRevoke() throws { + try skipIfNoDaVinciEnv() + loginWithValidCredentials() + + elementWithTestID("use-davinci-userinfo-btn").tapWhenReady() + let userinfo = textContentOfElement(withTestID: "use-davinci-userinfo-result", timeout: netTimeout) + XCTAssertTrue(userinfo.contains("\"sub\""), "Expected userinfo payload to contain 'sub'") + + elementWithTestID("use-davinci-refresh-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-davinci-refreshed").waitForExistence(timeout: netTimeout), + "Expected use-davinci-refreshed after refresh()" + ) + + elementWithTestID("use-davinci-revoke-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-davinci-revoked").waitForExistence(timeout: netTimeout), + "Expected use-davinci-revoked after revoke()" + ) + } + + func testLogoutCompletesViaHookActions() throws { + try skipIfNoDaVinciEnv() + loginWithValidCredentials() + elementWithTestID("use-davinci-logout-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-davinci-logged-out").waitForExistence(timeout: netTimeout), + "Expected use-davinci-logged-out after logoutUser()" + ) + } +} + +/// XCUITest equivalent of the 'useDaVinci — ErrorNode on wrong credentials' +/// describe block in use-davinci.test.ts. +final class UseDaVinciErrorUITests: BaseTestCase { + + override func setUp() { + super.setUp() + var extras: [String: String] = [ + "PINGONE_DISCOVERY_ENDPOINT": env.daVinciDiscoveryEndpoint, + "PINGONE_CLIENT_ID": env.daVinciClientId, + "PINGONE_REDIRECT_URI": env.daVinciRedirectUri, + // Same fresh-session requirement as UseDaVinciUITests.setUp. + "PING_CLEAR_STORAGE": "true", + ] + if !env.daVinciAcrValues.isEmpty { + extras["PINGONE_ACR_VALUES"] = env.daVinciAcrValues + } + launchApp(scenario: "use-davinci", extras: extras) + } + + func testAppLaunchesInUseDaVinciScenario() throws { + // A valid launch requires live DaVinci configuration; without it the + // scenario renders its error panel rather than the flow UI. + try skipIfNoDaVinciEnv() + assertAppReady() + } + + func testNextWithWrongPasswordReachesErrorNode() throws { + try skipIfNoDaVinciEnv() + elementWithTestID("use-davinci-start-btn").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-davinci-field-username").waitForExistence(timeout: netTimeout), + "Expected username field after start()" + ) + elementWithTestID("use-davinci-field-username").typeTextWhenReady(env.daVinciUsername) + elementWithTestID("use-davinci-field-password").typeTextWhenReady("wrong_password") + buttonWithLabel("Sign On").tapWhenReady() + XCTAssertTrue( + elementWithTestID("use-davinci-error-node").waitForExistence(timeout: netTimeout), + "Expected use-davinci-error-node after wrong password" + ) + } +} diff --git a/PingTestRunner/ios/PingTestRunnerUITests/UseJourneyUITests.swift b/PingTestRunner/ios/PingTestRunnerUITests/UseJourneyUITests.swift index 4f1584ca7..111fce6f3 100644 --- a/PingTestRunner/ios/PingTestRunnerUITests/UseJourneyUITests.swift +++ b/PingTestRunner/ios/PingTestRunnerUITests/UseJourneyUITests.swift @@ -29,7 +29,13 @@ final class UseJourneyUITests: BaseTestCase { // MARK: - Helpers + /// Delay before each login so back-to-back tests do not authenticate the + /// shared test account in rapid succession; consecutive logins spaced only + /// seconds apart have hung the flow with no node rendered. + private static let loginSpacing: TimeInterval = 10 + private func loginWithValidCredentials() { + Thread.sleep(forTimeInterval: Self.loginSpacing) elementWithTestID("use-journey-start-btn").tapWhenReady() waitForElementWithTestID("use-journey-field-NameCallback:0", timeout: netTimeout) elementWithTestID("use-journey-field-NameCallback:0").typeTextWhenReady(env.testUsername) diff --git a/PingTestRunner/scenarios/DaVinciScenario.tsx b/PingTestRunner/scenarios/DaVinciScenario.tsx index cf1657ff5..fceb70415 100644 --- a/PingTestRunner/scenarios/DaVinciScenario.tsx +++ b/PingTestRunner/scenarios/DaVinciScenario.tsx @@ -18,7 +18,8 @@ * davinci-field-{key} → per-collector input * davinci-field-{key}-option-{value} → option button for single/multi-select * davinci-flow-{key} → FLOW_BUTTON / FLOW_LINK / ACTION - * davinci-submit-btn → SUBMIT_BUTTON (or fallback submit) + * davinci-submit-btn → first SUBMIT_BUTTON (or fallback submit) + * davinci-submit-{label} → additional SUBMIT_BUTTON collectors, keyed by label * davinci-success → SuccessNode reached * davinci-error → ErrorNode reached * davinci-error-message → ErrorNode message @@ -52,25 +53,25 @@ import type { // ─── launch args ───────────────────────────────────────────────────────────── interface DaVinciLaunchArgs { - PING_DISCOVERY_ENDPOINT?: string; - PING_CLIENT_ID?: string; - PING_REDIRECT_URI?: string; - PING_SCOPES?: string; - PING_TIMEOUT?: string; - PING_ACR_VALUES?: string; + PINGONE_DISCOVERY_ENDPOINT?: string; + PINGONE_CLIENT_ID?: string; + PINGONE_REDIRECT_URI?: string; + PINGONE_SCOPES?: string; + PINGONE_TIMEOUT?: string; + PINGONE_ACR_VALUES?: string; } const args = LaunchArguments.value(); -const DISCOVERY_ENDPOINT = args.PING_DISCOVERY_ENDPOINT ?? ''; -const CLIENT_ID = args.PING_CLIENT_ID ?? ''; +const DISCOVERY_ENDPOINT = args.PINGONE_DISCOVERY_ENDPOINT ?? ''; +const CLIENT_ID = args.PINGONE_CLIENT_ID ?? ''; const REDIRECT_URI = - args.PING_REDIRECT_URI ?? 'org.forgerock.demo://oauth2redirect'; -const SCOPES = (args.PING_SCOPES ?? 'openid profile email') + args.PINGONE_REDIRECT_URI ?? 'org.forgerock.demo://oauth2redirect'; +const SCOPES = (args.PINGONE_SCOPES ?? 'openid profile email') .split(' ') .map((s) => s.trim()) .filter(Boolean); -const TIMEOUT = args.PING_TIMEOUT ? Number(args.PING_TIMEOUT) : undefined; -const ACR_VALUES = args.PING_ACR_VALUES ?? undefined; +const TIMEOUT = args.PINGONE_TIMEOUT ? Number(args.PINGONE_TIMEOUT) : undefined; +const ACR_VALUES = args.PINGONE_ACR_VALUES ?? undefined; // ─── state ─────────────────────────────────────────────────────────────────── @@ -315,11 +316,13 @@ function ContinueNodeForm({ onSubmit, onFlow, }: ContinueNodeFormProps): React.JSX.Element { - const hasSubmitButton = collectors.some((c) => c.type === 'SUBMIT_BUTTON'); + const firstSubmitIndex = collectors.findIndex( + (c) => c.type === 'SUBMIT_BUTTON', + ); return ( - {collectors.map((collector) => ( + {collectors.map((collector, index) => ( onChange({ ...values, [collector.key]: val })} onFlow={onFlow} onSubmit={onSubmit} + isFirstSubmitButton={index === firstSubmitIndex} /> ))} - {!hasSubmitButton && ( + {firstSubmitIndex < 0 && (