From 6bcb70435e19ccb363cc02b145e919c20bf40f48 Mon Sep 17 00:00:00 2001 From: Allan Juma Date: Mon, 26 Oct 2026 08:45:27 -0700 Subject: [PATCH] fix(flutter, expo): a refused or failed run lands on its unsubmitted details, as on Android and iOS --- .../use-smile-id-sample-sdk-flow-step.tsx | 19 ++++- .../test/use-smile-id-sample-flows.test.tsx | 22 +++++- expo/maestro/settings.yaml | 68 ++++++++++++++++ expo/maestro/token-session.yaml | 26 +++++++ .../use_smileid_sample_sdk_flow_tab.dart | 28 ++++--- ...e_smileid_sample_settings_sheets_test.dart | 4 +- ...use_smileid_sample_token_session_test.dart | 37 +++++++++ flutter/maestro/README.md | 6 +- flutter/maestro/document-options.yaml | 2 +- flutter/maestro/profile-journey.yaml | 2 +- flutter/maestro/sdk-flow.yaml | 19 ++++- flutter/maestro/settings.yaml | 77 +++++++++++++++++++ .../use_smileid_sample_settings_screen.dart | 22 ++++-- ...e_smileid_sample_settings_screen_test.dart | 53 +++++++++++++ .../UITests/UseSmileIDSampleFlowUITests.swift | 15 +++- .../UseSmileIDSampleSettingsSeed.swift | 10 +++ 16 files changed, 381 insertions(+), 29 deletions(-) create mode 100644 expo/maestro/settings.yaml create mode 100644 flutter/maestro/settings.yaml create mode 100644 flutter/sample_ui/test/use_smileid_sample_settings_screen_test.dart diff --git a/expo/app/src/steps/use-smile-id-sample-sdk-flow-step.tsx b/expo/app/src/steps/use-smile-id-sample-sdk-flow-step.tsx index 151189307..a591de5e5 100644 --- a/expo/app/src/steps/use-smile-id-sample-sdk-flow-step.tsx +++ b/expo/app/src/steps/use-smile-id-sample-sdk-flow-step.tsx @@ -156,6 +156,12 @@ export const UseSmileIDSampleSdkFlowStep = ({ scope }: { scope: UseSmileIDSample left.current = true; toProducts(); }; + // Where the SDK's own failure lands, so the reason is on screen rather than a dead tap. + const toUnsubmittedDetails = () => { + if (left.current) return; + left.current = true; + smileIDSampleLeaveRun(engine, scope.entry, 'verificationDetails', unsubmittedJobId); + }; if (snapshot === null) return ; if (preflight?.kind === 'needsSession') { @@ -174,10 +180,10 @@ export const UseSmileIDSampleSdkFlowStep = ({ scope }: { scope: UseSmileIDSample smileIDSampleLeaveRun(engine, scope.entry, 'consentDetailsForm', productId)} /> ); } - // No form fixes a misconfigured builder, and it must still never reach the SDK. + // No form fixes a misconfigured builder, and validate() gives build()'s verdict, so it never reaches the SDK. if (preflight?.kind === 'misconfigured') { const reason = preflight.issues.map((issue) => issue.message).join('; ') || strings.flowInvalid; - return ; + return ; } if (preflight?.kind !== 'ready') return ; if (!analyzersReady) return null; @@ -199,10 +205,14 @@ export const UseSmileIDSampleSdkFlowStep = ({ scope }: { scope: UseSmileIDSample if (snapshot.scenario === 'throwingCallback') { throw new Error('throwingCallback scenario: the host result callback throws'); } - if (result.status !== 'success') { + if (result.status === 'cancelled') { leave(); return; } + if (result.status !== 'success') { + toUnsubmittedDetails(); + return; + } // Held by the store, not this screen: the write must outlive a same-frame teardown. void addJob({ id: result.value.jobId, @@ -230,6 +240,9 @@ export const UseSmileIDSampleSdkFlowStep = ({ scope }: { scope: UseSmileIDSample const acceptedCode = 202; +/// The details entry for a run that never produced a job: the screen shows the run's result instead. +const unsubmittedJobId = 'unsubmitted'; + /// Recorded when the SDK hands back a captured job instead of submitting it. const capturedNotSubmitted = 'Captured, not submitted'; diff --git a/expo/app/test/use-smile-id-sample-flows.test.tsx b/expo/app/test/use-smile-id-sample-flows.test.tsx index bd1e83647..83caaf8db 100644 --- a/expo/app/test/use-smile-id-sample-flows.test.tsx +++ b/expo/app/test/use-smile-id-sample-flows.test.tsx @@ -567,7 +567,7 @@ describe('how a run ends', () => { return launch(path); }; - it('goes back to products when the SDK fails or is cancelled', async () => { + it('goes back to products when the SDK run is cancelled', async () => { const { engine } = await liveRun('flow/smartSelfieEnrollment/run', true); expect(smileIDSampleTopKind(engine())).toBe('sdkFlowFullScreen'); await waitFor(() => expect(mockOnResult).not.toBeNull()); @@ -576,6 +576,26 @@ describe('how a run ends', () => { expect(smileIDSampleStackKinds(engine())).toEqual(['products']); }); + it('lands a failed run on the unsubmitted details, where its reason shows', async () => { + const { engine } = await liveRun('flow/smartSelfieEnrollment/run', true); + await waitFor(() => expect(mockOnResult).not.toBeNull()); + await act(async () => mockOnResult!({ status: 'failure', error: new Error('network down') })); + await settle(); + expect(smileIDSampleTopKind(engine())).toBe('verificationDetails'); + expect(engine().state.entries.at(-1)?.arg).toBe('unsubmitted'); + expect(useSmileIDSampleResultStore.getState().result.lastError).toBe('network down'); + }); + + it('lands a run the SDK would refuse on the unsubmitted details, and never hands it to the SDK', async () => { + await act(async () => useSmileIDSampleSettingsStore.getState().setSetting('consentStep', false)); + const { engine } = await liveRun('flow/smartSelfieEnrollment/run'); + await waitFor(() => expect(smileIDSampleTopKind(engine())).toBe('verificationDetails')); + expect(engine().state.entries.at(-1)?.arg).toBe('unsubmitted'); + expect(useSmileIDSampleResultStore.getState().result.jobStatus).toBe('failed'); + expect(useSmileIDSampleResultStore.getState().result.lastError).not.toBeNull(); + expect(mockOnResult).toBeNull(); + }); + it("sends Auth's run back for the user ID it has no value for", async () => { const { engine } = await liveRun('flow/smartSelfieAuth/run', true); await waitFor(() => expect(smileIDSampleTopKind(engine())).toBe('authUserId')); diff --git a/expo/maestro/settings.yaml b/expo/maestro/settings.yaml new file mode 100644 index 000000000..deaff58c1 --- /dev/null +++ b/expo/maestro/settings.yaml @@ -0,0 +1,68 @@ +appId: ${APP_ID} +--- +# The consent switch, and the validation failure it is meant to demonstrate. Android's twin is +# ../../android/maestro/settings.yaml; Flutter's is ../../flutter/maestro/settings.yaml. +# +# No consent screen and no bound consent: the flow is refused before the SDK mounts, since the +# builder's validate() returns build()'s verdict. The run lands on the unsubmitted job's details, +# where the SDK's own failure lands, with the reason on its result card. A return to the product +# list instead reads as a dead tap, which is what this flow exists to catch. +# +# The switch persists, so the flow turns it back on, and proves the pair: with consent on, the +# same link reaches the SDK's consent screen. It types nothing, for the reason sdk-flow.yaml gives. +# Launch arguments arrive as a LINK on this platform — see ./README.md "Traps". + +- clearState +- stopApp +- openLink: "usesmileid-sample-expo://?probes=true&catalogue=fixture" +- runFlow: subflows/warm-start.yaml +- runFlow: subflows/link-session.yaml + +# --- consent off: the run is refused, and the reason is on screen --------------------------------- +- tapOn: + id: "sample_nav_settings" +- extendedWaitUntil: + visible: + id: "sample_settings_screen" + timeout: 30000 +- tapOn: + id: "sample_setting_consent_step" +- assertVisible: + id: "sample_setting_consent_step" + checked: false +# A cold link straight at the run: no form to type through, and the product needs no ID details. +- stopApp +- openLink: "usesmileid-sample-expo:///flow/smartSelfieEnrollment/run?probes=true&catalogue=fixture" +- extendedWaitUntil: + visible: + id: "sample_verification_details_screen" + timeout: 60000 +- assertVisible: + id: "sample_result_job_status" + text: "failed" +- assertVisible: + id: "sample_result_last_error" +- assertNotVisible: + id: "si_consent_screen" + +# --- consent back on: the same link reaches the SDK ------------------------------------------------ +- stopApp +- openLink: "usesmileid-sample-expo://?probes=true&catalogue=fixture" +- runFlow: subflows/warm-start.yaml +- tapOn: + id: "sample_nav_settings" +- extendedWaitUntil: + visible: + id: "sample_settings_screen" + timeout: 30000 +- tapOn: + id: "sample_setting_consent_step" +- assertVisible: + id: "sample_setting_consent_step" + checked: true +- stopApp +- openLink: "usesmileid-sample-expo:///flow/smartSelfieEnrollment/run?catalogue=fixture" +- extendedWaitUntil: + visible: + id: "si_consent_screen" + timeout: 60000 diff --git a/expo/maestro/token-session.yaml b/expo/maestro/token-session.yaml index eb284bc9c..13ee8ba3e 100644 --- a/expo/maestro/token-session.yaml +++ b/expo/maestro/token-session.yaml @@ -115,6 +115,19 @@ appId: ${APP_ID} visible: "Exit" timeout: 30000 - tapOn: "Exit" +# The run failed on the fixture token, so it lands on the unsubmitted job's details, as on Android and iOS. +- extendedWaitUntil: + visible: + id: "sample_verification_details_screen" + timeout: 30000 +# The details are the verifications tab's, so back reaches that tab's root, then the bar returns to Products. +- back +- extendedWaitUntil: + visible: + id: "sample_verifications_screen" + timeout: 30000 +- tapOn: + id: "sample_nav_products" - extendedWaitUntil: visible: id: "sample_products_screen" @@ -154,6 +167,19 @@ appId: ${APP_ID} visible: "Exit" timeout: 30000 - tapOn: "Exit" +# The run failed on the fixture token, so it lands on the unsubmitted job's details, as on Android and iOS. +- extendedWaitUntil: + visible: + id: "sample_verification_details_screen" + timeout: 30000 +# The details are the verifications tab's, so back reaches that tab's root, then the bar returns to Products. +- back +- extendedWaitUntil: + visible: + id: "sample_verifications_screen" + timeout: 30000 +- tapOn: + id: "sample_nav_products" - extendedWaitUntil: visible: id: "sample_products_screen" diff --git a/flutter/app/lib/src/screens/use_smileid_sample_sdk_flow_tab.dart b/flutter/app/lib/src/screens/use_smileid_sample_sdk_flow_tab.dart index 26cccc244..9b7b75207 100644 --- a/flutter/app/lib/src/screens/use_smileid_sample_sdk_flow_tab.dart +++ b/flutter/app/lib/src/screens/use_smileid_sample_sdk_flow_tab.dart @@ -16,6 +16,9 @@ import '../state/use_smileid_sample_providers.dart'; import '../state/use_smileid_sample_session_providers.dart'; import '../use_smileid_sample_shell_extension.dart'; +/// The details entry for a run that never produced a job: the screen shows the run's result instead. +const String unsubmittedJobId = 'unsubmitted'; + /// The single route hosting the SDK flow. The SDK owns everything inside it: no host chrome, no host back. class UseSmileIDSampleSdkFlowTab extends ConsumerStatefulWidget { /// [productId] comes from the run's first entry and decides the whole journey. @@ -36,7 +39,7 @@ class UseSmileIDSampleSdkFlowTab extends ConsumerStatefulWidget { /// Whether the run covers the shell or sits inside it, which its step decides. final UseSmileIDSampleFlowRoute route; - /// Where a cancelled, failed or never-started run lands. + /// Where a cancelled or never-started run lands. final VoidCallback onLeave; /// Where a run the forms can still fix goes instead of the SDK. @@ -48,7 +51,7 @@ class UseSmileIDSampleSdkFlowTab extends ConsumerStatefulWidget { /// Where SmartSelfie Authentication goes with no user ID to run as. final VoidCallback onNeedsUserId; - /// Where a delivered result lands, by the job id the server issued. + /// Where a run lands: the job id the server issued, or [unsubmittedJobId] for one that failed first. final void Function(String jobId) onResult; @override @@ -236,7 +239,7 @@ class _UseSmileIDSampleSdkFlowTabState ), ); widget.onNeedsSession(); - // No form fixes this, and it must still never reach the SDK. + // No form fixes this, and validate() gives build()'s verdict, so it never reaches the SDK. case UseSmileIDSampleFlowMisconfigured( :final List issues, ): @@ -248,7 +251,7 @@ class _UseSmileIDSampleSdkFlowTabState .map((UseSmileIDValidationException it) => it.message) .join('; '), ); - _leave(); + _land(unsubmittedJobId); } } @@ -262,13 +265,11 @@ class _UseSmileIDSampleSdkFlowTabState ): // Held by the provider, not this widget: the write must outlive a same-frame teardown. unawaited(_jobs.addJob(_processingJob(snapshot, value))); - if (!_left) { - _left = true; - widget.onResult(value.jobId); - } + _land(value.jobId); case UseSmileIDFailure(): - case UseSmileIDCancelled(): case UseSmileIDCaptured(): + _land(unsubmittedJobId); + case UseSmileIDCancelled(): _leave(); } } @@ -308,6 +309,15 @@ class _UseSmileIDSampleSdkFlowTabState widget.onLeave(); } + /// Lands on a job's details, so a failed or refused run shows its reason rather than a dead tap. + void _land(String jobId) { + if (_left) { + return; + } + _left = true; + widget.onResult(jobId); + } + UseSmileIDSampleJob _processingJob( UseSmileIDSampleFlowLaunchSnapshot snapshot, JobSubmissionResponse response, diff --git a/flutter/app/test/use_smileid_sample_settings_sheets_test.dart b/flutter/app/test/use_smileid_sample_settings_sheets_test.dart index ad2703c43..0f2acc441 100644 --- a/flutter/app/test/use_smileid_sample_settings_sheets_test.dart +++ b/flutter/app/test/use_smileid_sample_settings_sheets_test.dart @@ -50,7 +50,9 @@ void main() { container.read(useSmileIDSampleSettingsProvider); Future pick(WidgetTester tester, String id) async { - await tester.ensureVisible(byId(id)); + // Centred: a drag that starts on a tappable row loses its touch slop, so a scrolled-to row can sit at the edge. + await Scrollable.ensureVisible(tester.element(byId(id)), alignment: 0.5); + await tester.pumpAndSettle(); await tester.tap(byId(id)); await tester.pumpAndSettle(); } diff --git a/flutter/app/test/use_smileid_sample_token_session_test.dart b/flutter/app/test/use_smileid_sample_token_session_test.dart index 320ce7bcc..35432c415 100644 --- a/flutter/app/test/use_smileid_sample_token_session_test.dart +++ b/flutter/app/test/use_smileid_sample_token_session_test.dart @@ -14,9 +14,13 @@ import 'package:usesmileid_sample_flutter/src/flow/use_smileid_sample_token_bind import 'package:usesmileid_sample_flutter/src/navigation/use_smileid_sample_navigation.dart'; import 'package:usesmileid_sample_flutter/src/navigation/use_smileid_sample_njia.dart'; import 'package:usesmileid_sample_flutter/src/navigation/use_smileid_sample_njia_facts.dart'; +import 'package:usesmileid_sample_flutter/src/screens/use_smileid_sample_sdk_flow_tab.dart'; +import 'package:usesmileid_sample_flutter/src/state/use_smileid_sample_flow_result_provider.dart'; +import 'package:usesmileid_sample_flutter/src/state/use_smileid_sample_providers.dart'; import 'package:usesmileid_sample_flutter/src/state/use_smileid_sample_session_providers.dart'; import 'package:usesmileid_sample_flutter/src/status/use_smileid_sample_http_job_status_source.dart'; import 'support/use_smileid_sample_test_njia.dart'; +import 'support/use_smileid_sample_test_session.dart'; /// The token session on the host side. void main() { @@ -497,6 +501,7 @@ void main() { const UseSmileIDSampleSessionRecord(), String? at, int Function()? clock, + UseSmileIDSampleSettings settings = const UseSmileIDSampleSettings(), }) async { await tester.pumpWidget( ProviderScope( @@ -506,6 +511,7 @@ void main() { UseSmileIDSampleMemorySessionRepository(stored), ), useSmileIDSampleStoredSessionProvider.overrideWithValue(stored), + useSmileIDSampleStoredSettingsProvider.overrideWithValue(settings), if (clock != null) useSmileIDSampleWallClockProvider.overrideWithValue(clock), ], @@ -667,6 +673,37 @@ void main() { }, ); + testWidgets( + 'a run the SDK would refuse lands on the unsubmitted details, and never reaches the SDK', + (WidgetTester tester) async { + final ProviderContainer container = await pump( + tester, + settings: const UseSmileIDSampleSettings(consentStep: false), + stored: UseSmileIDSampleSessionRecord( + live: useSmileIDSampleTestSession(), + ), + at: UseSmileIDSampleRoutes.sdkFlow( + UseSmileIDSampleProduct.smartSelfieEnrollment.id, + ), + ); + await tester.pumpAndSettle(); + + expect( + useSmileIDSampleTestTop(tester), + UseSmileIDSampleStep.verificationDetails, + ); + expect( + useSmileIDSampleTestEngine(tester).state.top!.arg, + unsubmittedJobId, + ); + final UseSmileIDSampleResult result = container.read( + useSmileIDSampleFlowResultProvider, + ); + expect(result.jobStatus, UseSmileIDSampleFlowStatus.failed); + expect(result.lastError, isNotNull); + }, + ); + testWidgets('an ended session sends a run to the scanner, which says why', ( WidgetTester tester, ) async { diff --git a/flutter/maestro/README.md b/flutter/maestro/README.md index 24fc902af..59a08ebc2 100644 --- a/flutter/maestro/README.md +++ b/flutter/maestro/README.md @@ -15,7 +15,7 @@ and the AOT snapshot for all four ABIs is wasted minutes when only one of them i ## What this lane is for -Eight flows, not a port of the Android suite — that one was measured at 36–53 minutes returning +Nine flows, not a port of the Android suite — that one was measured at 36–53 minutes returning nothing across four runs. These cover what a **hostless** Flutter test structurally cannot reach: - **`licenses.yaml`** — `LicenseRegistry` is empty under `flutter test` by design, so no widget test @@ -34,6 +34,10 @@ nothing across four runs. These cover what a **hostless** Flutter test structura - **`document-options.yaml`** — the document form against `catalogue=fixture`: the Green Book as its own row, "Capture as" and the generic-document sheet through SDK mount, the `unreachable` error and Retry, Enhanced Document Verification's partner list, and the capture-mode sheet in Settings. A widget test has no route from a real product tap to the SDK. +- **`settings.yaml`** — the consent switch, as in the Android twin's `settings.yaml`: with no consent + screen and no bound consent the run is refused before the SDK, and it must land on the unsubmitted + job's details with the reason on its result card, not on the product list. A widget test pumps no + release build, and a refused run looked like a dead tap on a handset while every widget test passed. ## Traps diff --git a/flutter/maestro/document-options.yaml b/flutter/maestro/document-options.yaml index b1f352017..09f77f5df 100644 --- a/flutter/maestro/document-options.yaml +++ b/flutter/maestro/document-options.yaml @@ -130,7 +130,7 @@ appId: ${APP_ID} - tapOn: "Deny" - extendedWaitUntil: visible: - id: "sample_products_screen" + id: "sample_verification_details_screen" timeout: 30000 # --- the list failing ------------------------------------------------------------------------------- diff --git a/flutter/maestro/profile-journey.yaml b/flutter/maestro/profile-journey.yaml index 1eed71adc..00db4ffcf 100644 --- a/flutter/maestro/profile-journey.yaml +++ b/flutter/maestro/profile-journey.yaml @@ -43,7 +43,7 @@ appId: ${APP_ID} - tapOn: "Deny" - extendedWaitUntil: visible: - id: "sample_products_screen" + id: "sample_verification_details_screen" timeout: 30000 - runFlow: diff --git a/flutter/maestro/sdk-flow.yaml b/flutter/maestro/sdk-flow.yaml index c9f45e110..3e04e9103 100644 --- a/flutter/maestro/sdk-flow.yaml +++ b/flutter/maestro/sdk-flow.yaml @@ -67,9 +67,24 @@ appId: ${APP_ID} - assertNotVisible: id: "sample_nav_products" -# Deny is the failure exit, and the back gesture is the cancel one. Both return to the originating -# tab rather than into the wizard beneath, which is what `go` past the stack buys. +# Deny is the failure exit: it lands on the unsubmitted job's details, as on Android and iOS, with the +# reason on the result card. The back gesture is the cancel exit, which returns to the originating tab. - tapOn: "Deny" +- extendedWaitUntil: + visible: + id: "sample_verification_details_screen" + timeout: 30000 +- assertVisible: + id: "sample_result_job_status" + text: "failed" +# The details are the verifications tab's, so back reaches that tab's root, then the bar returns to Products. +- back +- extendedWaitUntil: + visible: + id: "sample_verifications_screen" + timeout: 30000 +- tapOn: + id: "sample_nav_products" - extendedWaitUntil: visible: id: "sample_products_screen" diff --git a/flutter/maestro/settings.yaml b/flutter/maestro/settings.yaml new file mode 100644 index 000000000..b2fba8116 --- /dev/null +++ b/flutter/maestro/settings.yaml @@ -0,0 +1,77 @@ +appId: ${APP_ID} +--- +# The consent switch, and the validation failure it is meant to demonstrate. Android's twin is +# ../../android/maestro/settings.yaml; Expo's is ../../expo/maestro/settings.yaml. +# +# No consent screen and no bound consent: the flow is refused before the SDK mounts, since the +# builder's validate() returns build()'s verdict. The run lands on the unsubmitted job's details, +# where the SDK's own failure lands, with the reason on its result card. A return to the product +# list instead reads as a dead tap, which is what this flow exists to catch. +# +# The switch persists, so the flow turns it back on, and proves the pair: with consent on, the +# same link reaches the SDK's consent screen. + +- clearState +- runFlow: + file: subflows/cold-start.yaml + env: + # `probes` puts the result card on a release build, which is what this lane installs. + LINK: "usesmileid-sample-flutter://?probes=true&catalogue=fixture" + LANDS_ON: "sample_products_screen" +- runFlow: subflows/link-session.yaml + +# --- consent off: the run is refused, and the reason is on screen --------------------------------- +- tapOn: + id: "sample_nav_settings" +- extendedWaitUntil: + visible: + id: "sample_settings_screen" + timeout: 30000 +# Settled before the tap: the tab animates in, and a tap measured mid-animation lands on no row. +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: "sample_setting_consent_step" +- assertVisible: + id: "sample_setting_consent_step" + checked: false +# A cold link straight at the run: no form to type through, and the product needs no ID details. +- runFlow: + file: subflows/cold-start.yaml + env: + LINK: "usesmileid-sample-flutter://flow/smartSelfieEnrollment/run?probes=true&catalogue=fixture" + LANDS_ON: "sample_verification_details_screen" +- assertVisible: + id: "sample_result_job_status" + text: "failed" +- assertVisible: + id: "sample_result_last_error" +- assertNotVisible: + id: "si_consent_screen" + +# --- consent back on: the same link reaches the SDK ------------------------------------------------ +- runFlow: + file: subflows/cold-start.yaml + env: + LINK: "usesmileid-sample-flutter://?probes=true&catalogue=fixture" + LANDS_ON: "sample_products_screen" +- tapOn: + id: "sample_nav_settings" +- extendedWaitUntil: + visible: + id: "sample_settings_screen" + timeout: 30000 +# Settled before the tap: the tab animates in, and a tap measured mid-animation lands on no row. +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: "sample_setting_consent_step" +- assertVisible: + id: "sample_setting_consent_step" + checked: true +- stopApp +- openLink: "usesmileid-sample-flutter://flow/smartSelfieEnrollment/run?probes=true&catalogue=fixture" +- extendedWaitUntil: + visible: + id: "si_consent_screen" + timeout: 30000 diff --git a/flutter/sample_ui/lib/src/screens/use_smileid_sample_settings_screen.dart b/flutter/sample_ui/lib/src/screens/use_smileid_sample_settings_screen.dart index c91b98c52..32f7bb2c2 100644 --- a/flutter/sample_ui/lib/src/screens/use_smileid_sample_settings_screen.dart +++ b/flutter/sample_ui/lib/src/screens/use_smileid_sample_settings_screen.dart @@ -439,14 +439,20 @@ class UseSmileIDSampleSettingsScreen extends StatelessWidget { required String icon, required String supportingText, required UseSmileIDSampleSetting setting, - }) => UseSmileIDSampleSettingRow( - title: title, - supportingText: supportingText, - leading: (Color tint) => UseSmileIDSampleIcon(asset: icon, tint: tint), - testId: setting.testId, - trailing: UseSmileIDSampleSwitch( - value: state.settings[setting], - onChanged: (bool enabled) => onSettingChanged(setting, enabled), + }) => GestureDetector( + // The whole row flips the switch; hidden from semantics, so the row still reads as the switch it merges. + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: () => onSettingChanged(setting, !state.settings[setting]), + child: UseSmileIDSampleSettingRow( + title: title, + supportingText: supportingText, + leading: (Color tint) => UseSmileIDSampleIcon(asset: icon, tint: tint), + testId: setting.testId, + trailing: UseSmileIDSampleSwitch( + value: state.settings[setting], + onChanged: (bool enabled) => onSettingChanged(setting, enabled), + ), ), ); diff --git a/flutter/sample_ui/test/use_smileid_sample_settings_screen_test.dart b/flutter/sample_ui/test/use_smileid_sample_settings_screen_test.dart new file mode 100644 index 000000000..4f0474d1a --- /dev/null +++ b/flutter/sample_ui/test/use_smileid_sample_settings_screen_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sample_ui/sample_ui.dart'; + +/// The Settings switch rows: the whole row is the control, as on the other three apps and for a screen reader. +void main() { + Finder byId(String id) => find.bySemanticsIdentifier(id); + + testWidgets( + 'a tap anywhere on a switch row flips that setting, which still reads as a switch', + (WidgetTester tester) async { + final List<(UseSmileIDSampleSetting, bool)> changed = + <(UseSmileIDSampleSetting, bool)>[]; + await tester.pumpWidget( + MaterialApp( + theme: UseSmileIDSampleTheme.light(), + home: Scaffold( + body: UseSmileIDSampleSettingsScreen( + state: const UseSmileIDSampleSettingsState( + settings: UseSmileIDSampleSettings(), + organisation: 'Organisation', + initials: 'OR', + versionLabel: 'Smile ID · 1.0.0', + deviceDark: false, + ), + onSettingChanged: + (UseSmileIDSampleSetting setting, bool enabled) => + changed.add((setting, enabled)), + onProfileTap: () {}, + onNavRowTap: (UseSmileIDSampleNavRow _) {}, + onSignOut: () {}, + onCaptureModeTap: () {}, + onAppearanceTap: () {}, + onLanguageTap: () {}, + ), + ), + ), + ); + + final Finder row = byId(UseSmileIDSampleTestIds.settingConsentStep); + await tester.ensureVisible(row); + expect( + tester.getSemantics(row), + isSemantics(hasToggledState: true, isToggled: true, isButton: false), + ); + await tester.tapAt(tester.getTopLeft(row) + const Offset(120, 24)); + + expect(changed, <(UseSmileIDSampleSetting, bool)>[ + (UseSmileIDSampleSetting.consentStep, false), + ]); + }, + ); +} diff --git a/ios/App/UITests/UseSmileIDSampleFlowUITests.swift b/ios/App/UITests/UseSmileIDSampleFlowUITests.swift index 5b219d95f..e67a1e23e 100644 --- a/ios/App/UITests/UseSmileIDSampleFlowUITests.swift +++ b/ios/App/UITests/UseSmileIDSampleFlowUITests.swift @@ -62,6 +62,17 @@ final class UseSmileIDSampleFlowUITests: XCTestCase { XCTAssertTrue(element("sample_user_details_screen").waitForNonExistence(timeout: 5), "the form is still stacked") } + /// With no consent screen and no bound consent the SDK refuses the run, which lands where its own failure does, never on products. + func testARunTheSdkWouldRefuseLandsOnTheUnsubmittedDetailsAndNeverMountsIt() { + launch(seed: useSmileIDSampleLaunchSeed.useSmileIDSampleSetting("-consent_step", to: "false")) + app.useSmileIDSampleOpen("flow/smartSelfieEnrollment/run") + + XCTAssertTrue(element("sample_verification_details_screen").waitForExistence(timeout: 10), "the refused run did not land on its details") + XCTAssertEqual(element("sample_result_job_status").label, "failed", "a refusal is a failure, not a cancel") + XCTAssertTrue(element("sample_result_last_error").exists, "the refusal's reason is not on screen") + XCTAssertFalse(element("si_consent_screen").exists, "a refused run reached the SDK") + } + /// Needs a consent-binding token, since only instructions carries the SDK's back control; XCUITest cannot drive the edge swipe. func testBackingOutOfTheSdksFirstScreenCancelsAndCreatesNoJob() { launch() @@ -352,8 +363,8 @@ final class UseSmileIDSampleFlowUITests: XCTestCase { } /// Signs out whatever a previous test left, then links a plain session unless told not to: every run needs one. - private func launch(arguments: [String] = [], linking: Bool = true) { - app.launchArguments = useSmileIDSampleLaunchSeed + arguments + private func launch(seed: [String] = useSmileIDSampleLaunchSeed, arguments: [String] = [], linking: Bool = true) { + app.launchArguments = seed + arguments app.launch() atATabRoot() if element("sample_session_card").exists || element("sample_session_ended_banner").exists { diff --git a/ios/App/UITests/UseSmileIDSampleSettingsSeed.swift b/ios/App/UITests/UseSmileIDSampleSettingsSeed.swift index 4704ca727..b313625ff 100644 --- a/ios/App/UITests/UseSmileIDSampleSettingsSeed.swift +++ b/ios/App/UITests/UseSmileIDSampleSettingsSeed.swift @@ -16,3 +16,13 @@ let useSmileIDSampleSettingsSeed = [ "-allow_skip_back", "false", "-selfie_first", "false" ] + +extension [String] { + /// This seed with one store key's launch value replaced, so a launch can start with a switch the UI would otherwise turn. + func useSmileIDSampleSetting(_ key: String, to value: String) -> [String] { + guard let index = firstIndex(of: key), index + 1 < count else { return self + [key, value] } + var seed = self + seed[index + 1] = value + return seed + } +}