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" ],