Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/actions/run-e2e-suite/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 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
description: Build the Quickstart sample and drive it with Maestro against a real ThunderID server

inputs:
thunderid-version:
description: ThunderID release to test against, without the leading "v". Defaults to the latest release.
required: false
default: ""
artifact-suffix:
description: Appended to the debug artifact name, so concurrent callers do not collide.
required: false
default: ""

runs:
using: composite
steps:
- 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
43 changes: 43 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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
runs-on: macos-latest
timeout-minutes: 45
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: 🔬 Run E2E Suite
uses: ./.github/actions/run-e2e-suite
with:
thunderid-version: ${{ inputs.thunderid-version }}
artifact-suffix: -nightly
12 changes: 12 additions & 0 deletions .github/workflows/pr-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,15 @@ 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' }}
runs-on: macos-latest
timeout-minutes: 45
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: 🔬 Run E2E Suite
uses: ./.github/actions/run-e2e-suite
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,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
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,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
Expand Down
15 changes: 15 additions & 0 deletions lib/src/models/thunderid_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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;

Expand All @@ -61,6 +74,7 @@ class ThunderIDConfig {
this.applicationId,
this.organizationHandle,
this.attestationEnabled = false,
this.allowInsecureConnections = false,
this.cloudProjectNumber,
this.tokenValidation = const TokenValidationConfig(),
this.preferences,
Expand All @@ -81,6 +95,7 @@ class ThunderIDConfig {
if (applicationId != null) 'applicationId': applicationId,
if (organizationHandle != null) 'organizationHandle': organizationHandle,
'attestationEnabled': attestationEnabled,
'allowInsecureConnections': allowInsecureConnections,
if (cloudProjectNumber != null) 'cloudProjectNumber': cloudProjectNumber,
'tokenValidation': tokenValidation.toMap(),
if (preferences != null) 'preferences': preferences!.toMap(),
Expand Down
80 changes: 52 additions & 28 deletions lib/src/widgets/flow_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -387,20 +387,28 @@ class _FlowFormState extends State<FlowForm> {
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,
),
);
}
Expand Down Expand Up @@ -491,21 +499,26 @@ class _FlowFormState extends State<FlowForm> {

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),
),
),
);
}
Expand Down Expand Up @@ -613,6 +626,17 @@ class _FlowFormState extends State<FlowForm> {
),
);

/// 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<String, dynamic> comp) => _str(
comp['identifier'],
fallback: _str(comp['name'], fallback: _fieldRef(comp)),
);

String _inputRef(Map<String, dynamic> input) => _str(
input['name'],
fallback: _str(
Expand Down
6 changes: 4 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading