Skip to content
Merged
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
19 changes: 16 additions & 3 deletions expo/app/src/steps/use-smile-id-sample-sdk-flow-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <UseSmileIDSampleLeave onLeave={toProducts} />;
if (preflight?.kind === 'needsSession') {
Expand All @@ -174,10 +180,10 @@ export const UseSmileIDSampleSdkFlowStep = ({ scope }: { scope: UseSmileIDSample
<UseSmileIDSampleLeave onLeave={() => 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 <RecordBlocked reason={reason} run={runOf(snapshot)} onBlocked={toProducts} />;
return <RecordBlocked reason={reason} run={runOf(snapshot)} onBlocked={toUnsubmittedDetails} />;
}
if (preflight?.kind !== 'ready') return <UseSmileIDSampleLeave onLeave={toProducts} />;
if (!analyzersReady) return null;
Expand All @@ -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,
Expand Down Expand Up @@ -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';

Expand Down
22 changes: 21 additions & 1 deletion expo/app/test/use-smile-id-sample-flows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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'));
Expand Down
68 changes: 68 additions & 0 deletions expo/maestro/settings.yaml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions expo/maestro/token-session.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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<UseSmileIDValidationException> issues,
):
Expand All @@ -248,7 +251,7 @@ class _UseSmileIDSampleSdkFlowTabState
.map((UseSmileIDValidationException it) => it.message)
.join('; '),
);
_leave();
_land(unsubmittedJobId);
}
}

Expand All @@ -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<JobSubmissionResponse>():
case UseSmileIDCancelled<JobSubmissionResponse>():
case UseSmileIDCaptured<JobSubmissionResponse>():
_land(unsubmittedJobId);
case UseSmileIDCancelled<JobSubmissionResponse>():
_leave();
}
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ void main() {
container.read(useSmileIDSampleSettingsProvider);

Future<void> 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();
}
Expand Down
37 changes: 37 additions & 0 deletions flutter/app/test/use_smileid_sample_token_session_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -497,6 +501,7 @@ void main() {
const UseSmileIDSampleSessionRecord(),
String? at,
int Function()? clock,
UseSmileIDSampleSettings settings = const UseSmileIDSampleSettings(),
}) async {
await tester.pumpWidget(
ProviderScope(
Expand All @@ -506,6 +511,7 @@ void main() {
UseSmileIDSampleMemorySessionRepository(stored),
),
useSmileIDSampleStoredSessionProvider.overrideWithValue(stored),
useSmileIDSampleStoredSettingsProvider.overrideWithValue(settings),
if (clock != null)
useSmileIDSampleWallClockProvider.overrideWithValue(clock),
],
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion flutter/maestro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion flutter/maestro/document-options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion flutter/maestro/profile-journey.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ appId: ${APP_ID}
- tapOn: "Deny"
- extendedWaitUntil:
visible:
id: "sample_products_screen"
id: "sample_verification_details_screen"
timeout: 30000

- runFlow:
Expand Down
Loading
Loading