@smileid/usesmileid 12.0.1 → 12.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/dist/camera/camera_defaults.d.ts +2 -0
  4. package/dist/camera/camera_defaults.d.ts.map +1 -1
  5. package/dist/camera/camera_defaults.js +2 -0
  6. package/dist/camera/camera_defaults.js.map +1 -1
  7. package/dist/core/exception/flow_exceptions.d.ts +4 -2
  8. package/dist/core/exception/flow_exceptions.d.ts.map +1 -1
  9. package/dist/core/exception/flow_exceptions.js +3 -2
  10. package/dist/core/exception/flow_exceptions.js.map +1 -1
  11. package/dist/core/exception/missing_job_type_exception.d.ts +1 -3
  12. package/dist/core/exception/missing_job_type_exception.d.ts.map +1 -1
  13. package/dist/core/exception/missing_job_type_exception.js +2 -4
  14. package/dist/core/exception/missing_job_type_exception.js.map +1 -1
  15. package/dist/data/api/interceptors/smile_auth_interceptor.js +1 -1
  16. package/dist/data/api/interceptors/smile_auth_interceptor.js.map +1 -1
  17. package/dist/data/api/smile_id_api.d.ts +4 -0
  18. package/dist/data/api/smile_id_api.d.ts.map +1 -1
  19. package/dist/data/api/smile_id_api.js +30 -27
  20. package/dist/data/api/smile_id_api.js.map +1 -1
  21. package/dist/data/model/job_request.d.ts +28 -12
  22. package/dist/data/model/job_request.d.ts.map +1 -1
  23. package/dist/data/model/token_payload.d.ts +78 -0
  24. package/dist/data/model/token_payload.d.ts.map +1 -0
  25. package/dist/data/model/token_payload.js +132 -0
  26. package/dist/data/model/token_payload.js.map +1 -0
  27. package/dist/data/model/user_details_payload.d.ts +39 -0
  28. package/dist/data/model/user_details_payload.d.ts.map +1 -0
  29. package/dist/data/model/user_details_payload.js +53 -0
  30. package/dist/data/model/user_details_payload.js.map +1 -0
  31. package/dist/data/util/use_smile_id_token_decoder.d.ts +15 -3
  32. package/dist/data/util/use_smile_id_token_decoder.d.ts.map +1 -1
  33. package/dist/data/util/use_smile_id_token_decoder.js +20 -8
  34. package/dist/data/util/use_smile_id_token_decoder.js.map +1 -1
  35. package/dist/l10n/strings_en.d.ts +4 -1
  36. package/dist/l10n/strings_en.d.ts.map +1 -1
  37. package/dist/l10n/strings_en.js +4 -1
  38. package/dist/l10n/strings_en.js.map +1 -1
  39. package/dist/l10n/use_smile_id_localizations.d.ts +10 -0
  40. package/dist/l10n/use_smile_id_localizations.d.ts.map +1 -1
  41. package/dist/l10n/use_smile_id_localizations.js +59 -3
  42. package/dist/l10n/use_smile_id_localizations.js.map +1 -1
  43. package/dist/metadata/metadata_configuration_mappers.js +1 -1
  44. package/dist/metadata/metadata_configuration_mappers.js.map +1 -1
  45. package/dist/metadata/use_smile_id_metadata_factory.js +1 -1
  46. package/dist/metadata/use_smile_id_metadata_factory.js.map +1 -1
  47. package/dist/presentation/components/use_smile_id_button.d.ts.map +1 -1
  48. package/dist/presentation/components/use_smile_id_button.js +8 -2
  49. package/dist/presentation/components/use_smile_id_button.js.map +1 -1
  50. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts +3 -6
  51. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts.map +1 -1
  52. package/dist/presentation/flow/dsl/builder/screens_builder.js +4 -6
  53. package/dist/presentation/flow/dsl/builder/screens_builder.js.map +1 -1
  54. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.d.ts.map +1 -1
  55. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js +23 -1
  56. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js.map +1 -1
  57. package/dist/presentation/flow/flow_navigation_manager.d.ts +17 -2
  58. package/dist/presentation/flow/flow_navigation_manager.d.ts.map +1 -1
  59. package/dist/presentation/flow/flow_navigation_manager.js +77 -45
  60. package/dist/presentation/flow/flow_navigation_manager.js.map +1 -1
  61. package/dist/presentation/flow/screen/orchestrated_processing_screen.d.ts.map +1 -1
  62. package/dist/presentation/flow/screen/orchestrated_processing_screen.js +25 -5
  63. package/dist/presentation/flow/screen/orchestrated_processing_screen.js.map +1 -1
  64. package/dist/presentation/flow/use_smile_id_flow.d.ts.map +1 -1
  65. package/dist/presentation/flow/use_smile_id_flow.js +35 -8
  66. package/dist/presentation/flow/use_smile_id_flow.js.map +1 -1
  67. package/dist/presentation/flow/validation/flow_validator.d.ts +16 -2
  68. package/dist/presentation/flow/validation/flow_validator.d.ts.map +1 -1
  69. package/dist/presentation/flow/validation/flow_validator.js +19 -27
  70. package/dist/presentation/flow/validation/flow_validator.js.map +1 -1
  71. package/dist/presentation/flow/validation/job_type_validator.d.ts +7 -0
  72. package/dist/presentation/flow/validation/job_type_validator.d.ts.map +1 -1
  73. package/dist/presentation/flow/validation/job_type_validator.js +52 -27
  74. package/dist/presentation/flow/validation/job_type_validator.js.map +1 -1
  75. package/dist/presentation/flow/validation/user_details_validator.d.ts +9 -0
  76. package/dist/presentation/flow/validation/user_details_validator.d.ts.map +1 -0
  77. package/dist/presentation/flow/validation/user_details_validator.js +40 -0
  78. package/dist/presentation/flow/validation/user_details_validator.js.map +1 -0
  79. package/dist/presentation/screen/helpers/capture_screen_helpers.d.ts.map +1 -1
  80. package/dist/presentation/screen/helpers/capture_screen_helpers.js +1 -0
  81. package/dist/presentation/screen/helpers/capture_screen_helpers.js.map +1 -1
  82. package/dist/presentation/screen/use_smile_id_capture_screen.d.ts.map +1 -1
  83. package/dist/presentation/screen/use_smile_id_capture_screen.js +64 -18
  84. package/dist/presentation/screen/use_smile_id_capture_screen.js.map +1 -1
  85. package/package.json +3 -3
  86. package/src/__tests__/react_native_mock.tsx +22 -0
  87. package/src/__tests__/snapshots/__snapshots__/screens_structural.test.tsx.snap +14 -8
  88. package/src/camera/__tests__/camera_permissions.test.ts +72 -0
  89. package/src/camera/__tests__/camera_system_focus.test.ts +173 -0
  90. package/src/camera/__tests__/use_smile_id_camera_preview.test.tsx +461 -0
  91. package/src/camera/__tests__/use_smile_id_frame_stream_hook.test.tsx +229 -0
  92. package/src/camera/camera_defaults.ts +3 -0
  93. package/src/core/exception/__tests__/builder_validation_exceptions.test.ts +14 -0
  94. package/src/core/exception/flow_exceptions.test.ts +10 -0
  95. package/src/core/exception/flow_exceptions.ts +3 -2
  96. package/src/core/exception/missing_job_type_exception.ts +2 -4
  97. package/src/data/api/__tests__/smile_id_api.test.ts +275 -1
  98. package/src/data/api/interceptors/__tests__/credential_header_redaction.test.ts +144 -0
  99. package/src/data/api/interceptors/smile_auth_interceptor.ts +1 -1
  100. package/src/data/api/smile_id_api.ts +36 -49
  101. package/src/data/dsl/__tests__/use_smile_id_network_builder.test.ts +157 -0
  102. package/src/data/factory/__tests__/signed_job_submission_headers.test.ts +1 -1
  103. package/src/data/model/__tests__/token_payload.test.ts +224 -0
  104. package/src/data/model/__tests__/user_details_payload.test.ts +100 -0
  105. package/src/data/model/job_request.ts +27 -12
  106. package/src/data/model/token_payload.ts +182 -0
  107. package/src/data/model/user_details_payload.ts +84 -0
  108. package/src/data/repository/__tests__/network_retries.test.ts +1 -1
  109. package/src/data/repository/__tests__/repository_dispatch.test.ts +366 -0
  110. package/src/data/repository/__tests__/use_smile_id_repository.test.ts +1 -1
  111. package/src/data/security/__tests__/signing_crypto.test.ts +203 -0
  112. package/src/data/util/__tests__/use_smile_id_token_decoder.test.ts +179 -0
  113. package/src/data/util/use_smile_id_token_decoder.ts +29 -8
  114. package/src/l10n/__tests__/use_smile_id_localizations.test.ts +122 -5
  115. package/src/l10n/__tests__/use_smile_id_localizations_provider.test.tsx +112 -0
  116. package/src/l10n/__tests__/use_smile_id_localizations_surface.test.ts +231 -0
  117. package/src/l10n/strings_en.ts +5 -1
  118. package/src/l10n/use_smile_id_localizations.ts +80 -3
  119. package/src/metadata/__tests__/device_orientation_sampling.test.ts +165 -0
  120. package/src/metadata/__tests__/geolocation.test.ts +150 -0
  121. package/src/metadata/__tests__/metadata_configuration_objects.test.ts +1 -1
  122. package/src/metadata/__tests__/metadata_device_context.test.ts +3 -3
  123. package/src/metadata/__tests__/metadata_golden_fixture.json +1 -1
  124. package/src/metadata/__tests__/metadata_payload_golden.test.ts +4 -4
  125. package/src/metadata/__tests__/optional_native_modules.test.ts +234 -0
  126. package/src/metadata/__tests__/use_smile_id_metadata.test.ts +1 -1
  127. package/src/metadata/__tests__/use_smile_id_metadata_factory.test.ts +615 -0
  128. package/src/metadata/metadata_configuration_mappers.ts +1 -1
  129. package/src/metadata/use_smile_id_metadata_factory.ts +1 -1
  130. package/src/presentation/components/__tests__/use_smile_id_content_card.test.tsx +112 -0
  131. package/src/presentation/components/__tests__/use_smile_id_face_oval_image.test.tsx +190 -0
  132. package/src/presentation/components/__tests__/use_smile_id_learn_more_content.test.tsx +159 -0
  133. package/src/presentation/components/__tests__/use_smile_id_processing_center_indicator.test.tsx +88 -0
  134. package/src/presentation/components/use_smile_id_button.tsx +7 -2
  135. package/src/presentation/flow/__tests__/flow_navigation_manager.test.ts +323 -1
  136. package/src/presentation/flow/__tests__/use_smile_id_flow.test.tsx +651 -0
  137. package/src/presentation/flow/dsl/__tests__/use_smile_id_builder_component.test.tsx +120 -0
  138. package/src/presentation/flow/dsl/builder/screens_builder.ts +4 -6
  139. package/src/presentation/flow/dsl/use_smile_id_flow_builder.ts +27 -1
  140. package/src/presentation/flow/flow_navigation_manager.ts +102 -92
  141. package/src/presentation/flow/graph/__tests__/screen_renderer.test.tsx +102 -0
  142. package/src/presentation/flow/screen/__tests__/orchestrated_capture_screen.test.tsx +836 -0
  143. package/src/presentation/flow/screen/__tests__/orchestrated_consent_screen.test.tsx +165 -0
  144. package/src/presentation/flow/screen/__tests__/orchestrated_instructions_screen.test.tsx +163 -0
  145. package/src/presentation/flow/screen/__tests__/orchestrated_preview_screen.test.tsx +224 -0
  146. package/src/presentation/flow/screen/__tests__/orchestrated_processing_screen.test.tsx +338 -0
  147. package/src/presentation/flow/screen/orchestrated_processing_screen.tsx +42 -5
  148. package/src/presentation/flow/use_smile_id_flow.tsx +52 -8
  149. package/src/presentation/flow/validation/__tests__/flow_validator.test.ts +158 -0
  150. package/src/presentation/flow/validation/__tests__/job_type_validator.test.ts +200 -0
  151. package/src/presentation/flow/validation/flow_validator.ts +30 -31
  152. package/src/presentation/flow/validation/job_type_validator.ts +73 -39
  153. package/src/presentation/flow/validation/user_details_validator.ts +53 -0
  154. package/src/presentation/screen/__tests__/feedback_copy_table.test.ts +145 -0
  155. package/src/presentation/screen/__tests__/use_smile_id_capture_screen.test.tsx +348 -0
  156. package/src/presentation/screen/__tests__/use_smile_id_document_back_instructions.test.tsx +3 -1
  157. package/src/presentation/screen/__tests__/use_smile_id_permission_screen.test.tsx +68 -0
  158. package/src/presentation/screen/helpers/capture_screen_helpers.ts +1 -0
  159. package/src/presentation/screen/use_smile_id_capture_screen.tsx +69 -18
  160. package/src/presentation/theme/__tests__/use_use_smile_id_fonts.test.tsx +166 -0
