diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx
index c94c1504ea9..80efa33869b 100644
--- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx
+++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx
@@ -7,6 +7,7 @@ import type {
RegisterFieldCallback,
} from '@18f/identity-form-steps';
import AcuantCapture from './acuant-capture';
+import SelfieCaptureContext from '../context/selfie-capture';
interface DocumentSideAcuantCaptureProps {
side: 'front' | 'back' | 'selfie';
@@ -43,6 +44,9 @@ function DocumentSideAcuantCapture({
}: DocumentSideAcuantCaptureProps) {
const error = errors.find(({ field }) => field === side)?.error;
const { changeStepCanComplete } = useContext(FormStepsContext);
+ const { isSelfieCaptureEnabled, isSelfieDesktopTestMode } = useContext(SelfieCaptureContext);
+ const isUploadAllowed = isSelfieDesktopTestMode || !isSelfieCaptureEnabled;
+
return (
);
}
diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx
index 030f479b8b2..0411ea159e8 100644
--- a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx
+++ b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx
@@ -1,6 +1,7 @@
-import { createContext, useState } from 'react';
+import { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';
import useCounter from '../hooks/use-counter';
+import SelfieCaptureContext from './selfie-capture';
interface CaptureAttemptMetadata {
isAssessedAsGlare: boolean;
@@ -125,6 +126,7 @@ function FailedCaptureAttemptsContextProvider({
useCounter();
const [failedSubmissionAttempts, incrementFailedSubmissionAttempts] = useCounter();
const [failedCameraPermissionAttempts, incrementFailedCameraPermissionAttempts] = useCounter();
+ const { isSelfieCaptureEnabled } = useContext(SelfieCaptureContext);
const [failedSubmissionImageFingerprints, setFailedSubmissionImageFingerprints] =
useState(failedFingerprints);
@@ -143,10 +145,12 @@ function FailedCaptureAttemptsContextProvider({
incrementFailedCameraPermissionAttempts();
}
- const forceNativeCamera =
+ const hasExhaustedAttempts =
failedCaptureAttempts >= maxCaptureAttemptsBeforeNativeCamera ||
failedSubmissionAttempts >= maxSubmissionAttemptsBeforeNativeCamera;
+ const forceNativeCamera = isSelfieCaptureEnabled ? false : hasExhaustedAttempts;
+
return (
({
isSelfieCaptureEnabled: false,
+ isSelfieDesktopTestMode: false,
});
SelfieCaptureContext.displayName = 'SelfieCaptureContext';
diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx
index 3f752d0735c..68e690dd167 100644
--- a/app/javascript/packs/document-capture.tsx
+++ b/app/javascript/packs/document-capture.tsx
@@ -39,6 +39,7 @@ interface AppRootData {
skipDocAuth: string;
howToVerifyURL: string;
uiExitQuestionSectionEnabled: string;
+ docAuthSelfieDesktopTestMode: string;
}
const appRoot = document.getElementById('document-capture-form')!;
@@ -106,6 +107,7 @@ const {
skipDocAuth,
howToVerifyUrl,
uiExitQuestionSectionEnabled = '',
+ docAuthSelfieDesktopTestMode,
} = appRoot.dataset as DOMStringMap & AppRootData;
let parsedUsStatesTerritories = [];
@@ -177,6 +179,15 @@ const App = composeComponents(
value: getServiceProvider(),
},
],
+ [
+ SelfieCaptureContext.Provider,
+ {
+ value: {
+ isSelfieCaptureEnabled: getSelfieCaptureEnabled(),
+ isSelfieDesktopTestMode: String(docAuthSelfieDesktopTestMode) === 'true',
+ },
+ },
+ ],
[
FailedCaptureAttemptsContextProvider,
{
@@ -192,14 +203,6 @@ const App = composeComponents(
},
},
],
- [
- SelfieCaptureContext.Provider,
- {
- value: {
- isSelfieCaptureEnabled: getSelfieCaptureEnabled(),
- },
- },
- ],
[
DocumentCapture,
{
diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb
index 07f8cd31453..02e22f02c8e 100644
--- a/app/views/idv/shared/_document_capture.html.erb
+++ b/app/views/idv/shared/_document_capture.html.erb
@@ -37,6 +37,7 @@
in_person_outage_expected_update_date: IdentityConfig.store.in_person_outage_expected_update_date,
us_states_territories: us_states_territories,
doc_auth_selfie_capture: FeatureManagement.idv_allow_selfie_check? && doc_auth_selfie_capture,
+ doc_auth_selfie_desktop_test_mode: IdentityConfig.store.doc_auth_selfie_desktop_test_mode,
skip_doc_auth: skip_doc_auth,
how_to_verify_url: idv_how_to_verify_url,
ui_exit_question_section_enabled: IdentityConfig.store.doc_auth_exit_question_section_enabled,
diff --git a/spec/javascript/packages/document-capture/components/document-capture-abandon-spec.tsx b/spec/javascript/packages/document-capture/components/document-capture-abandon-spec.tsx
index 1235587fefa..14b0783e3e8 100644
--- a/spec/javascript/packages/document-capture/components/document-capture-abandon-spec.tsx
+++ b/spec/javascript/packages/document-capture/components/document-capture-abandon-spec.tsx
@@ -41,6 +41,7 @@ describe('DocumentCaptureAbandon', () => {
cancelURL: '/cancel',
exitURL: '/exit',
currentStep: 'document_capture',
+ accountURL: '',
}}
>
{
cancelURL: '/cancel',
exitURL: '/exit',
currentStep: 'document_capture',
+ accountURL: '',
}}
>
{
+ const DEFAULT_PROPS = {
+ errors: [],
+ registerField: () => undefined,
+ value: '',
+ onChange: () => undefined,
+ onError: () => undefined,
+ };
+
+ context('when selfie is _not_ enabled', () => {
+ context('and using mobile', () => {
+ context('and doc_auth_selfie_desktop_test_mode is false', () => {
+ it('_does_ display a photo upload button', () => {
+ const { queryAllByText } = render(
+
+
+
+
+
+ ,
+ );
+
+ const takeOrUploadPictureText = queryAllByText(
+ 'doc_auth.buttons.take_or_upload_picture_html',
+ );
+ expect(takeOrUploadPictureText).to.have.lengthOf(2);
+ });
+ });
+
+ context('and doc_auth_selfie_desktop_test_mode is true', () => {
+ it('_does_ display a photo upload button', () => {
+ const { queryAllByText } = render(
+
+
+
+
+
+ ,
+ );
+
+ const takeOrUploadPictureText = queryAllByText(
+ 'doc_auth.buttons.take_or_upload_picture_html',
+ );
+ expect(takeOrUploadPictureText).to.have.lengthOf(2);
+ });
+ });
+ });
+
+ context('and using desktop', () => {
+ context('and doc_auth_selfie_desktop_test_mode is false', () => {
+ it('shows a file pick area for each field', () => {
+ const { queryAllByText } = render(
+
+
+
+
+
+ ,
+ );
+
+ const uploadPictureText = queryAllByText('doc_auth.forms.choose_file_html');
+ expect(uploadPictureText).to.have.lengthOf(2);
+ });
+ });
+
+ context('and doc_auth_selfie_desktop_test_mode is true', () => {
+ it('shows a file pick area for each field', () => {
+ const { queryAllByText } = render(
+
+
+
+
+
+ ,
+ );
+
+ const uploadPictureText = queryAllByText('doc_auth.forms.choose_file_html');
+ expect(uploadPictureText).to.have.lengthOf(2);
+ });
+ });
+ });
+ });
+
+ context('when selfie _is_ enabled', () => {
+ context('and using mobile', () => {
+ context('and doc_auth_selfie_desktop_test_mode is false', () => {
+ it('does _not_ display a photo upload button', () => {
+ const { queryAllByText } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ const takePictureText = queryAllByText('doc_auth.buttons.take_picture');
+ expect(takePictureText).to.have.lengthOf(3);
+
+ const takeOrUploadPictureText = queryAllByText(
+ 'doc_auth.buttons.take_or_upload_picture_html',
+ );
+ expect(takeOrUploadPictureText).to.have.lengthOf(0);
+ });
+ });
+
+ context('and doc_auth_selfie_desktop_test_mode is true', () => {
+ it('does _not_ display a photo upload button', () => {
+ const { queryAllByText } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ const takePictureText = queryAllByText('doc_auth.buttons.take_picture');
+ expect(takePictureText).to.have.lengthOf(3);
+
+ const takeOrUploadPictureText = queryAllByText(
+ 'doc_auth.buttons.take_or_upload_picture_html',
+ );
+ expect(takeOrUploadPictureText).to.have.lengthOf(3);
+ });
+ });
+ });
+
+ context('and using desktop', () => {
+ context('and doc_auth_selfie_desktop_test_mode is false', () => {
+ it('never loads these components', () => {
+ // noop
+ });
+ });
+
+ context('and doc_auth_selfie_desktop_test_mode is true', () => {
+ it('shows a file pick area for each field', () => {
+ const { queryAllByText } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ const uploadPictureText = queryAllByText('doc_auth.forms.choose_file_html');
+ expect(uploadPictureText).to.have.lengthOf(3);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx b/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx
index 7f6f94e1ddc..bb323b10cad 100644
--- a/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx
+++ b/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx
@@ -1,7 +1,11 @@
import { useContext } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
-import { DeviceContext, AnalyticsContext } from '@18f/identity-document-capture';
+import {
+ DeviceContext,
+ AnalyticsContext,
+ SelfieCaptureContext,
+} from '@18f/identity-document-capture';
import { Provider as AcuantContextProvider } from '@18f/identity-document-capture/context/acuant';
import AcuantCapture from '@18f/identity-document-capture/components/acuant-capture';
import FailedCaptureAttemptsContext, {
@@ -164,6 +168,59 @@ describe('FailedCaptureAttemptsContext testing of forceNativeCamera logic', () =
expect(result.current.failedCaptureAttempts).to.equal(1);
expect(result.current.forceNativeCamera).to.equal(false);
});
+
+ describe('when selfie is enabled', () => {
+ it('forceNativeCamera is always false, no matter how many times any attempt fails', () => {
+ const trackEvent = sinon.spy();
+ const { result, rerender } = renderHook(() => useContext(FailedCaptureAttemptsContext), {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ });
+
+ result.current.onFailedCaptureAttempt({
+ isAssessedAsGlare: true,
+ isAssessedAsBlurry: false,
+ });
+ rerender(true);
+ expect(result.current.forceNativeCamera).to.equal(false);
+ result.current.onFailedCaptureAttempt({
+ isAssessedAsGlare: false,
+ isAssessedAsBlurry: true,
+ });
+ rerender(true);
+ expect(result.current.forceNativeCamera).to.equal(false);
+ result.current.onFailedCaptureAttempt({
+ isAssessedAsGlare: false,
+ isAssessedAsBlurry: true,
+ });
+ rerender({});
+ expect(result.current.failedCaptureAttempts).to.equal(3);
+ expect(result.current.forceNativeCamera).to.equal(false);
+
+ result.current.onFailedSubmissionAttempt();
+ rerender(true);
+ expect(result.current.forceNativeCamera).to.equal(false);
+ result.current.onFailedSubmissionAttempt();
+ rerender(true);
+ expect(result.current.forceNativeCamera).to.equal(false);
+ result.current.onFailedSubmissionAttempt();
+ rerender({});
+ expect(result.current.failedSubmissionAttempts).to.equal(3);
+ expect(result.current.forceNativeCamera).to.equal(false);
+
+ expect(trackEvent).to.not.have.been.calledWith(
+ 'IdV: Native camera forced after failed attempts',
+ );
+ });
+ });
});
describe('maxCaptureAttemptsBeforeNativeCamera logging tests', () => {
diff --git a/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx b/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx
index e48dda792c9..cf57d166158 100644
--- a/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx
+++ b/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx
@@ -6,7 +6,7 @@ describe('document-capture/context/feature-flag', () => {
it('has expected default properties', () => {
const { result } = renderHook(() => useContext(SelfieCaptureContext));
- expect(result.current).to.have.keys(['isSelfieCaptureEnabled']);
+ expect(result.current).to.have.keys(['isSelfieCaptureEnabled', 'isSelfieDesktopTestMode']);
expect(result.current.isSelfieCaptureEnabled).to.be.a('boolean');
});
});
diff --git a/spec/javascript/support/document-capture.jsx b/spec/javascript/support/document-capture.jsx
index 5259b26eded..f547b254396 100644
--- a/spec/javascript/support/document-capture.jsx
+++ b/spec/javascript/support/document-capture.jsx
@@ -1,5 +1,6 @@
import { render as baseRender, cleanup } from '@testing-library/react';
import sinon from 'sinon';
+// @ts-ignore
import { UploadContextProvider } from '@18f/identity-document-capture';
/** @typedef {import('@testing-library/react').RenderOptions} BaseRenderOptions */
@@ -44,8 +45,12 @@ export function render(element, options = {}) {
return baseRender(element, {
...baseRenderOptions,
wrapper: ({ children }) => (
+ // @ts-ignore
- {baseWrapper({ children })}
+ {
+ // @ts-ignore
+ baseWrapper({ children })
+ }
),
});
@@ -57,10 +62,15 @@ export function useAcuant() {
// resetting the global variables, since otherwise the component's effect unsubscribe will
// attempt to reference globals that no longer exist.
cleanup();
+ // @ts-ignore
delete window.AcuantJavascriptWebSdk;
+ // @ts-ignore
delete window.AcuantCamera;
+ // @ts-ignore
delete window.AcuantCameraUI;
+ // @ts-ignore
delete window.AcuantPassiveLiveness;
+ // @ts-ignore
delete window.loadAcuantSdk;
});
@@ -75,6 +85,7 @@ export function useAcuant() {
triggerCapture = sinon.stub(),
} = {}) {
window.AcuantJavascriptWebSdk = {
+ // @ts-ignore
initialize: (_credentials, _endpoint, { onSuccess, onFail }) =>
isSuccess ? onSuccess() : onFail(401, 'Server returned a 401 (missing credentials).'),
startWorkers: sinon.stub().callsArg(0),
@@ -82,13 +93,16 @@ export function useAcuant() {
REPEAT_FAIL_CODE: 'repeat-fail-code',
SEQUENCE_BREAK_CODE: 'sequence-break-code',
};
+ // @ts-ignore
window.AcuantCamera = { isCameraSupported, triggerCapture };
window.AcuantCameraUI = {
start: sinon.stub().callsFake((...args) => {
const camera = document.getElementById('acuant-camera');
const canvas = document.createElement('canvas');
canvas.id = 'acuant-ui-canvas';
+ // @ts-ignore
camera.appendChild(canvas);
+ // @ts-ignore
camera.dispatchEvent(new window.CustomEvent('acuantcameracreated'));
start(...args);
}),
@@ -97,7 +111,9 @@ export function useAcuant() {
window.AcuantPassiveLiveness = { start: selfieStart, end: selfieEnd };
window.loadAcuantSdk = () => {};
const sdkScript = document.querySelector('[data-acuant-sdk]');
+ // @ts-ignore
sdkScript.onload();
+ // @ts-ignore
sdkScript.onload = null;
},
};
diff --git a/tsconfig.json b/tsconfig.json
index f02a216e7ee..24beb2ac6b1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -22,6 +22,7 @@
"app/javascript/packs",
"spec/javascript/spec_helper.d.ts",
"spec/javascript/**/*.ts",
+ "spec/javascript/**/*.tsx",
"./*.js",
"scripts"
],