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 " >> $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 " >> $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 && (
)}
@@ -344,6 +348,7 @@ interface CollectorFieldProps {
onValueChange: (val: DaVinciFormValue) => void;
onFlow: (flowKey: string) => void;
onSubmit: () => void;
+ isFirstSubmitButton: boolean;
}
function CollectorField({
@@ -352,6 +357,7 @@ function CollectorField({
onValueChange,
onFlow,
onSubmit,
+ isFirstSubmitButton,
}: CollectorFieldProps): React.JSX.Element | null {
const testID = `davinci-field-${collector.key}`;
@@ -397,10 +403,20 @@ function CollectorField({
DaVinciNormalizedCollector,
{ type: 'SUBMIT_BUTTON' }
>;
+ const label = submitCollector.label || 'Submit';
+ // A flow screen can carry several submit buttons (e.g. Sign On / Register /
+ // Trouble) and tests must target one specifically, so give every button a
+ // label-derived testID. The first additionally keeps the shared alias so
+ // single-button flows work with the generic davinci-submit-btn testID.
+ const labelKey = label.toLowerCase().replace(/[^a-z0-9]+/g, '');
return (
);
diff --git a/PingTestRunner/scenarios/UseDaVinciScenario.tsx b/PingTestRunner/scenarios/UseDaVinciScenario.tsx
index e135ea430..8a65c72e9 100644
--- a/PingTestRunner/scenarios/UseDaVinciScenario.tsx
+++ b/PingTestRunner/scenarios/UseDaVinciScenario.tsx
@@ -36,7 +36,7 @@
* use-davinci-logged-out → visible after logout
*/
-import React, { useCallback, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Switch, Text, TextInput, View } from 'react-native';
import { LaunchArguments } from 'react-native-launch-arguments';
import {
@@ -45,49 +45,87 @@ import {
useDaVinciForm,
} from '@ping-identity/rn-davinci';
import type {
+ DaVinciClient,
DaVinciFormValue,
DaVinciNormalizedCollector,
} from '@ping-identity/rn-davinci';
+import { DaVinciError } from '@ping-identity/rn-davinci';
// ─── launch args ─────────────────────────────────────────────────────────────
interface DaVinciLaunchArgs {
- PING_DISCOVERY_ENDPOINT?: string;
- PING_CLIENT_ID?: string;
- PING_REDIRECT_URI?: string;
- PING_SCOPES?: string;
- PING_TIMEOUT?: string;
+ PINGONE_DISCOVERY_ENDPOINT?: string;
+ PINGONE_CLIENT_ID?: string;
+ PINGONE_REDIRECT_URI?: string;
+ PINGONE_SCOPES?: string;
+ PINGONE_TIMEOUT?: string;
+ PINGONE_ACR_VALUES?: string;
+ PING_CLEAR_STORAGE?: string;
+ PING_AUTOSTART?: 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 TIMEOUT = args.PINGONE_TIMEOUT ? Number(args.PINGONE_TIMEOUT) : undefined;
+const ACR_VALUES = args.PINGONE_ACR_VALUES ?? undefined;
+// PING_CLEAR_STORAGE is consumed natively (AppDelegate): the wipe completes
+// before the RN bundle boots, so start() reaches the login form instead of
+// resuming a stale SSO session. No JS-side gate is needed.
// ─── component ───────────────────────────────────────────────────────────────
-export default function UseDaVinciScenario(): React.JSX.Element {
- const client = useMemo(
- () =>
- createDaVinciClient({
+// createDaVinciClient throws (argument_error) on empty discoveryEndpoint /
+// clientId / redirectUri. Throwing from render would tear down the whole RN
+// tree — including ping-test-runner-root — and break the app-launch test, so
+// an invalid config is caught here and surfaced through the use-davinci-error
+// testID instead. A placeholder-configured client keeps the useDaVinci hooks
+// valid; it is never used because handleStart() bails when configError is set.
+function createClientOrError(): { client: DaVinciClient; configError: string } {
+ try {
+ return {
+ client: createDaVinciClient({
modules: {
oidc: {
discoveryEndpoint: DISCOVERY_ENDPOINT,
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
scopes: SCOPES,
+ ...(ACR_VALUES !== undefined ? { acrValues: ACR_VALUES } : {}),
},
},
...(TIMEOUT !== undefined ? { timeout: TIMEOUT } : {}),
}),
- [],
- );
+ configError: '',
+ };
+ } catch (err) {
+ return {
+ client: createDaVinciClient({
+ modules: {
+ oidc: {
+ discoveryEndpoint: 'https://placeholder.invalid',
+ clientId: 'placeholder',
+ redirectUri: REDIRECT_URI,
+ scopes: SCOPES,
+ },
+ },
+ }),
+ configError:
+ err instanceof DaVinciError
+ ? err.message
+ : `Failed to create DaVinci client: ${String(err)}`,
+ };
+ }
+}
+
+export default function UseDaVinciScenario(): React.JSX.Element {
+ const { client, configError } = useMemo(() => createClientOrError(), []);
const {
node,
@@ -108,6 +146,10 @@ export default function UseDaVinciScenario(): React.JSX.Element {
const [refreshed, setRefreshed] = useState(false);
const [revoked, setRevoked] = useState(false);
const [loggedOut, setLoggedOut] = useState(false);
+ // Survives the node being cleared: useDaVinci.revoke()/logoutUser() call
+ // setNode(null), which would unmount a panel gated on node.type — hiding the
+ // very markers these actions are meant to reveal.
+ const [successReached, setSuccessReached] = useState(false);
const fetchToken = useCallback(async () => {
try {
@@ -121,20 +163,38 @@ export default function UseDaVinciScenario(): React.JSX.Element {
}, [user]);
const handleStart = useCallback(async () => {
+ if (configError !== '') {
+ return;
+ }
try {
const firstNode = await start();
if (firstNode.type === 'SuccessNode') {
+ setSuccessReached(true);
await fetchToken();
}
} catch {
// error updated by hook
}
- }, [start, fetchToken]);
+ }, [configError, start, fetchToken]);
+
+ // Debug aid: with -PING_AUTOSTART true the flow starts on mount without
+ // waiting for the Start button, so flow traffic can be observed via
+ // `log stream` on the simulator without a UI driver tapping the button.
+ useEffect(() => {
+ if (args.PING_AUTOSTART !== 'true') {
+ return;
+ }
+ // Intentional mount-time trigger of the external auth system; start()
+ // internally calls setLoading(true) synchronously, which the rule flags.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ handleStart();
+ }, [handleStart]);
const handleSubmit = useCallback(async () => {
try {
const nextNode = await next(form.input);
if (nextNode.type === 'SuccessNode') {
+ setSuccessReached(true);
await fetchToken();
}
} catch {
@@ -191,6 +251,10 @@ export default function UseDaVinciScenario(): React.JSX.Element {
return (
+ {configError !== '' && (
+ {configError}
+ )}
+