From 55361f9df281b17319473e630972a44d325345a8 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 9 Sep 2026 16:33:24 +0530 Subject: [PATCH] Add an end-to-end test suite for the Quickstart sample Drive the Quickstart through real authentication against a real ThunderID server using Maestro: sign in and sign out, then register a new account and sign in as it. The suite lives in tests/e2e and one script owns the whole run, so CI executes exactly what a contributor runs locally. run-e2e.sh drives an iOS Simulator and needs a Mac; run-e2e.ps1 drives an Android emulator and is the path for contributors on Windows. This is the only layer that exercises the Dart and native halves together, since the unit tests mock the method channel. CI is shared between the PR builder and a nightly workflow through a reusable run-e2e-suite.yml workflow, so the two cannot drift. It's a reusable workflow rather than a composite action because a composite action's steps can't carry a per-step timeout-minutes, which the emulator step needs so a hung teardown can't eat the whole job's budget and force the job's conclusion to cancelled with no real result - the same structure used in the Android and iOS SDK repos. The PR builder pins thunderid-version to a known-good release like iOS already does, since fork PRs run without repository secrets and the unauthenticated GitHub API call resolving the latest release hits its rate limit easily. Pin the Maestro CLI version and emit a JUnit report. CI installed whatever Maestro was newest at the time, so the test runner changed under the suite between runs with nothing in the repository to show it, which makes a CI-only failure impossible to reproduce faithfully. Pin it, and bump deliberately after checking the flows. The JUnit report gives a failed run a machine-readable result instead of a console log to scrape, and CI collects it with the other debug artifacts. Wait explicitly for the landing screen in ensure-signed-out. Every flow starts here, and a bare assertVisible gets Maestro's short default lookup timeout rather than the generous budget the rest of the suite uses. On a loaded CI emulator the landing screen does not always render inside it, which failed the Android sign-up flow 19 seconds in, before the flow had done anything. Expose flow field and action identifiers to the platform accessibility tree. A widget Key never leaves the Flutter tree, so nothing driving the app from outside could see these fields. They now also carry Semantics.identifier, which maps to resource-id on Android and accessibilityIdentifier on iOS. The identifier resolves the server's field identifier rather than its ref, which is what keeps one set of selectors working across all three SDKs; the ref remains the key used for form state and submission. Semantics.identifier requires Flutter 3.19, so the declared minimum moves up from 3.16. flow_form.dart previously had no Semantics at all, which also left it short of the accessibility rule in the repository's own guidelines. Add ThunderIDConfig.allowInsecureConnections and forward it across the method channel. Without it there was no way for a Flutter app to reach a development server over the self-signed certificate ThunderID generates for localhost, which blocked local development on Android as much as it blocked these tests. The native Android SDK honours it for loopback hosts only, and the sample enables it for debug builds only. Also fixes issues surfaced while getting the suite green on this branch: - A duplicate allowInsecureConnections declaration (Dart and Kotlin) left behind by rebasing onto upstream/main - git's textual merge combined two independent additions of the same field without flagging a conflict. - Package.swift declared its target's source path as '../Classes', which escapes the package root SPM requires targets to stay within (ios/thunderid_flutter/) - exactly what Xcode reported as 'target thunderid_flutter in package flutter-sdks is outside the package root'. This had failed every E2E run since the suite was added. Moves the plugin's Swift sources to the SPM-conventional ios/thunderid_flutter/Sources/thunderid_flutter/, now referenced by both the podspec (CocoaPods) and Package.swift (SPM). - ThunderIDFlutterPlugin wasn't @MainActor while ThunderIDMethodHandler is, so the plugin's 'private let handler = ThunderIDMethodHandler()' property initializer called a main-actor-isolated initializer from a synchronous nonisolated context, flagged under the CI runner's stricter Swift concurrency checking. Flutter always registers and dispatches plugins on the main thread anyway, so isolating the whole class is correct. Refs thunder-id/thunderid#5181 Signed-off-by: Brion --- .github/workflows/nightly.yml | 36 ++ .github/workflows/pr-builder.yml | 11 + .github/workflows/run-e2e-suite.yml | 73 ++++ .gitignore | 8 + .../flutter/ThunderIDMethodHandler.kt | 5 +- ios/thunderid_flutter.podspec | 2 +- ios/thunderid_flutter/Package.resolved | 14 + ios/thunderid_flutter/Package.swift | 3 +- .../AppAttestTokenProvider.swift | 0 .../ThunderIDFlutterPlugin.swift | 2 +- .../ThunderIDMethodHandler.swift | 0 lib/src/models/thunderid_config.dart | 21 +- lib/src/widgets/flow_form.dart | 80 ++-- pubspec.yaml | 6 +- samples/quickstart/README.md | 119 +----- .../thunderid-config/thunderid-config.yaml | 34 ++ tests/e2e/README.md | 73 ++++ tests/e2e/flows/config.yaml | 6 + tests/e2e/flows/signin.yaml | 49 +++ tests/e2e/flows/signup.yaml | 91 ++++ .../flows/subflows/dismiss-save-password.yaml | 12 + .../e2e/flows/subflows/ensure-signed-out.yaml | 26 ++ tests/e2e/run-e2e.ps1 | 369 ++++++++++++++++ tests/e2e/run-e2e.sh | 404 ++++++++++++++++++ 24 files changed, 1289 insertions(+), 155 deletions(-) create mode 100644 .github/workflows/nightly.yml create mode 100644 .github/workflows/run-e2e-suite.yml create mode 100644 ios/thunderid_flutter/Package.resolved rename ios/{Classes => thunderid_flutter/Sources/thunderid_flutter}/AppAttestTokenProvider.swift (100%) rename ios/{Classes => thunderid_flutter/Sources/thunderid_flutter}/ThunderIDFlutterPlugin.swift (89%) rename ios/{Classes => thunderid_flutter/Sources/thunderid_flutter}/ThunderIDMethodHandler.swift (100%) create mode 100644 samples/quickstart/thunderid-config/thunderid-config.yaml create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/flows/config.yaml create mode 100644 tests/e2e/flows/signin.yaml create mode 100644 tests/e2e/flows/signup.yaml create mode 100644 tests/e2e/flows/subflows/dismiss-save-password.yaml create mode 100644 tests/e2e/flows/subflows/ensure-signed-out.yaml create mode 100644 tests/e2e/run-e2e.ps1 create mode 100755 tests/e2e/run-e2e.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..3fdfa0f --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,36 @@ +# Runs the Quickstart E2E suite against the latest published ThunderID release, every night. +# +# The PR builder runs the same suite through the same composite action. The nightly exists +# because these flows talk to a freshly downloaded server release, so a scheduled run catches +# breakage introduced by a new server release rather than by a change in this repository. +# +# Uses: +# OS: macos-latest + +name: 🌙 Nightly E2E + +on: + schedule: + # 02:30 UTC. + - cron: "30 2 * * *" + workflow_dispatch: + inputs: + thunderid-version: + description: ThunderID release to test against, without the leading "v". Blank uses the latest. + required: false + default: "" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PRODUCT_NAME: "ThunderID" + +jobs: + e2e: + name: 🎭 E2E Tests + uses: ./.github/workflows/run-e2e-suite.yml + with: + thunderid-version: ${{ inputs.thunderid-version }} + artifact-suffix: -nightly diff --git a/.github/workflows/pr-builder.yml b/.github/workflows/pr-builder.yml index 90ecae5..c4342da 100644 --- a/.github/workflows/pr-builder.yml +++ b/.github/workflows/pr-builder.yml @@ -138,3 +138,14 @@ jobs: - name: 🔨 Build Quickstart Sample working-directory: samples/quickstart run: flutter build apk --debug + + e2e: + name: 🎭 E2E Tests + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/run-e2e-suite.yml + with: + # Fork PRs run without repository secrets, so the unauthenticated GitHub API call that + # resolves "latest" hits its rate limit easily. The nightly workflow already exists to + # catch breakage from new server releases, so pin the PR builder to a known-good + # version instead of resolving it live; bump this alongside the server's own releases. + thunderid-version: "1.0.1" diff --git a/.github/workflows/run-e2e-suite.yml b/.github/workflows/run-e2e-suite.yml new file mode 100644 index 0000000..9518fbb --- /dev/null +++ b/.github/workflows/run-e2e-suite.yml @@ -0,0 +1,73 @@ +# Runs the Quickstart E2E suite: a ThunderID server, the provisioned test application and user, +# the sample built onto a simulator, and the Maestro flows driving it. +# +# Shared by the PR builder and the nightly workflow so the two cannot drift. All of the actual +# work lives in tests/e2e/run-e2e.sh, which is also what a contributor runs locally, so a green +# run here and a green run on a laptop mean the same thing. +# +# These flows run on iOS: the sample's iOS target carries an NSAllowsArbitraryLoads exemption so +# it accepts the server's self-signed certificate, while its Android target has no equivalent and +# the plugin exposes no allowInsecureConnections option of its own. + +name: Run E2E Suite + +on: + workflow_call: + inputs: + thunderid-version: + description: ThunderID release to test against, without the leading "v". Defaults to the latest release. + required: false + type: string + default: "" + artifact-suffix: + description: Appended to the debug artifact name, so concurrent callers do not collide. + required: false + type: string + default: "" + +jobs: + e2e: + name: 🎭 E2E Tests + runs-on: macos-latest + timeout-minutes: 45 + steps: + - name: 📥 Checkout Code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🐦 Set up Flutter + uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff # v2 + with: + channel: stable + cache: true + + - name: 🧭 Install Maestro + shell: bash + # Pinned rather than latest: an unpinned install swaps the test runner out from under the + # suite between runs, so a CI failure cannot be reproduced against the version a + # contributor has locally. Bump this deliberately, after checking the flows against it. + env: + MAESTRO_VERSION: "2.9.0" + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: 🔬 Run E2E Suite + shell: bash + working-directory: tests/e2e + env: + THUNDERID_VERSION: ${{ inputs.thunderid-version }} + run: ./run-e2e.sh + + - name: 📤 Upload Debug Artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: e2e-debug-flutter${{ inputs.artifact-suffix }} + # Maestro writes screenshots, the recorded hierarchy and its own logs here on failure, + # which is the only way to tell a genuine regression from a flake after the fact. + path: | + ~/.maestro/tests + tests/e2e/report.xml + tests/e2e/.thunderid-server/server.log + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 809f405..62697f0 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ unlinked_spec.ds **/ios/**/Icon? **/ios/**/Pods/ **/ios/**/.symlinks/ +**/ios/**/.build/ +**/ios/**/.swiftpm/ **/ios/**/profile **/ios/**/xcuserdata **/ios/.generated/ @@ -126,3 +128,9 @@ app.*.symbols .env .env.local .env.*.local + +# ThunderID server distribution downloaded by the E2E suite +.thunderid-server/ + +# Maestro JUnit report from the E2E suite. +tests/e2e/report.xml diff --git a/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt b/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt index 1f124d4..50d6fd5 100644 --- a/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt +++ b/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt @@ -180,6 +180,10 @@ class ThunderIDMethodHandler(private val context: Context) { afterSignInUrl = args["afterSignInUrl"] as? String, afterSignOutUrl = args["afterSignOutUrl"] as? String, applicationId = args["applicationId"] as? String, + // Lets a development build reach a ThunderID server using the self-signed certificate + // it generates for localhost. iOS achieves the same at the app level through an + // NSAppTransportSecurity exemption, so the flag is only meaningful here. + allowInsecureConnections = args["allowInsecureConnections"] as? Boolean ?: false, attestationEnabled = attestationEnabled, attestationTokenProvider = if (attestationEnabled) { PlayIntegrityTokenProvider(context, cloudProjectNumber!!)::requestToken @@ -187,7 +191,6 @@ class ThunderIDMethodHandler(private val context: Context) { null }, tokenValidation = validation, - allowInsecureConnections = args["allowInsecureConnections"] as? Boolean ?: false, vendor = args["vendor"] as? String ?: ThunderIDConfig.DEFAULT_VENDOR ) } diff --git a/ios/thunderid_flutter.podspec b/ios/thunderid_flutter.podspec index 08e3e8e..abb53f0 100644 --- a/ios/thunderid_flutter.podspec +++ b/ios/thunderid_flutter.podspec @@ -10,7 +10,7 @@ Pod::Spec.new do |s| s.license = { :type => 'Apache License 2.0', :file => '../LICENSE' } s.author = { 'ThunderID' => 'dev@thunderid.dev' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*' + s.source_files = 'thunderid_flutter/Sources/thunderid_flutter/**/*' s.dependency 'Flutter' s.dependency 'ThunderID', '>= 1.1.0' s.platform = :ios, '16.0' diff --git a/ios/thunderid_flutter/Package.resolved b/ios/thunderid_flutter/Package.resolved new file mode 100644 index 0000000..499497d --- /dev/null +++ b/ios/thunderid_flutter/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "ios-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/thunder-id/ios-sdks.git", + "state" : { + "revision" : "c8186ebe83be4afb2107f39fddd4b12b993575ef", + "version" : "1.1.0" + } + } + ], + "version" : 2 +} diff --git a/ios/thunderid_flutter/Package.swift b/ios/thunderid_flutter/Package.swift index eab5f7b..379a8e2 100644 --- a/ios/thunderid_flutter/Package.swift +++ b/ios/thunderid_flutter/Package.swift @@ -15,8 +15,7 @@ let package = Package( name: "thunderid_flutter", dependencies: [ .product(name: "ThunderID", package: "ios-sdks") - ], - path: "../Classes" + ] ) ] ) diff --git a/ios/Classes/AppAttestTokenProvider.swift b/ios/thunderid_flutter/Sources/thunderid_flutter/AppAttestTokenProvider.swift similarity index 100% rename from ios/Classes/AppAttestTokenProvider.swift rename to ios/thunderid_flutter/Sources/thunderid_flutter/AppAttestTokenProvider.swift diff --git a/ios/Classes/ThunderIDFlutterPlugin.swift b/ios/thunderid_flutter/Sources/thunderid_flutter/ThunderIDFlutterPlugin.swift similarity index 89% rename from ios/Classes/ThunderIDFlutterPlugin.swift rename to ios/thunderid_flutter/Sources/thunderid_flutter/ThunderIDFlutterPlugin.swift index 036441c..720b5c6 100644 --- a/ios/Classes/ThunderIDFlutterPlugin.swift +++ b/ios/thunderid_flutter/Sources/thunderid_flutter/ThunderIDFlutterPlugin.swift @@ -2,7 +2,7 @@ import Flutter import UIKit import ThunderID -@objc public class ThunderIDFlutterPlugin: NSObject, FlutterPlugin { +@objc @MainActor public class ThunderIDFlutterPlugin: NSObject, FlutterPlugin { private let handler = ThunderIDMethodHandler() public static func register(with registrar: FlutterPluginRegistrar) { diff --git a/ios/Classes/ThunderIDMethodHandler.swift b/ios/thunderid_flutter/Sources/thunderid_flutter/ThunderIDMethodHandler.swift similarity index 100% rename from ios/Classes/ThunderIDMethodHandler.swift rename to ios/thunderid_flutter/Sources/thunderid_flutter/ThunderIDMethodHandler.swift diff --git a/lib/src/models/thunderid_config.dart b/lib/src/models/thunderid_config.dart index 7d028ed..343e5e8 100644 --- a/lib/src/models/thunderid_config.dart +++ b/lib/src/models/thunderid_config.dart @@ -36,14 +36,6 @@ class ThunderIDConfig { /// validate against and no endpoint to save to. final bool fetchUserProfile; - // Transport - /// Disables TLS certificate and hostname verification on Android. - /// - /// Intended only for local development against a self-signed ThunderID instance; - /// gate it on a debug flag and never ship it enabled. Ignored on iOS, where the - /// native SDK already trusts a locally-served certificate on its own. - final bool allowInsecureConnections; - // Platform Attestation /// When true, the native SDK sends a platform attestation token (Apple App Attest / /// Google Play Integrity) on native flow-initiate requests. @@ -52,6 +44,19 @@ class ThunderIDConfig { /// Google Cloud project number, required by Play Integrity on Android. final int? cloudProjectNumber; + // Transport + /// When true, the native SDK accepts TLS certificates it cannot verify. + /// + /// This exists so a development build can talk to a ThunderID server using the self-signed + /// certificate it generates for `localhost`. On iOS the same thing is achieved at the app + /// level with an `NSAppTransportSecurity` exemption, so this flag only takes effect on + /// Android, where it is forwarded to the native SDK's own `allowInsecureConnections`. + /// + /// Never enable it in a release build: it disables certificate validation entirely, which + /// removes the guarantee that the server on the other end is the one you think it is. Gate it + /// on a debug check, as the Quickstart sample does. + final bool allowInsecureConnections; + // Token Validation final TokenValidationConfig tokenValidation; diff --git a/lib/src/widgets/flow_form.dart b/lib/src/widgets/flow_form.dart index 045edcd..08dbb4b 100644 --- a/lib/src/widgets/flow_form.dart +++ b/lib/src/widgets/flow_form.dart @@ -387,20 +387,28 @@ class _FlowFormState extends State { final label = _resolve(comp['label'], fallback: _capitalize(ref)); return Padding( padding: const EdgeInsets.only(bottom: 16), - child: TextField( - key: Key('thunderid-field-$ref'), - controller: _controllers[ref], - decoration: InputDecoration( - labelText: label, - hintText: _resolve(comp['placeholder'], fallback: _capitalize(ref)), - floatingLabelBehavior: FloatingLabelBehavior.always, - border: const OutlineInputBorder(), + // A widget Key is internal to the Flutter tree and never reaches the platform + // accessibility tree, so it cannot be targeted by anything driving the app from outside + // (UI Automator, XCUITest, and black-box runners such as Maestro). Semantics.identifier + // is what maps to resource-id on Android and accessibilityIdentifier on iOS. The Key is + // kept as well so widget tests can keep finding these fields by key. + child: Semantics( + identifier: 'thunderid-field-${_fieldTestId(comp)}', + child: TextField( + key: Key('thunderid-field-$ref'), + controller: _controllers[ref], + decoration: InputDecoration( + labelText: label, + hintText: _resolve(comp['placeholder'], fallback: _capitalize(ref)), + floatingLabelBehavior: FloatingLabelBehavior.always, + border: const OutlineInputBorder(), + ), + obscureText: isPassword, + keyboardType: isPassword + ? TextInputType.visiblePassword + : TextInputType.emailAddress, + autocorrect: false, ), - obscureText: isPassword, - keyboardType: isPassword - ? TextInputType.visiblePassword - : TextInputType.emailAddress, - autocorrect: false, ), ); } @@ -491,21 +499,26 @@ class _FlowFormState extends State { return Padding( padding: const EdgeInsets.only(top: 8), - child: FilledButton( - key: Key('thunderid-action-$actionId'), - onPressed: widget.isLoading - ? null - : () => widget.submit( - actionId, - _controllers.map((k, v) => MapEntry(k, v.text)), - ), - child: isSpinning - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(label), + // See the note on the field above: the Key alone is invisible outside the Flutter tree, + // so the identifier is what an external driver can actually target. + child: Semantics( + identifier: 'thunderid-action-$actionId', + child: FilledButton( + key: Key('thunderid-action-$actionId'), + onPressed: widget.isLoading + ? null + : () => widget.submit( + actionId, + _controllers.map((k, v) => MapEntry(k, v.text)), + ), + child: isSpinning + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(label), + ), ), ); } @@ -613,6 +626,17 @@ class _FlowFormState extends State { ), ); + /// The value used to build a field's accessibility identifier. + /// + /// Deliberately different from [_fieldRef], which prefers `ref` because that is the key the + /// flow submission is built from. The iOS and Android SDKs tag their fields with the server's + /// `identifier` instead (`thunderid-field-username`, not `thunderid-field-input_001`), so + /// preferring `identifier` here keeps one set of selectors working across all three platforms. + String _fieldTestId(Map comp) => _str( + comp['identifier'], + fallback: _str(comp['name'], fallback: _fieldRef(comp)), + ); + String _inputRef(Map input) => _str( input['name'], fallback: _str( diff --git a/pubspec.yaml b/pubspec.yaml index 3d080e1..8a8e2fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,8 +6,10 @@ repository: https://github.com/thunder-id/flutter-sdks issue_tracker: https://github.com/thunder-id/flutter-sdks/issues environment: - sdk: ">=3.2.0 <4.0.0" - flutter: ">=3.16.0" + # Semantics.identifier, used to expose flow field/action identifiers to the platform + # accessibility tree, was added in Flutter 3.19 (Dart 3.3). + sdk: ">=3.3.0 <4.0.0" + flutter: ">=3.19.0" dependencies: flutter: diff --git a/samples/quickstart/README.md b/samples/quickstart/README.md index 9a14fba..f8b379f 100644 --- a/samples/quickstart/README.md +++ b/samples/quickstart/README.md @@ -5,118 +5,13 @@ ThunderID Flutter Quickstart demonstrates the full authentication lifecycle usin **Flow demonstrated:** 1. App opens → unauthenticated state (sign-in screen) 2. User initiates sign-in / sign-up → SDK starts app-native Flow Execution -3. User completes the flow and logs in to ThunderID -4. Successful → authenticated state with profile information, token debugging, and sign-out button. +3. User completes the flow +4. Sign-in → authenticated state with profile information, token debugging, and sign-out button. + Sign-up creates the account but does not start a session, so it returns to the landing screen + and the new credentials have to be used to sign in. 5. User taps Sign Out → session terminated, returns to sign-in screen -## Prerequisites +## Setup & Run -- Flutter 3.16+ -- A running ThunderID instance -- iOS 16+ or Android API 26+ - -## Setup - -```bash -# 1. Copy and fill in your environment -cp .env.example .env - -# 2. Install dependencies -flutter pub get -``` - -### Configuration - -> [!NOTE] -> This sample uses app-native authentication (Flow Execution API), so only the base URL and application ID are required — no OAuth2 client ID or redirect URIs. - -| Variable | Description | -|----------|-------------| -| `THUNDERID_BASE_URL` | Base URL of your ThunderID server (HTTPS) | -| `THUNDERID_APP_ID` | Application UUID from ThunderID console | -| `THUNDERID_ATTESTATION_ENABLED` | `true` to send a platform attestation token on native flows | -| `THUNDERID_CLOUD_PROJECT_NUMBER` | Google Cloud project number (Android Play Integrity) | - -💡 `.env` is gitignored. Never commit real credentials. - -### Attestation via Google Play Integrity / Apple App Attest (optional) - -Set `THUNDERID_ATTESTATION_ENABLED=true` to send a platform attestation token on native -sign-in/sign-up. The token is minted natively by the plugin — Apple App Attest on iOS, -Google Play Integrity on Android. - -Requirements to test end-to-end: -- **Android**: set `THUNDERID_CLOUD_PROJECT_NUMBER`, and publish the app to a Play Console - track linked to that Cloud project (Play Integrity needs a recognized package + signature). -- **iOS**: a physical device and the **App Attest** capability on the `Runner` target (adds the - `com.apple.developer.devicecheck.appattest-environment` entitlement). App Attest does not work - in the simulator. -- **Server**: the **Team ID** and **Bundle ID** registered on the ThunderID application's - attestation settings must match the ones the app is signed with. ThunderID derives the expected - App ID from `.` and rejects a token whose attested App ID differs. - -The plugin generates the App Attest challenge locally. ThunderID does not yet bind verification to -a server-issued challenge, so this is sufficient to exercise the flow end to end; move to a -server-issued challenge once that check lands. - -### Passkeys (WebAuthn) - -Passkey registration/authentication requires the server's passkey relying party (`rp.id`) to be a -real HTTPS domain — not `localhost`, `127.0.0.1`, or `10.0.2.2`. Each platform verifies the calling -app is allowed to use that `rp.id` differently, and this Flutter sample packages both a native iOS -runner and a native Android app, so both verification mechanisms apply: - -**iOS — Associated Domains entitlement.** `ASAuthorizationPlatformPublicKeyCredentialProvider` -requires the app to declare a `webcredentials:` Associated Domain, backed by a hosted -`apple-app-site-association` file. Without this, `ASAuthorizationController` fails immediately with -`Error Domain=com.apple.AuthenticationServices.AuthorizationError Code=1004`. - -This sample ships `ios/Runner/Runner.entitlements` with a placeholder -`webcredentials:your-thunderid-domain.example` entry, wired into `ios/Runner.xcodeproj` via -`CODE_SIGN_ENTITLEMENTS`. To exercise passkeys end-to-end: - -1. Replace the placeholder domain in `ios/Runner/Runner.entitlements` with the domain your - ThunderID server is actually reachable at (must serve valid HTTPS — self-signed certs and - `localhost` will not work). -2. Host an `apple-app-site-association` file at - `https:///.well-known/apple-app-site-association` declaring this app's Team ID and - bundle identifier (`dev.thunderid.Quickstart`) under `webcredentials.apps`. -3. Make sure the server's passkey `rp.id` matches that same domain. -4. Set a `DEVELOPMENT_TEAM` and enable the **Associated Domains** capability for the Runner target - in Xcode (Signing & Capabilities) so the entitlement is actually applied to the build. - -**Android — Digital Asset Links.** Android's Credential Manager requires the domain to serve -`https:///.well-known/assetlinks.json` declaring this app's package name and signing -certificate SHA-256 fingerprint(s) under `delegate_permission/common.get_login_creds`. Without this, -Credential Manager rejects the ceremony. - -This sample ships `assetlinks.json.example` with a placeholder `sha256_cert_fingerprints` entry for -the app's `applicationId` (`dev.thunderid.flutter_quickstart`). To exercise passkeys end-to-end: - -1. Rename/copy `assetlinks.json.example` to `assetlinks.json` and replace - `` with the SHA-256 fingerprint of the signing - certificate for the build you'll test with (get it via - `keytool -list -v -keystore -alias ` or a Gradle `signingReport`). -2. Host that file at `https:///.well-known/assetlinks.json` (same domain as - the iOS setup above, and matching the server's `rp.id`). -3. No `AndroidManifest.xml` change is required — Digital Asset Links verification is purely a - server-hosted file requirement, no App Links intent filter needed. - -Exposing a local ThunderID instance under a real, HTTPS-reachable domain (e.g. via a tunnel) is left -to you — this sample only wires up the entitlement/template file/documentation on each platform, not -the tunnel itself. - -## Run - -Open the project in your IDE and or terminal: - -```bash -# List available devices -flutter devices - -# Launch a specific emulator -flutter emulators --launch - -# Run on a specific device -flutter run -d -``` +See [Try the Flutter Sample App](https://thunderid.dev/docs/v1.0.x/sdks/flutter/guides/try-the-sample-app) +in the Flutter SDK docs for prerequisites, configuration, attestation, passkeys, and run instructions. diff --git a/samples/quickstart/thunderid-config/thunderid-config.yaml b/samples/quickstart/thunderid-config/thunderid-config.yaml new file mode 100644 index 0000000..424ee7f --- /dev/null +++ b/samples/quickstart/thunderid-config/thunderid-config.yaml @@ -0,0 +1,34 @@ +--- +resource_type: application +# Application used exclusively by the Flutter Quickstart E2E suite. It has no OAuth profile: the +# mobile SDKs authenticate app-natively via the Flow Execution API, so the sample app needs only +# the base URL and this application ID. +# +# The ID is fixed so the sample's Config.plist and the Maestro flows can be committed against a +# known value instead of discovering it at run time. +id: 019e5b10-1001-7a2b-9c3d-4e5f60718293 +ouHandle: default +name: Mobile Quickstart E2E App +description: Application for the ThunderID mobile SDK quickstart E2E suites +type: mobile +# A mobile application must normally prove its binary identity via platform attestation before it +# may initiate a flow directly, otherwise /flow/execute fails with FES-1016. Apple App Attest is +# unavailable in the iOS Simulator and Play Integrity requires real Play Services, so neither can +# be satisfied on the emulated devices this suite drives. devMode skips that check. +# +# Test-only. Never enable this on a real tenant: it removes the guarantee that the client +# initiating a flow is the genuine, unmodified app. +# +# NOTE: POST /import currently drops this block silently (it reports success, then stores +# attestation: null), so run-e2e.sh re-applies it over PUT /applications/{id}. Once the +# import path preserves it, that extra step can go. +attestation: + devMode: true +authFlowHandle: default-flow +# The suite registers a fresh user through the UI rather than relying on a seeded account, so +# self-service registration has to be enabled for this application. +registrationFlowHandle: default-flow +isRegistrationFlowEnabled: true +signOutFlowHandle: default-flow +allowedUserTypes: + - Person diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..c4fdad3 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,73 @@ +# Quickstart E2E + +Maestro flows that drive the Quickstart sample through real authentication against a real +ThunderID server on an iOS Simulator. The flows live in [`flows/`](./flows); the +scripts alongside them get a server into the right state to run against. + +## Running locally + +```bash +# Everything: start a server, provision it, build and install the sample, run the flows +./run-e2e.sh + +# Iterate faster once the server is up and the sample is installed +./run-e2e.sh --skip-server --skip-build + +# Run a single flow +./run-e2e.sh flows/signin.yaml +``` + +Every stage is idempotent. A server that is already serving on `:8090` is reused rather than +restarted, and provisioning re-applies cleanly over an existing application and user. + +## What gets provisioned + +| Resource | Value | +|---|---| +| Application | `019e5b10-1001-7a2b-9c3d-4e5f60718293` (`Mobile Quickstart E2E App`) | +| Test user | `e2e_mobile_user` / `TestPassword@123` | + +The application is declared in +[`samples/quickstart/thunderid-config/thunderid-config.yaml`](../../samples/quickstart/thunderid-config/thunderid-config.yaml), +alongside the sample it configures. The sign-up flow +registers an additional throwaway user per run, named `e2e_signup_`. + +## Things worth knowing + +**The application must be `type: mobile` with `attestation.devMode: true`.** A mobile application +normally has to prove its binary identity through platform attestation before it can initiate a +flow directly, and `/flow/execute` rejects it with `FES-1016` otherwise. Apple App Attest does not +exist in the Simulator, so the check can never be satisfied on the device these flows run on. +`devMode` is test-only and must never be enabled on a real tenant. + +**`POST /import` silently drops the `attestation` block.** It reports the import as successful and +then stores `attestation: null`, which is why `run-e2e.sh` re-applies it over +`PUT /applications/{id}`. Once the import path preserves it, that step can be removed. + +**Sign-up does not sign the user in.** The registration flow completes without issuing an +assertion, so the app returns to the landing screen with the account created but no session. The +sign-up flow therefore signs in afterwards with the credentials it just registered, which is also +what proves the new account actually works. + +**Tokens survive `clearState`.** They are stored in the Keychain, which lives outside the app +container and outlives both a state reset and a reinstall. A previous run can leave the app +signed in, so every flow starts with the `ensure-signed-out` subflow rather than assuming a clean +device. + +**These flows run on iOS, not Android.** The sample's iOS target carries an +`NSAllowsArbitraryLoads` exemption so it accepts the server's self-signed localhost certificate. +Its Android target has no equivalent, and the Flutter SDK exposes no `allowInsecureConnections` +option of its own (the native Android SDK has one, but the plugin never forwards it), so the +Android side cannot currently reach a self-signed local server at all. Running these flows on +Android needs that gap closed first. + +**Identifiers reach the accessibility tree through `Semantics`, not `Key`.** A Flutter widget key +is internal to the framework tree and invisible to anything driving the app from outside, so the +SDK sets `Semantics.identifier` alongside the key. It resolves the field's server-side +`identifier` rather than its `ref`, which is what keeps one set of selectors working across iOS, +Android and Flutter. + +**`npx thunderid` cannot be used in CI.** It renders an interactive TUI and aborts with +`bubbletea: could not open TTY` whenever stdout is not a terminal. `run-e2e.sh` downloads the +release directly and calls the distribution's own `setup.sh` and `start.sh`, which take the same +arguments non-interactively. diff --git a/tests/e2e/flows/config.yaml b/tests/e2e/flows/config.yaml new file mode 100644 index 0000000..f59cbe1 --- /dev/null +++ b/tests/e2e/flows/config.yaml @@ -0,0 +1,6 @@ +# Maestro workspace configuration for the Flutter Quickstart E2E suite. +# +# Only the top-level flows are test entry points. Without this, `maestro test .maestro/` would +# also recurse into subflows/ and run the shared building blocks as if they were tests. +flows: + - "*.yaml" diff --git a/tests/e2e/flows/signin.yaml b/tests/e2e/flows/signin.yaml new file mode 100644 index 0000000..7c01cb9 --- /dev/null +++ b/tests/e2e/flows/signin.yaml @@ -0,0 +1,49 @@ +appId: dev.thunderid.Quickstart +name: Sign in and sign out +tags: + - auth +env: + E2E_USERNAME: e2e_mobile_user + E2E_PASSWORD: TestPassword@123 +--- +- launchApp: + clearState: true +- runFlow: subflows/ensure-signed-out.yaml + +- tapOn: "Sign in" + +# The credentials step is rendered from the flow definition the server returns, not from static +# native views. The SDK tags each input with the field's identifier and each button with the +# action's ref, so these ids track the server's flow response. +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 20000 + +- tapOn: + id: "thunderid-field-username" +- inputText: ${E2E_USERNAME} + +- tapOn: + id: "thunderid-field-password" +- inputText: ${E2E_PASSWORD} + +- tapOn: + id: "thunderid-action-action_001" + +- runFlow: subflows/dismiss-save-password.yaml + +# Authenticated state. +- extendedWaitUntil: + visible: "Session active" + timeout: 30000 + +# ...and back out again, so the flow leaves the app as it found it. +- scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 +- tapOn: "Sign out" +- extendedWaitUntil: + visible: "Get started" + timeout: 20000 diff --git a/tests/e2e/flows/signup.yaml b/tests/e2e/flows/signup.yaml new file mode 100644 index 0000000..90ef569 --- /dev/null +++ b/tests/e2e/flows/signup.yaml @@ -0,0 +1,91 @@ +appId: dev.thunderid.Quickstart +name: Sign up a new user, then sign in as them +tags: + - auth +env: + E2E_PASSWORD: TestPassword@123 +--- +- launchApp: + clearState: true +- runFlow: subflows/ensure-signed-out.yaml + +# Registration has to claim a username that does not exist yet, so each run generates its own. +# This leaves a user behind on the target server per run, which is fine for a disposable CI +# instance; against a long-lived dev server, expect the accounts to accumulate. +- evalScript: ${output.username = 'e2e_signup_' + Date.now()} + +- tapOn: "Get started" + +# The registration step is server-rendered like the sign-in step, but its flow definition uses +# different action refs, so the submit button id differs from signin.yaml. +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 20000 + +- tapOn: + id: "thunderid-field-username" +- inputText: ${output.username} + +- tapOn: + id: "thunderid-field-password" +- inputText: ${E2E_PASSWORD} + +- tapOn: + id: "thunderid-action-action_credentials" + +- runFlow: subflows/dismiss-save-password.yaml + +# Registration is multi-step: the credentials step is followed by a step collecting the +# attributes the user schema requires (email here). Each step is a fresh server-rendered view +# with its own action ref. +- extendedWaitUntil: + visible: + id: "thunderid-field-email" + timeout: 20000 + +- tapOn: + id: "thunderid-field-email" +- inputText: ${output.username}@example.com + +- tapOn: + id: "thunderid-action-action_schema_attrs" + +# Registration completes without issuing an assertion, so the user is created but NOT signed in +# and the app falls back to the landing screen. Signing in with the credentials just registered +# is what proves the account is actually usable. +- extendedWaitUntil: + visible: "Get started" + timeout: 30000 + +- tapOn: "Sign in" +- extendedWaitUntil: + visible: + id: "thunderid-field-username" + timeout: 20000 + +- tapOn: + id: "thunderid-field-username" +- inputText: ${output.username} + +- tapOn: + id: "thunderid-field-password" +- inputText: ${E2E_PASSWORD} + +- tapOn: + id: "thunderid-action-action_001" + +- runFlow: subflows/dismiss-save-password.yaml + +- extendedWaitUntil: + visible: "Session active" + timeout: 30000 + +- scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 +- tapOn: "Sign out" +- extendedWaitUntil: + visible: "Get started" + timeout: 20000 diff --git a/tests/e2e/flows/subflows/dismiss-save-password.yaml b/tests/e2e/flows/subflows/dismiss-save-password.yaml new file mode 100644 index 0000000..de1d2fe --- /dev/null +++ b/tests/e2e/flows/subflows/dismiss-save-password.yaml @@ -0,0 +1,12 @@ +appId: dev.thunderid.Quickstart +--- +# iOS offers to store submitted credentials in the Keychain ("Save Password?") after a password +# field is submitted. It is a system alert drawn over the app, so it swallows the taps and +# assertions that follow a sign-in or sign-up. It is also not guaranteed to appear - whether iOS +# prompts depends on prior AutoFill state - so it has to be dismissed conditionally rather than +# unconditionally waited for. +- runFlow: + when: + visible: "Save Password?" + commands: + - tapOn: "Not Now" diff --git a/tests/e2e/flows/subflows/ensure-signed-out.yaml b/tests/e2e/flows/subflows/ensure-signed-out.yaml new file mode 100644 index 0000000..058ce38 --- /dev/null +++ b/tests/e2e/flows/subflows/ensure-signed-out.yaml @@ -0,0 +1,26 @@ +appId: dev.thunderid.Quickstart +--- +# Bring the app to the unauthenticated landing screen, whatever state it starts in. +# +# The SDK persists tokens in the Keychain, which is outside the app container and therefore +# survives both `clearState: true` and an app reinstall. A previous flow can consequently leave +# the app authenticated, so an explicit sign-out is the only reliable way to get back to a known +# starting point on a device that is not freshly created. +- runFlow: + when: + visible: "Session active" + commands: + - scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 20000 + - tapOn: "Sign out" + - extendedWaitUntil: + visible: "Get started" + timeout: 20000 + +# A bare assertVisible relies on Maestro's short default lookup timeout, which is not always +# enough for the landing screen to render on a loaded CI emulator/simulator - wait explicitly. +- extendedWaitUntil: + visible: "Sign in" + timeout: 20000 diff --git a/tests/e2e/run-e2e.ps1 b/tests/e2e/run-e2e.ps1 new file mode 100644 index 0000000..58020c2 --- /dev/null +++ b/tests/e2e/run-e2e.ps1 @@ -0,0 +1,369 @@ +<# +.SYNOPSIS + Run the Flutter Quickstart E2E suite end to end on Windows, against an Android emulator. + +.DESCRIPTION + Starts a ThunderID server, provisions the test application and user, builds and installs the + sample, then drives it with Maestro. + + This is the Windows path, and it drives Android. Building for iOS requires Xcode, so + run-e2e.sh on macOS is the only way to exercise the iOS side. Everything else, the Dart + layer, the widgets and the Android bridge, is covered here. + + Reaching a local server over its self-signed certificate relies on + ThunderIDConfig.allowInsecureConnections, which the sample enables for debug builds only and + the native SDK honours for loopback hosts only. + + Every stage is idempotent, so re-running is safe and is the normal way to iterate. + +.PARAMETER SkipServer + Skip starting and provisioning; the server is already up and provisioned. + +.PARAMETER SkipBuild + Skip building and installing; the sample is already on the device. + +.PARAMETER SkipTest + Set up everything but do not run Maestro. + +.PARAMETER MaestroArgs + Passed through to Maestro, so a specific flow path or extra options both work. + +.EXAMPLE + .\run-e2e.ps1 + +.EXAMPLE + .\run-e2e.ps1 -SkipServer -SkipBuild + +.NOTES + Environment variables, all optional: + THUNDERID_VERSION Release to run, without the leading "v" (default: latest release) + SERVER_URL Where the server is reachable (default https://localhost:8090) + ADMIN_USERNAME Admin user to bootstrap (default admin) + ADMIN_PASSWORD Admin password (default admin) + E2E_USERNAME Test user to create (default e2e_mobile_user) + E2E_PASSWORD Test user password (default TestPassword@123) + INSTALL_DIR Where to unpack the distribution (default .\.thunderid-server) + + Requires PowerShell 7 or later, plus the Flutter SDK and a running Android emulator. +#> + +[CmdletBinding()] +param( + [switch]$SkipServer, + [switch]$SkipBuild, + [switch]$SkipTest, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$MaestroArgs +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "PowerShell 7 or later is required (found $($PSVersionTable.PSVersion)). Install it with: winget install Microsoft.PowerShell" +} + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Get-EnvOrDefault([string]$Name, [string]$Default) { + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { return $Default } + return $value +} + +$ServerUrl = Get-EnvOrDefault 'SERVER_URL' 'https://localhost:8090' +$AdminUser = Get-EnvOrDefault 'ADMIN_USERNAME' 'admin' +$AdminPass = Get-EnvOrDefault 'ADMIN_PASSWORD' 'admin' +$E2eUser = Get-EnvOrDefault 'E2E_USERNAME' 'e2e_mobile_user' +$E2ePass = Get-EnvOrDefault 'E2E_PASSWORD' 'TestPassword@123' +$InstallDir = Get-EnvOrDefault 'INSTALL_DIR' (Join-Path $ScriptDir '.thunderid-server') + +# The sample owns its own application config, not the test script. +$ConfigFile = Join-Path $ScriptDir '../../samples/quickstart/thunderid-config/thunderid-config.yaml' + +# Must match the `id` in $ConfigFile and the application ID the sample is built with. +$AppId = '019e5b10-1001-7a2b-9c3d-4e5f60718293' + +# Every call goes to a host with a self-signed certificate. +$PSDefaultParameterValues['Invoke-RestMethod:SkipCertificateCheck'] = $true +$PSDefaultParameterValues['Invoke-WebRequest:SkipCertificateCheck'] = $true + +# ------------------------------------------------------------------------------------------- +# Start +# ------------------------------------------------------------------------------------------- +function Start-ThunderIDServer { + # Reuse a server that is already serving. All three mobile SDK suites bind the same port, so + # failing here would mean tearing down a perfectly good server just to start an identical one. + # Provisioning runs regardless and is idempotent, so the reused server still ends up correct. + try { + Invoke-WebRequest -Uri "$ServerUrl/health/liveness" -TimeoutSec 3 -UseBasicParsing | Out-Null + Write-Host "==> A server is already serving at $ServerUrl, reusing it" + return + } catch { + # Nothing listening, carry on and start one. + } + + $arch = switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'x64' } + 'ARM64' { 'arm64' } + default { throw "Unsupported architecture $($env:PROCESSOR_ARCHITECTURE)." } + } + if ($arch -eq 'arm64') { + # Only win-x64 is published; it runs under emulation on ARM64 Windows. + Write-Host '==> No win-arm64 build is published, using win-x64 under emulation' + $arch = 'x64' + } + + $version = $env:THUNDERID_VERSION + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Host '==> Resolving the latest ThunderID release' + $release = Invoke-RestMethod 'https://api.github.com/repos/thunder-id/thunderid/releases/latest' + $version = $release.tag_name -replace '^v', '' + if ([string]::IsNullOrWhiteSpace($version)) { + throw 'Could not resolve the latest release (rate limited?). Set THUNDERID_VERSION.' + } + } + + $archive = "thunderid-$version-win-$arch.zip" + $url = "https://github.com/thunder-id/thunderid/releases/download/v$version/$archive" + $distHome = Join-Path $InstallDir "thunderid-$version-win-$arch" + + if (-not (Test-Path $distHome)) { + Write-Host "==> Downloading $archive" + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + $zipPath = Join-Path $InstallDir $archive + Invoke-WebRequest -Uri $url -OutFile $zipPath + Expand-Archive -Path $zipPath -DestinationPath $InstallDir -Force + } + + Write-Host '==> Running first-time setup' + & (Join-Path $distHome 'setup.ps1') -AdminUsername $AdminUser -AdminPassword $AdminPass + if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed with exit code $LASTEXITCODE." } + + Write-Host '==> Starting the server' + # Start-Process detaches the server from this session, so it outlives the script the same way + # setsid does on Linux. + $logPath = Join-Path $InstallDir 'server.log' + Start-Process -FilePath 'pwsh' ` + -ArgumentList '-NoProfile', '-File', (Join-Path $distHome 'start.ps1') ` + -WorkingDirectory $distHome ` + -RedirectStandardOutput $logPath ` + -RedirectStandardError (Join-Path $InstallDir 'server.err.log') ` + -WindowStyle Hidden | Out-Null + + Write-Host "==> Waiting for $ServerUrl to accept connections" + foreach ($i in 1..120) { + try { + Invoke-WebRequest -Uri "$ServerUrl/health/liveness" -TimeoutSec 3 -UseBasicParsing | Out-Null + Write-Host ' up' + return + } catch { + Start-Sleep -Seconds 2 + } + } + + Write-Host 'ERROR: the server did not come up within 240s. Last 50 log lines:' -ForegroundColor Red + if (Test-Path $logPath) { Get-Content $logPath -Tail 50 } + throw 'Server did not start.' +} + +# ------------------------------------------------------------------------------------------- +# Admin token +# +# /applications and /users require a bearer token; the Direct-Auth-Secret header does not apply +# to them (it only gates /auth/, /register/passkey/ and /access/). Mirrors mint_admin_token() in +# the product's tests/e2e/run-e2e.sh: the CONSOLE client runs an authorization-code + PKCE +# exchange, whose credentials step is submitted over the Flow Execution API. +# ------------------------------------------------------------------------------------------- +function Get-AdminToken { + $redirectUri = "$ServerUrl/console" + + $bytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + $verifier = (($bytes | ForEach-Object { $_.ToString('x2') }) -join '').Substring(0, 43) + + $sha = [System.Security.Cryptography.SHA256]::Create() + $hash = $sha.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($verifier)) + $challenge = [Convert]::ToBase64String($hash).Replace('+', '-').Replace('/', '_').TrimEnd('=') + + $query = @( + 'client_id=CONSOLE' + "redirect_uri=$([uri]::EscapeDataString($redirectUri))" + 'scope=system' + "resource=$([uri]::EscapeDataString("$ServerUrl/mcp"))" + 'response_type=code' + "code_challenge=$challenge" + 'code_challenge_method=S256' + ) -join '&' + + $authorize = Invoke-WebRequest -Uri "$ServerUrl/oauth2/authorize?$query" ` + -MaximumRedirection 0 -SkipHttpErrorCheck -ErrorAction SilentlyContinue + $location = $authorize.Headers['Location'] + if ($location -is [array]) { $location = $location[0] } + if ([string]::IsNullOrWhiteSpace($location)) { + throw 'The authorize request returned no Location header.' + } + + $authId = [regex]::Match($location, '[?&]authId=([^&]*)').Groups[1].Value + $execId = [regex]::Match($location, '[?&]executionId=([^&]*)').Groups[1].Value + if (-not $authId -or -not $execId) { + throw "Could not parse authId/executionId from the authorize redirect. Location: $location" + } + + # The console login flow runs an SSO check ahead of the credentials prompt. This is a fresh, + # cookie-less login, so the first call advances past that check and mints a challenge token; + # the second submits the admin credentials with it. + $prompt = Invoke-RestMethod -Method Post -Uri "$ServerUrl/flow/execute" ` + -ContentType 'application/json' -Body (@{executionId = $execId} | ConvertTo-Json) + if (-not $prompt.challengeToken) { throw 'Flow execution returned no challenge token.' } + + $flow = Invoke-RestMethod -Method Post -Uri "$ServerUrl/flow/execute" ` + -ContentType 'application/json' -Body (@{ + executionId = $execId + challengeToken = $prompt.challengeToken + action = 'action_001' + inputs = @{username = $AdminUser; password = $AdminPass} + } | ConvertTo-Json) + if (-not $flow.assertion) { throw 'Admin login returned no assertion.' } + + $callback = Invoke-RestMethod -Method Post -Uri "$ServerUrl/oauth2/auth/callback" ` + -ContentType 'application/json' ` + -Body (@{authId = $authId; assertion = $flow.assertion} | ConvertTo-Json) + $code = [regex]::Match([string]$callback.redirect_uri, '[?&]code=([^&]*)').Groups[1].Value + if (-not $code) { throw 'The OAuth2 callback returned no authorization code.' } + + $token = Invoke-RestMethod -Method Post -Uri "$ServerUrl/oauth2/token" ` + -ContentType 'application/x-www-form-urlencoded' -Body @{ + grant_type = 'authorization_code' + code = $code + redirect_uri = $redirectUri + client_id = 'CONSOLE' + resource = "$ServerUrl/mcp" + code_verifier = $verifier + } + if (-not $token.access_token) { throw 'The token endpoint returned no access token.' } + return $token.access_token +} + +# ------------------------------------------------------------------------------------------- +# Provision +# ------------------------------------------------------------------------------------------- +function Invoke-Provision { + Write-Host "==> Obtaining admin token from $ServerUrl" + $adminToken = Get-AdminToken + $authHeader = @{Authorization = "Bearer $adminToken"} + + Write-Host '==> Importing the E2E application' + $configYaml = Get-Content $ConfigFile -Raw + $import = Invoke-RestMethod -Method Post -Uri "$ServerUrl/import" -Headers $authHeader ` + -ContentType 'application/json' ` + -Body (@{content = $configYaml; options = @{upsert = $true}} | ConvertTo-Json) + if ($import.summary.failed -ne 0) { + throw "Import failed: $($import | ConvertTo-Json -Depth 5)" + } + + # POST /import drops the attestation block (see the note in $ConfigFile), so without + # this the app stores attestation: null and /flow/execute rejects the sample with FES-1016. + # Re-applying it over PUT is the only way to get devMode persisted today. + Write-Host '==> Re-applying attestation devMode over PUT (import drops it)' + $app = Invoke-RestMethod -Uri "$ServerUrl/applications/$AppId" -Headers $authHeader + $app | Add-Member -NotePropertyName attestation -NotePropertyValue @{devMode = $true} -Force + $put = Invoke-RestMethod -Method Put -Uri "$ServerUrl/applications/$AppId" -Headers $authHeader ` + -ContentType 'application/json' -Body ($app | ConvertTo-Json -Depth 10) + if (-not $put.attestation.devMode) { + throw 'Attestation devMode did not persist.' + } + + Write-Host "==> Ensuring test user '$E2eUser' exists" + # The filter grammar accepts exactly one `attribute eq "value"` clause. + $filter = [uri]::EscapeDataString("username eq ""$E2eUser""") + $existing = Invoke-RestMethod -Uri "$ServerUrl/users?filter=$filter" -Headers $authHeader + if ($existing.users -and $existing.users.Count -gt 0) { + Write-Host ' already present, leaving it as is' + } else { + $types = Invoke-RestMethod -Uri "$ServerUrl/user-types" -Headers $authHeader + $ouId = ($types.types | Where-Object {$_.name -eq 'Person'}).ouId + if (-not $ouId) { throw 'Could not resolve the ouId of the Person user type.' } + + $created = Invoke-RestMethod -Method Post -Uri "$ServerUrl/users" -Headers $authHeader ` + -ContentType 'application/json' -Body (@{ + ouId = $ouId + type = 'Person' + attributes = @{ + username = $E2eUser + password = $E2ePass + email = "$E2eUser@example.com" + given_name = 'E2E' + family_name = 'Mobile' + } + } | ConvertTo-Json -Depth 5) + if (-not $created.id) { throw 'Failed to create the test user.' } + Write-Host ' created' + } +} + +# ------------------------------------------------------------------------------------------- +# Build and install the sample +# ------------------------------------------------------------------------------------------- +function Build-Sample { + $sampleDir = Join-Path $ScriptDir '../../samples/quickstart' | Resolve-Path + + Write-Host '==> Configuring the sample' + # 10.0.2.2 is the emulator's alias for the host loopback, where the server is listening. It + # is also one of the loopback hosts the native SDK will relax certificate validation for. + @( + 'THUNDERID_BASE_URL=https://10.0.2.2:8090' + "THUNDERID_APP_ID=$AppId" + 'THUNDERID_ATTESTATION_ENABLED=false' + 'THUNDERID_CLOUD_PROJECT_NUMBER=' + ) | Set-Content -Path (Join-Path $sampleDir '.env') -Encoding utf8 + + Write-Host '==> Building and installing the sample' + Push-Location $sampleDir + try { + & flutter pub get + if ($LASTEXITCODE -ne 0) { throw "flutter pub get failed with exit code $LASTEXITCODE." } + & flutter build apk --debug + if ($LASTEXITCODE -ne 0) { throw "flutter build apk failed with exit code $LASTEXITCODE." } + & adb install -r 'build/app/outputs/flutter-apk/app-debug.apk' + if ($LASTEXITCODE -ne 0) { throw "adb install failed with exit code $LASTEXITCODE." } + } finally { + Pop-Location + } +} + +# ------------------------------------------------------------------------------------------- +# Run the flows +# ------------------------------------------------------------------------------------------- +function Invoke-Flows { + if (-not (Get-Command maestro -ErrorAction SilentlyContinue)) { + throw 'maestro is not installed. See https://maestro.mobile.dev/getting-started/installing-maestro' + } + # Default to the whole suite when no flow path was passed through. + $target = if ($MaestroArgs) { $MaestroArgs } else { @('flows/') } + + Write-Host '==> Running Maestro' + Push-Location $ScriptDir + try { + # The JUnit report is what makes a failed run readable without scraping the console log; + # it sits alongside Maestro's own debug output and CI collects both. + & maestro --platform android test @target ` + --format=JUNIT --output=report.xml ` + -e "E2E_USERNAME=$E2eUser" -e "E2E_PASSWORD=$E2ePass" + if ($LASTEXITCODE -ne 0) { throw "Maestro reported failures (exit code $LASTEXITCODE)." } + } finally { + Pop-Location + } +} + +if (-not $SkipServer) { + Start-ThunderIDServer + Invoke-Provision + Write-Host '' + Write-Host " server : $ServerUrl" + Write-Host " application id : $AppId" + Write-Host " test user : $E2eUser" + Write-Host '' +} +if (-not $SkipBuild) { Build-Sample } +if (-not $SkipTest) { Invoke-Flows } diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh new file mode 100755 index 0000000..d6c8006 --- /dev/null +++ b/tests/e2e/run-e2e.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# +# Run the Flutter Quickstart E2E suite end to end: start a ThunderID server, provision the test +# application and user, build and install the sample, then drive it with Maestro. +# +# Every stage is idempotent, so re-running is safe and is the normal way to iterate. +# +# Usage: +# ./run-e2e.sh Everything +# ./run-e2e.sh --skip-server Server already running and provisioned +# ./run-e2e.sh --skip-build Sample already installed on the device +# ./run-e2e.sh flows/signin.yaml Run one flow instead of the whole suite +# +# Any argument that is not a recognised flag is passed through to Maestro, so extra Maestro +# options and a specific flow path both work. +# +# Environment: +# THUNDERID_VERSION Release to run, without the leading "v" (default: latest release) +# SERVER_URL Where the server is reachable (default https://localhost:8090) +# ADMIN_USERNAME Admin user to bootstrap (default admin) +# ADMIN_PASSWORD Admin password (default admin) +# E2E_USERNAME Test user to create (default e2e_mobile_user) +# E2E_PASSWORD Test user password (default TestPassword@123) +# INSTALL_DIR Where to unpack the distribution (default ./.thunderid-server) +# +# Why not `npx thunderid`? That wrapper renders an interactive TUI and aborts with +# "bubbletea: could not open TTY" when stdout is not a terminal, which is always the case on a CI +# runner. The distribution's own setup.sh/start.sh take the same arguments non-interactively. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SERVER_URL="${SERVER_URL:-https://localhost:8090}" +ADMIN_USER="${ADMIN_USERNAME:-admin}" +ADMIN_PASS="${ADMIN_PASSWORD:-admin}" +E2E_USER="${E2E_USERNAME:-e2e_mobile_user}" +E2E_PASS="${E2E_PASSWORD:-TestPassword@123}" +INSTALL_DIR="${INSTALL_DIR:-$SCRIPT_DIR/.thunderid-server}" + +# The sample owns its own application config, not the test script. +CONFIG_FILE="$SCRIPT_DIR/../../samples/quickstart/thunderid-config/thunderid-config.yaml" + +# Must match the `id` in $CONFIG_FILE and the application ID the sample is built with. +APP_ID="019e5b10-1001-7a2b-9c3d-4e5f60718293" + +do_server=true +do_build=true +do_test=true +maestro_args=() +for arg in "$@"; do + case "$arg" in + --skip-server) do_server=false ;; + --skip-build) do_build=false ;; + --skip-test) do_test=false ;; + -h | --help) sed -n '2,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) maestro_args+=("$arg") ;; + esac +done + +for tool in curl jq openssl; do + command -v "$tool" >/dev/null 2>&1 || { echo "ERROR: $tool is required but not installed." >&2; exit 1; } +done + +# --------------------------------------------------------------------------------------------- +# Start +# --------------------------------------------------------------------------------------------- +start_server() { + # Reuse a server that is already serving. All three mobile SDK suites bind the same port, so + # failing here would mean tearing down a perfectly good server just to start an identical one. + # Provisioning runs regardless and is idempotent, so the reused server still ends up correct. + if curl -sk -o /dev/null --max-time 3 "$SERVER_URL/health/liveness" 2>/dev/null; then + echo "==> A server is already serving at $SERVER_URL, reusing it" + return 0 + fi + + command -v unzip >/dev/null 2>&1 || { echo "ERROR: unzip is required but not installed." >&2; exit 1; } + + local pkg_os pkg_arch + case "$(uname -s)" in + Darwin) pkg_os="macos" ;; + Linux) pkg_os="linux" ;; + *) echo "ERROR: unsupported OS $(uname -s)." >&2; exit 1 ;; + esac + case "$(uname -m)" in + arm64 | aarch64) pkg_arch="arm64" ;; + x86_64 | amd64) pkg_arch="x64" ;; + *) echo "ERROR: unsupported architecture $(uname -m)." >&2; exit 1 ;; + esac + + local version="${THUNDERID_VERSION:-}" + if [ -z "$version" ]; then + echo "==> Resolving the latest ThunderID release" + version=$(curl -sSL https://api.github.com/repos/thunder-id/thunderid/releases/latest | + jq -r '.tag_name // empty' | sed 's/^v//') + if [ -z "$version" ]; then + echo "ERROR: could not resolve the latest release (rate limited?). Set THUNDERID_VERSION." >&2 + exit 1 + fi + fi + + local archive="thunderid-${version}-${pkg_os}-${pkg_arch}.zip" + local url="https://github.com/thunder-id/thunderid/releases/download/v${version}/${archive}" + local dist_home="$INSTALL_DIR/thunderid-${version}-${pkg_os}-${pkg_arch}" + + if [ ! -d "$dist_home" ]; then + echo "==> Downloading $archive" + mkdir -p "$INSTALL_DIR" + curl -sSLf "$url" -o "$INSTALL_DIR/$archive" + unzip -q "$INSTALL_DIR/$archive" -d "$INSTALL_DIR" + fi + + echo "==> Running first-time setup" + (cd "$dist_home" && ./setup.sh --admin-username "$ADMIN_USER" --admin-password "$ADMIN_PASS") + + echo "==> Starting the server" + # The server has to outlive the step that starts it, which means leaving this process group: + # `nohup` alone only ignores SIGHUP, so anything that signals the group still takes the server + # down with it. setsid does that but does not exist on macOS, so fall back to Python, whose + # start_new_session flag calls setsid in the child. + if command -v setsid >/dev/null 2>&1; then + (cd "$dist_home" && setsid ./start.sh > "$INSTALL_DIR/server.log" 2>&1 < /dev/null &) + else + python3 - "$dist_home" "$INSTALL_DIR/server.log" <<'PY' +import subprocess +import sys + +dist_home, log_path = sys.argv[1], sys.argv[2] +with open(log_path, "ab") as log: + subprocess.Popen( + ["./start.sh"], + cwd=dist_home, + stdout=log, + stderr=log, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) +PY + fi + + echo "==> Waiting for $SERVER_URL to accept connections" + # The server serves a self-signed certificate on localhost, hence -k. + local i + for i in $(seq 1 120); do + if curl -sk -o /dev/null --max-time 3 "$SERVER_URL/health/liveness"; then + echo " up" + return 0 + fi + sleep 2 + done + + echo "ERROR: the server did not come up within 240s. Last 50 log lines:" >&2 + tail -50 "$INSTALL_DIR/server.log" >&2 || true + exit 1 +} + +# --------------------------------------------------------------------------------------------- +# Admin token +# +# /applications and /users require a bearer token; the Direct-Auth-Secret header does not apply +# to them (it only gates /auth/, /register/passkey/ and /access/). Mirrors mint_admin_token() in +# the product's tests/e2e/run-e2e.sh: the CONSOLE client runs an authorization-code + PKCE +# exchange, whose credentials step is submitted over the Flow Execution API. +# --------------------------------------------------------------------------------------------- +mint_admin_token() { + local redirect_uri="$SERVER_URL/console" + local verifier challenge + verifier=$(openssl rand -hex 32 | cut -c1-43) + challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') + + local headers_file location auth_id exec_id + headers_file=$(mktemp) + curl -sk -o /dev/null -D "$headers_file" \ + -G "$SERVER_URL/oauth2/authorize" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "scope=system" \ + --data-urlencode "resource=$SERVER_URL/mcp" \ + --data-urlencode "response_type=code" \ + --data-urlencode "code_challenge=$challenge" \ + --data-urlencode "code_challenge_method=S256" + location=$(grep -i "^location:" "$headers_file" | tr -d '\r' | sed 's/^[Ll]ocation: //' || true) + rm -f "$headers_file" + + auth_id=$(sed -n 's/.*[?&]authId=\([^&]*\).*/\1/p' <<<"$location") + exec_id=$(sed -n 's/.*[?&]executionId=\([^&]*\).*/\1/p' <<<"$location") + if [ -z "$auth_id" ] || [ -z "$exec_id" ]; then + echo "ERROR: could not parse authId/executionId from the authorize redirect." >&2 + echo "Location: $location" >&2 + exit 1 + fi + + # The console login flow runs an SSO check ahead of the credentials prompt. This is a fresh, + # cookie-less login, so the first call advances past that check and mints a challenge token; + # the second submits the admin credentials with it. + local prompt_resp challenge_token flow_resp assertion + prompt_resp=$(curl -sk -X POST "$SERVER_URL/flow/execute" \ + -H "Content-Type: application/json" \ + -d "{\"executionId\": \"$exec_id\"}") + challenge_token=$(jq -r '.challengeToken // empty' <<<"$prompt_resp") + if [ -z "$challenge_token" ]; then + echo "ERROR: no challenge token. Response: $prompt_resp" >&2 + exit 1 + fi + + flow_resp=$(curl -sk -X POST "$SERVER_URL/flow/execute" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg executionId "$exec_id" \ + --arg challengeToken "$challenge_token" \ + --arg username "$ADMIN_USER" \ + --arg password "$ADMIN_PASS" \ + '{executionId: $executionId, challengeToken: $challengeToken, action: "action_001", + inputs: {username: $username, password: $password}}')") + assertion=$(jq -r '.assertion // empty' <<<"$flow_resp") + if [ -z "$assertion" ]; then + echo "ERROR: admin login returned no assertion. Response: $flow_resp" >&2 + exit 1 + fi + + local callback_resp auth_code token_resp + callback_resp=$(curl -sk -X POST "$SERVER_URL/oauth2/auth/callback" \ + -H "Content-Type: application/json" \ + -d "{\"authId\": \"$auth_id\", \"assertion\": \"$assertion\"}") + auth_code=$(jq -r '.redirect_uri // empty' <<<"$callback_resp" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p') + if [ -z "$auth_code" ]; then + echo "ERROR: callback returned no authorization code. Response: $callback_resp" >&2 + exit 1 + fi + + token_resp=$(curl -sk -X POST "$SERVER_URL/oauth2/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code=$auth_code" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "resource=$SERVER_URL/mcp" \ + --data-urlencode "code_verifier=$verifier") + ADMIN_TOKEN=$(jq -r '.access_token // empty' <<<"$token_resp") + if [ -z "$ADMIN_TOKEN" ]; then + echo "ERROR: token endpoint returned no access token. Response: $token_resp" >&2 + exit 1 + fi +} + +# --------------------------------------------------------------------------------------------- +# Provision +# --------------------------------------------------------------------------------------------- +provision() { + echo "==> Obtaining admin token from $SERVER_URL" + mint_admin_token + + echo "==> Importing the E2E application" + local import_resp + import_resp=$(jq -n --arg content "$(cat "$CONFIG_FILE")" \ + '{content: $content, options: {upsert: true}}' | + curl -sk -X POST "$SERVER_URL/import" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d @-) + if [ "$(jq -r '.summary.failed // 1' <<<"$import_resp")" != "0" ]; then + echo "ERROR: import failed. Response: $import_resp" >&2 + exit 1 + fi + + # POST /import drops the attestation block (see the note in $CONFIG_FILE), so without + # this the app stores attestation: null and /flow/execute rejects the sample with FES-1016. + # Re-applying it over PUT is the only way to get devMode persisted today. + echo "==> Re-applying attestation devMode over PUT (import drops it)" + local app_json updated put_resp + app_json=$(curl -sk "$SERVER_URL/applications/$APP_ID" -H "Authorization: Bearer $ADMIN_TOKEN") + updated=$(jq '.attestation = {devMode: true}' <<<"$app_json") + put_resp=$(curl -sk -X PUT "$SERVER_URL/applications/$APP_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$updated") + if [ "$(jq -r '.attestation.devMode // false' <<<"$put_resp")" != "true" ]; then + echo "ERROR: attestation devMode did not persist. Response: $put_resp" >&2 + exit 1 + fi + + echo "==> Ensuring test user '$E2E_USER' exists" + # The filter grammar accepts exactly one `attribute eq "value"` clause. + local filter existing ou_id create_resp + filter=$(printf 'username eq "%s"' "$E2E_USER" | jq -sRr @uri) + existing=$(curl -sk "$SERVER_URL/users?filter=$filter" -H "Authorization: Bearer $ADMIN_TOKEN") + if [ "$(jq -r '(.users // []) | length' <<<"$existing")" -gt 0 ]; then + echo " already present, leaving it as is" + else + ou_id=$(curl -sk "$SERVER_URL/user-types" -H "Authorization: Bearer $ADMIN_TOKEN" | + jq -r '.types[] | select(.name == "Person") | .ouId') + if [ -z "$ou_id" ] || [ "$ou_id" = "null" ]; then + echo "ERROR: could not resolve the ouId of the Person user type." >&2 + exit 1 + fi + create_resp=$(curl -sk -X POST "$SERVER_URL/users" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg u "$E2E_USER" --arg p "$E2E_PASS" --arg ou "$ou_id" \ + '{ouId: $ou, type: "Person", + attributes: {username: $u, password: $p, email: ($u + "@example.com"), + given_name: "E2E", family_name: "Mobile"}}')") + if [ -z "$(jq -r '.id // empty' <<<"$create_resp")" ]; then + echo "ERROR: failed to create the test user. Response: $create_resp" >&2 + exit 1 + fi + echo " created" + fi +} + +# --------------------------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------------------------- +# Resolve a booted simulator, booting one if necessary, so a local run and a CI run take the same +# path. Which iPhone models exist depends on the installed Xcode, so prefer SIMULATOR_NAME but +# fall back to whatever iPhone is available. +resolve_simulator() { + SIM_UDID=$(xcrun simctl list devices booted | grep -oE "[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}" | head -1 || true) + if [ -n "$SIM_UDID" ]; then + echo "==> Using the booted simulator $SIM_UDID" + return 0 + fi + + local wanted="${SIMULATOR_NAME:-iPhone 17}" + SIM_UDID=$(xcrun simctl list devices available | grep -m1 "$wanted (" | + grep -oE "[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}" || true) + if [ -z "$SIM_UDID" ]; then + SIM_UDID=$(xcrun simctl list devices available | grep -m1 -E "^[[:space:]]+iPhone " | + grep -oE "[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}" || true) + fi + if [ -z "$SIM_UDID" ]; then + echo "ERROR: no available iPhone simulator." >&2 + xcrun simctl list devices available >&2 + exit 1 + fi + + echo "==> Booting simulator $SIM_UDID" + xcrun simctl boot "$SIM_UDID" + xcrun simctl bootstatus "$SIM_UDID" -b +} + +# --------------------------------------------------------------------------------------------- +# Build and install the sample +# --------------------------------------------------------------------------------------------- +build_sample() { + local sample_dir="$SCRIPT_DIR/../../samples/quickstart" + + echo "==> Configuring the sample" + # These flows run on iOS: the sample's iOS target carries an NSAllowsArbitraryLoads exemption + # so it accepts the server's self-signed certificate, while its Android target has no + # equivalent and the plugin exposes no allowInsecureConnections option of its own. + cat > "$sample_dir/.env" < Building and installing the sample" + (cd "$sample_dir" && flutter pub get && flutter build ios --debug --simulator) + xcrun simctl install "$SIM_UDID" \ + "$sample_dir/build/ios/iphonesimulator/Runner.app" +} + +# --------------------------------------------------------------------------------------------- +# Run the flows +# --------------------------------------------------------------------------------------------- +run_flows() { + command -v maestro >/dev/null 2>&1 || { + echo "ERROR: maestro is not installed. See https://maestro.mobile.dev/getting-started/installing-maestro" >&2 + exit 1 + } + # Default to the whole suite when no flow path was passed through. Checking the length before + # expanding matters: under `set -u`, bash 3.2 (still the default on macOS) treats expanding an + # empty array as an unbound variable. + local target + if [ "${#maestro_args[@]}" -eq 0 ]; then + target=("flows/") + else + target=("${maestro_args[@]}") + fi + + echo "==> Running Maestro" + # The JUnit report is what makes a failed run readable without scraping the console log; it + # sits alongside Maestro's own debug output and CI collects both. + (cd "$SCRIPT_DIR" && maestro --device "$SIM_UDID" test "${target[@]}" \ + --format=JUNIT \ + --output=report.xml \ + -e E2E_USERNAME="$E2E_USER" \ + -e E2E_PASSWORD="$E2E_PASS") +} + +if $do_server; then + start_server + provision + echo + echo " server : $SERVER_URL" + echo " application id : $APP_ID" + echo " test user : $E2E_USER" + echo +fi +if $do_build || $do_test; then resolve_simulator; fi +$do_build && build_sample +$do_test && run_flows