@@ -0,0 +1,53 @@
1
+ import type { UserDetails } from "../../../core/models/params";
2
+ import { InvalidFieldValueException } from "../../../core/exception/job_validation_exceptions";
3
+ import type { TokenPayload } from "../../../data/model/token_payload";
4
+ import type { ValidationIssue } from "./validation_state";
5
+
6
+ /**
7
+ * Field-union rule shared by the content validator and the job-type required
8
+ * rule: a field is covered when the token binds it or the builder supplies it.
9
+ */
10
+ export function missingUserDetailsFieldIssues(
11
+ userDetails: UserDetails | undefined,
12
+ tokenPayload: TokenPayload | undefined,
13
+ ): ValidationIssue[] {
14
+ const issues: ValidationIssue[] = [];
15
+ const fieldReason =
16
+ tokenPayload === undefined
17
+ ? "must not be blank"
18
+ : "not bound by the token and not supplied on the builder";
19
+ const tokenBindsField = {
20
+ givenNames: tokenPayload?.hasGivenNames === true,
21
+ lastName: tokenPayload?.hasLastName === true,
22
+ } as const;
23
+ for (const field of ["givenNames", "lastName"] as const) {
24
+ if (tokenBindsField[field]) continue;
25
+ const value = userDetails?.[field];
26
+ if (!value || !value.trim()) {
27
+ issues.push(
28
+ new InvalidFieldValueException({
29
+ fieldName: `userDetails.${field}`,
30
+ reason: fieldReason,
31
+ }),
32
+ );
33
+ }
34
+ }
35
+ // email and phoneNumber are each optional, but one must come from the builder or the token.
36
+ const hasEmail = !!userDetails?.email && userDetails.email.trim().length > 0;
37
+ const hasPhone =
38
+ !!userDetails?.phoneNumber && userDetails.phoneNumber.trim().length > 0;
39
+ const tokenHasContact =
40
+ tokenPayload?.hasEmail === true || tokenPayload?.hasPhoneNumber === true;
41
+ if (!hasEmail && !hasPhone && !tokenHasContact) {
42
+ issues.push(
43
+ new InvalidFieldValueException({
44
+ fieldName: "userDetails",
45
+ reason:
46
+ tokenPayload === undefined
47
+ ? "at least one of email or phoneNumber must be non blank"
48
+ : "at least one contact field must be bound by the token or supplied on the builder",
49
+ }),
50
+ );
51
+ }
52
+ return issues;
53
+ }
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ documentFeedbackKeyFor,
5
+ resolveFeedbackCopy,
6
+ selfieFeedbackKeyFor,
7
+ } from "../helpers/capture_screen_helpers";
8
+ import { UseSmileIDOverlayState } from "../../state/use_smile_id_overlay_state";
9
+ import { UseSmileIDLocalizations } from "../../../l10n/use_smile_id_localizations";
10
+ import { enStrings, type SiKey } from "../../../l10n/strings_en";
11
+
12
+ /**
13
+ * The feedback-key → copy table is the single seam between the capture
14
+ * overlay's stable `si_*` keys and the partner's localization instance.
15
+ * Every entry must resolve to the canonical English default and must
16
+ * honour a partner override, otherwise a translated build silently
17
+ * shows English (or the wrong string) mid-capture.
18
+ */
19
+
20
+ const loc = new UseSmileIDLocalizations({});
21
+
22
+ // Every key the capture surface can emit as feedback.
23
+ const FEEDBACK_KEYS: readonly SiKey[] = [
24
+ "si_capture_initial_instruction",
25
+ "si_start_capture_instruction",
26
+ "si_capture_too_close",
27
+ "si_capture_too_far",
28
+ "si_capture_center_face",
29
+ "si_capture_multiple_faces",
30
+ "si_capture_low_light",
31
+ "si_capture_bright_light",
32
+ "si_capture_rotate_device",
33
+ "si_capture_smile",
34
+ "si_capture_move_left",
35
+ "si_capture_move_right",
36
+ "si_capture_move_higher",
37
+ "si_capture_move_lower",
38
+ "si_capture_ready",
39
+ "si_capture_hold_steady",
40
+ "si_capture_timeout",
41
+ "si_capturing",
42
+ "si_selfie_capture_complete",
43
+ "si_selfie_capture_timeout",
44
+ "si_selfie_capture_unsatisfied",
45
+ "si_ess_capture_initial_instruction",
46
+ "si_ess_hold_still",
47
+ "si_ess_look_left",
48
+ "si_ess_look_right",
49
+ "si_ess_look_up",
50
+ "si_ess_look_at_camera",
51
+ "si_document_initial_instruction",
52
+ "si_document_ready",
53
+ "si_document_capturing",
54
+ "si_document_capture_complete",
55
+ "si_document_capture_unsatisfied",
56
+ "si_document_capture_timeout",
57
+ "si_document_too_close",
58
+ "si_document_too_far",
59
+ "si_document_center",
60
+ "si_document_hold_still",
61
+ "si_document_gallery_image_too_small",
62
+ "si_document_gallery_image_failed",
63
+ ];
64
+
65
+ describe("resolveFeedbackCopy", () => {
66
+ for (const key of FEEDBACK_KEYS) {
67
+ it(`resolves ${key} to its canonical English default`, () => {
68
+ expect(resolveFeedbackCopy(key, loc)).toBe(enStrings[key]);
69
+ });
70
+ }
71
+
72
+ it("honours a partner override for every feedback key", () => {
73
+ const overrides = Object.fromEntries(
74
+ FEEDBACK_KEYS.map((key) => [key, `translated:${key}`]),
75
+ );
76
+ const translated = new UseSmileIDLocalizations(overrides);
77
+
78
+ for (const key of FEEDBACK_KEYS) {
79
+ expect(resolveFeedbackCopy(key, translated)).toBe(`translated:${key}`);
80
+ }
81
+ });
82
+
83
+ it("falls back to the initial instruction for a key outside the table", () => {
84
+ // `si_consent_title` is a real key that the capture surface never emits.
85
+ expect(resolveFeedbackCopy("si_consent_title" as SiKey, loc)).toBe(
86
+ loc.siCaptureInitialInstruction,
87
+ );
88
+ });
89
+ });
90
+
91
+ describe("documentFeedbackKeyFor", () => {
92
+ it("maps each document guidance state to its stable key", () => {
93
+ expect(
94
+ documentFeedbackKeyFor(UseSmileIDOverlayState.DOCUMENT_TOO_CLOSE),
95
+ ).toBe("si_document_too_close");
96
+ expect(
97
+ documentFeedbackKeyFor(UseSmileIDOverlayState.DOCUMENT_TOO_FAR),
98
+ ).toBe("si_document_too_far");
99
+ expect(
100
+ documentFeedbackKeyFor(UseSmileIDOverlayState.DOCUMENT_NOT_CENTERED),
101
+ ).toBe("si_document_center");
102
+ expect(
103
+ documentFeedbackKeyFor(UseSmileIDOverlayState.DOCUMENT_HOLD_STILL),
104
+ ).toBe("si_document_hold_still");
105
+ });
106
+
107
+ it("returns null for lifecycle states that carry no guidance", () => {
108
+ expect(
109
+ documentFeedbackKeyFor(UseSmileIDOverlayState.INSTRUCTION),
110
+ ).toBeNull();
111
+ expect(
112
+ documentFeedbackKeyFor(UseSmileIDOverlayState.DOCUMENT_READY),
113
+ ).toBeNull();
114
+ expect(documentFeedbackKeyFor(UseSmileIDOverlayState.CAPTURING)).toBeNull();
115
+ // A selfie-only guidance state must not leak into the document table.
116
+ expect(documentFeedbackKeyFor(UseSmileIDOverlayState.TOO_CLOSE)).toBeNull();
117
+ });
118
+
119
+ it("resolves every document key it emits through the copy table", () => {
120
+ const states = [
121
+ UseSmileIDOverlayState.DOCUMENT_TOO_CLOSE,
122
+ UseSmileIDOverlayState.DOCUMENT_TOO_FAR,
123
+ UseSmileIDOverlayState.DOCUMENT_NOT_CENTERED,
124
+ UseSmileIDOverlayState.DOCUMENT_HOLD_STILL,
125
+ ];
126
+ for (const state of states) {
127
+ const key = documentFeedbackKeyFor(state);
128
+ expect(key).not.toBeNull();
129
+ expect(resolveFeedbackCopy(key as SiKey, loc)).toBe(
130
+ enStrings[key as SiKey],
131
+ );
132
+ }
133
+ });
134
+ });
135
+
136
+ describe("selfieFeedbackKeyFor", () => {
137
+ it("returns null for the document-only states", () => {
138
+ expect(
139
+ selfieFeedbackKeyFor(UseSmileIDOverlayState.DOCUMENT_TOO_CLOSE),
140
+ ).toBeNull();
141
+ expect(
142
+ selfieFeedbackKeyFor(UseSmileIDOverlayState.DOCUMENT_HOLD_STILL),
143
+ ).toBeNull();
144
+ });
145
+ });
@@ -476,6 +476,18 @@ describe("UseSmileIDCaptureScreen", () => {
476
476
  expect(mockedSetBrightness).toHaveBeenCalledWith(1);
477
477
  });
478
478
 
479
+ it("asks for dark status-bar content, matching the white capture scrim", () => {
480
+ const { root } = render(
481
+ <UseSmileIDCaptureScreen
482
+ analyzer={fakeAnalyzer}
483
+ captureType={CaptureType.selfie}
484
+ />,
485
+ );
486
+ expect(root.findAllByType(StatusBar)[0]!.props.barStyle).toBe(
487
+ "dark-content",
488
+ );
489
+ });
490
+
479
491
  it("renders a translucent status bar so the preview is edge-to-edge", () => {
480
492
  // Translucent status bar = full-bleed window = full-screen oval reference.
481
493
  const { root } = render(
@@ -777,4 +789,340 @@ describe("session-boundary epoch reset — debouncer regression", () => {
777
789
  expect(mockedTakePhoto).toHaveBeenCalledTimes(1);
778
790
  });
779
791
  });
792
+
793
+ describe("selfie capture timeout recovery", () => {
794
+ function renderSelfieScreen() {
795
+ return render(
796
+ <UseSmileIDCaptureScreen
797
+ analyzer={fakeAnalyzer}
798
+ captureType={CaptureType.selfie}
799
+ />,
800
+ );
801
+ }
802
+
803
+ function startCapture(root: ReactTestInstance) {
804
+ act(() => {
805
+ findByTestID(root, "si_capture_start_button")!.props.onClick();
806
+ });
807
+ }
808
+
809
+ function emit(state: UseSmileIDScanState) {
810
+ expect(capturedOnResult).not.toBeNull();
811
+ act(() => {
812
+ capturedOnResult!({
813
+ output: { faces: [], brightness: 0 },
814
+ state,
815
+ capturedEvents: [],
816
+ });
817
+ });
818
+ }
819
+
820
+ it("offers Start again after a timeout", () => {
821
+ const { root } = renderSelfieScreen();
822
+ startCapture(root);
823
+
824
+ emit({ type: UseSmileIDScanStateType.Timeout });
825
+
826
+ const start = findByTestID(root, "si_capture_start_button");
827
+ expect(start).not.toBeNull();
828
+ expect(start!.props.enabled).toBe(true);
829
+ });
830
+
831
+ it("resets the native session when Start is tapped after a timeout", async () => {
832
+ const { root } = renderSelfieScreen();
833
+ startCapture(root);
834
+ emit({ type: UseSmileIDScanStateType.Timeout });
835
+ fakeAnalyzer.reset.mockClear();
836
+
837
+ startCapture(root);
838
+ await act(async () => {
839
+ await Promise.resolve();
840
+ });
841
+
842
+ // The native session is terminal after a timeout — without a reset the
843
+ // first retry frame re-reports Timeout and re-latches the error.
844
+ expect(fakeAnalyzer.reset).toHaveBeenCalledTimes(1);
845
+ });
846
+
847
+ it("restarts the session when Start is tapped after a timeout", () => {
848
+ const onStartCapture = vi.fn();
849
+ const { root } = render(
850
+ <UseSmileIDCaptureScreen
851
+ analyzer={fakeAnalyzer}
852
+ captureType={CaptureType.selfie}
853
+ onStartCapture={onStartCapture}
854
+ />,
855
+ );
856
+ startCapture(root);
857
+ emit({ type: UseSmileIDScanStateType.Timeout });
858
+
859
+ startCapture(root);
860
+
861
+ expect(onStartCapture).toHaveBeenCalledTimes(2);
862
+ });
863
+
864
+ it("still delivers a capture that completes on the retry", async () => {
865
+ const onCaptureComplete = vi.fn();
866
+ const { root } = render(
867
+ <UseSmileIDCaptureScreen
868
+ analyzer={fakeAnalyzer}
869
+ captureType={CaptureType.selfie}
870
+ onCaptureComplete={onCaptureComplete}
871
+ />,
872
+ );
873
+ startCapture(root);
874
+ emit({ type: UseSmileIDScanStateType.Timeout });
875
+ startCapture(root);
876
+
877
+ emit({ type: UseSmileIDScanStateType.Finished });
878
+ await act(async () => {
879
+ await Promise.resolve();
880
+ await Promise.resolve();
881
+ });
882
+
883
+ expect(onCaptureComplete).toHaveBeenCalledTimes(1);
884
+ });
885
+
886
+ it("drops a capture that lands after the timeout was already reported", async () => {
887
+ const onCaptureComplete = vi.fn();
888
+ const onCaptureTimeout = vi.fn();
889
+ const { root } = render(
890
+ <UseSmileIDCaptureScreen
891
+ analyzer={fakeAnalyzer}
892
+ captureType={CaptureType.selfie}
893
+ onCaptureComplete={onCaptureComplete}
894
+ onCaptureTimeout={onCaptureTimeout}
895
+ />,
896
+ );
897
+ startCapture(root);
898
+
899
+ emit({ type: UseSmileIDScanStateType.Timeout });
900
+ emit({ type: UseSmileIDScanStateType.Finished });
901
+ await act(async () => {
902
+ await Promise.resolve();
903
+ await Promise.resolve();
904
+ });
905
+
906
+ expect(onCaptureTimeout).toHaveBeenCalledTimes(1);
907
+ expect(onCaptureComplete).not.toHaveBeenCalled();
908
+ });
909
+
910
+ it("does not pad the retry with frames banked before the timeout", async () => {
911
+ const onCaptureComplete = vi.fn();
912
+ const { root } = render(
913
+ <UseSmileIDCaptureScreen
914
+ analyzer={fakeAnalyzer}
915
+ captureType={CaptureType.selfie}
916
+ onCaptureComplete={onCaptureComplete}
917
+ />,
918
+ );
919
+ startCapture(root);
920
+ act(() => {
921
+ capturedOnResult!({
922
+ output: { faces: [], brightness: 0 },
923
+ state: { type: UseSmileIDScanStateType.Found },
924
+ capturedEvents: [
925
+ {
926
+ kind: CaptureReadyEventKind.Liveness,
927
+ captureIndex: 0,
928
+ url: "file:///stale.jpg",
929
+ },
930
+ ] as CaptureReadyEvent[],
931
+ });
932
+ });
933
+ emit({ type: UseSmileIDScanStateType.Timeout });
934
+
935
+ startCapture(root);
936
+ act(() => {
937
+ capturedOnResult!({
938
+ output: { faces: [], brightness: 0 },
939
+ state: { type: UseSmileIDScanStateType.Found },
940
+ capturedEvents: [
941
+ {
942
+ kind: CaptureReadyEventKind.Selfie,
943
+ captureIndex: 0,
944
+ url: "file:///fresh.jpg",
945
+ },
946
+ ] as CaptureReadyEvent[],
947
+ });
948
+ });
949
+ emit({ type: UseSmileIDScanStateType.Finished });
950
+ await act(async () => {
951
+ await Promise.resolve();
952
+ await Promise.resolve();
953
+ });
954
+
955
+ expect(onCaptureComplete).toHaveBeenCalledTimes(1);
956
+ const [delivered] = onCaptureComplete.mock.calls[0] as [
957
+ readonly CaptureReadyEvent[],
958
+ ];
959
+ expect(delivered.map((event) => event.url)).toEqual([
960
+ "file:///fresh.jpg",
961
+ ]);
962
+ });
963
+
964
+ it("reports the timeout to the partner exactly once", () => {
965
+ const onCaptureTimeout = vi.fn();
966
+ const { root } = render(
967
+ <UseSmileIDCaptureScreen
968
+ analyzer={fakeAnalyzer}
969
+ captureType={CaptureType.selfie}
970
+ onCaptureTimeout={onCaptureTimeout}
971
+ />,
972
+ );
973
+ startCapture(root);
974
+
975
+ emit({ type: UseSmileIDScanStateType.Timeout });
976
+ emit({ type: UseSmileIDScanStateType.Timeout });
977
+
978
+ expect(onCaptureTimeout).toHaveBeenCalledTimes(1);
979
+ });
980
+ });
981
+
982
+ describe("no camera frames at all", () => {
983
+ beforeEach(() => {
984
+ vi.useFakeTimers();
985
+ });
986
+
987
+ afterEach(() => {
988
+ vi.useRealTimers();
989
+ });
990
+
991
+ function renderAndStart() {
992
+ const tree = render(
993
+ <UseSmileIDCaptureScreen
994
+ analyzer={fakeAnalyzer}
995
+ captureType={CaptureType.selfie}
996
+ />,
997
+ );
998
+ act(() => {
999
+ findByTestID(tree.root, "si_capture_start_button")!.props.onClick();
1000
+ });
1001
+ return tree;
1002
+ }
1003
+
1004
+ function elapseFirstFrameWait() {
1005
+ act(() => {
1006
+ vi.advanceTimersByTime(10_000);
1007
+ });
1008
+ }
1009
+
1010
+ it("names the cause instead of sitting on the initial instruction", () => {
1011
+ const { root } = renderAndStart();
1012
+
1013
+ elapseFirstFrameWait();
1014
+
1015
+ expect(collectText(findByTestID(root, "si_capture_feedback_text")!)).toBe(
1016
+ enStrings.si_capture_camera_unavailable,
1017
+ );
1018
+ });
1019
+
1020
+ it("leaves the user a way to retry", () => {
1021
+ const { root } = renderAndStart();
1022
+
1023
+ elapseFirstFrameWait();
1024
+
1025
+ const start = findByTestID(root, "si_capture_start_button");
1026
+ expect(start).not.toBeNull();
1027
+ expect(start!.props.enabled).toBe(true);
1028
+ });
1029
+
1030
+ it("reports a terminal timeout so the flow is not left waiting", () => {
1031
+ const onCaptureTimeout = vi.fn();
1032
+ const tree = render(
1033
+ <UseSmileIDCaptureScreen
1034
+ analyzer={fakeAnalyzer}
1035
+ captureType={CaptureType.selfie}
1036
+ onCaptureTimeout={onCaptureTimeout}
1037
+ />,
1038
+ );
1039
+ act(() => {
1040
+ findByTestID(tree.root, "si_capture_start_button")!.props.onClick();
1041
+ });
1042
+
1043
+ elapseFirstFrameWait();
1044
+
1045
+ expect(onCaptureTimeout).toHaveBeenCalledTimes(1);
1046
+ });
1047
+
1048
+ it("drops the stall copy once a frame finally arrives", () => {
1049
+ const { root } = renderAndStart();
1050
+ elapseFirstFrameWait();
1051
+ expect(collectText(findByTestID(root, "si_capture_feedback_text")!)).toBe(
1052
+ enStrings.si_capture_camera_unavailable,
1053
+ );
1054
+
1055
+ act(() => {
1056
+ capturedOnResult!({
1057
+ output: { faces: [], brightness: 0 },
1058
+ state: { type: UseSmileIDScanStateType.Initial },
1059
+ capturedEvents: [],
1060
+ });
1061
+ });
1062
+
1063
+ expect(
1064
+ collectText(findByTestID(root, "si_capture_feedback_text")!),
1065
+ ).not.toBe(enStrings.si_capture_camera_unavailable);
1066
+ });
1067
+
1068
+ it("still fires when the partner passes an inline callback", () => {
1069
+ const onCaptureTimeout = vi.fn();
1070
+ const screen = (
1071
+ <UseSmileIDCaptureScreen
1072
+ analyzer={fakeAnalyzer}
1073
+ captureType={CaptureType.selfie}
1074
+ onCaptureTimeout={(state) => onCaptureTimeout(state)}
1075
+ />
1076
+ );
1077
+ const tree = render(screen);
1078
+ act(() => {
1079
+ findByTestID(tree.root, "si_capture_start_button")!.props.onClick();
1080
+ });
1081
+
1082
+ for (let step = 0; step < 4; step += 1) {
1083
+ act(() => {
1084
+ tree.update(
1085
+ <UseSmileIDCaptureScreen
1086
+ analyzer={fakeAnalyzer}
1087
+ captureType={CaptureType.selfie}
1088
+ onCaptureTimeout={(state) => onCaptureTimeout(state)}
1089
+ />,
1090
+ );
1091
+ });
1092
+ act(() => {
1093
+ vi.advanceTimersByTime(3_000);
1094
+ });
1095
+ }
1096
+
1097
+ expect(onCaptureTimeout).toHaveBeenCalledTimes(1);
1098
+ });
1099
+
1100
+ it("stays quiet once a frame has been analyzed", () => {
1101
+ const onCaptureTimeout = vi.fn();
1102
+ const tree = render(
1103
+ <UseSmileIDCaptureScreen
1104
+ analyzer={fakeAnalyzer}
1105
+ captureType={CaptureType.selfie}
1106
+ onCaptureTimeout={onCaptureTimeout}
1107
+ />,
1108
+ );
1109
+ act(() => {
1110
+ findByTestID(tree.root, "si_capture_start_button")!.props.onClick();
1111
+ });
1112
+ act(() => {
1113
+ capturedOnResult!({
1114
+ output: { faces: [], brightness: 0 },
1115
+ state: { type: UseSmileIDScanStateType.Initial },
1116
+ capturedEvents: [],
1117
+ });
1118
+ });
1119
+
1120
+ elapseFirstFrameWait();
1121
+
1122
+ expect(onCaptureTimeout).not.toHaveBeenCalled();
1123
+ expect(
1124
+ collectText(findByTestID(tree.root, "si_capture_feedback_text")!),
1125
+ ).not.toBe(enStrings.si_capture_camera_unavailable);
1126
+ });
1127
+ });
780
1128
  });
@@ -62,7 +62,9 @@ describe("UseSmileIDDocumentBackInstructions", () => {
62
62
  />,
63
63
  );
64
64
  expect(findByTestID(root, "si_document_back_instructions")).not.toBeNull();
65
- expect(findTextNode(root, "Flip your Green Book")).not.toBeNull();
65
+ expect(
66
+ findTextNode(root, "Flip your \u2068Green Book\u2069"),
67
+ ).not.toBeNull();
66
68
  });
67
69
 
68
70
  it("invokes onCaptureBack from the primary button", () => {
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as React from "react";
3
+ import { create as createRenderer, act } from "react-test-renderer";
4
+ import { UseSmileIDPermissionScreen } from "../use_smile_id_permission_screen";
5
+ import { UseSmileIDThemeProvider } from "../../theme/theme_context";
6
+ import { defaultDarkTheme, defaultLightTheme } from "../../theme/theme";
7
+
8
+ function render(element: React.ReactElement) {
9
+ let tree!: ReturnType<typeof createRenderer>;
10
+ act(() => {
11
+ tree = createRenderer(element);
12
+ });
13
+ return tree;
14
+ }
15
+
16
+ function rootStyle(
17
+ tree: ReturnType<typeof createRenderer>,
18
+ ): Record<string, unknown> {
19
+ const json = tree.toJSON() as { props: { style: Record<string, unknown> } };
20
+ return json.props.style;
21
+ }
22
+
23
+ describe("UseSmileIDPermissionScreen", () => {
24
+ it("shows a default camera rationale when the partner supplies none", () => {
25
+ const tree = render(
26
+ <UseSmileIDPermissionScreen theme={defaultLightTheme} />,
27
+ );
28
+
29
+ expect(JSON.stringify(tree.toJSON())).toContain(
30
+ "Camera permission required",
31
+ );
32
+ });
33
+
34
+ it("shows the partner's rationale when one is supplied", () => {
35
+ const tree = render(
36
+ <UseSmileIDPermissionScreen
37
+ theme={defaultLightTheme}
38
+ cameraRationale="Acme needs your camera to verify your ID"
39
+ />,
40
+ );
41
+
42
+ const text = JSON.stringify(tree.toJSON());
43
+ expect(text).toContain("Acme needs your camera to verify your ID");
44
+ expect(text).not.toContain("Camera permission required");
45
+ });
46
+
47
+ it("paints on the theme background from the theme prop", () => {
48
+ const tree = render(
49
+ <UseSmileIDPermissionScreen theme={defaultDarkTheme} />,
50
+ );
51
+
52
+ expect(rootStyle(tree).backgroundColor).toBe(
53
+ defaultDarkTheme.colors.background,
54
+ );
55
+ });
56
+
57
+ it("falls back to the theme from context when no theme prop is given", () => {
58
+ const tree = render(
59
+ <UseSmileIDThemeProvider value={defaultDarkTheme}>
60
+ <UseSmileIDPermissionScreen />
61
+ </UseSmileIDThemeProvider>,
62
+ );
63
+
64
+ expect(rootStyle(tree).backgroundColor).toBe(
65
+ defaultDarkTheme.colors.background,
66
+ );
67
+ });
68
+ });
@@ -83,6 +83,7 @@ const FEEDBACK_COPY: Partial<
83
83
  si_capture_ready: (loc) => loc.siCaptureReady,
84
84
  si_capture_hold_steady: (loc) => loc.siCaptureHoldSteady,
85
85
  si_capture_timeout: (loc) => loc.siCaptureTimeout,
86
+ si_capture_camera_unavailable: (loc) => loc.siCaptureCameraUnavailable,
86
87
  si_capturing: (loc) => loc.siCapturing,
87
88
  si_selfie_capture_complete: (loc) => loc.siSelfieCaptureComplete,
88
89
  si_selfie_capture_timeout: (loc) => loc.siSelfieCaptureTimeout,