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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
RegisterFieldCallback,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kellular and @daviddsilvanava - This is replacing #10165 and thanks to other work from Timnit, by now shouldn't have the UX issues we talked about on that PR.

} from '@18f/identity-form-steps';
import AcuantCapture from './acuant-capture';
import SelfieCaptureContext from '../context/selfie-capture';

interface DocumentSideAcuantCaptureProps {
side: 'front' | 'back' | 'selfie';
Expand Down Expand Up @@ -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 (
<AcuantCapture
ref={registerField(side, { isRequired: true })}
Expand Down Expand Up @@ -74,6 +78,7 @@ function DocumentSideAcuantCapture({
errorMessage={error ? error.message : undefined}
name={side}
className={className}
allowUpload={isUploadAllowed}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -125,6 +126,7 @@ function FailedCaptureAttemptsContextProvider({
useCounter();
const [failedSubmissionAttempts, incrementFailedSubmissionAttempts] = useCounter();
const [failedCameraPermissionAttempts, incrementFailedCameraPermissionAttempts] = useCounter();
const { isSelfieCaptureEnabled } = useContext(SelfieCaptureContext);

const [failedSubmissionImageFingerprints, setFailedSubmissionImageFingerprints] =
useState<UploadedImageFingerprints>(failedFingerprints);
Expand All @@ -143,10 +145,12 @@ function FailedCaptureAttemptsContextProvider({
incrementFailedCameraPermissionAttempts();
}

const forceNativeCamera =
const hasExhaustedAttempts =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice variable naming here. 👍🏻

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Credit goes to @aduth for thinking of this name 😁

failedCaptureAttempts >= maxCaptureAttemptsBeforeNativeCamera ||
failedSubmissionAttempts >= maxSubmissionAttemptsBeforeNativeCamera;

const forceNativeCamera = isSelfieCaptureEnabled ? false : hasExhaustedAttempts;

return (
<FailedCaptureAttemptsContext.Provider
value={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ interface SelfieCaptureProps {
* Specify whether to show the selfie capture on the doc capture screen.
*/
isSelfieCaptureEnabled: boolean;
/**
* Specify whether to allow uploads for selfie when in test mode.
*/
isSelfieDesktopTestMode: boolean;
}

const SelfieCaptureContext = createContext<SelfieCaptureProps>({
isSelfieCaptureEnabled: false,
isSelfieDesktopTestMode: false,
});

SelfieCaptureContext.displayName = 'SelfieCaptureContext';
Expand Down
19 changes: 11 additions & 8 deletions app/javascript/packs/document-capture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface AppRootData {
skipDocAuth: string;
howToVerifyURL: string;
uiExitQuestionSectionEnabled: string;
docAuthSelfieDesktopTestMode: string;
}

const appRoot = document.getElementById('document-capture-form')!;
Expand Down Expand Up @@ -106,6 +107,7 @@ const {
skipDocAuth,
howToVerifyUrl,
uiExitQuestionSectionEnabled = '',
docAuthSelfieDesktopTestMode,
} = appRoot.dataset as DOMStringMap & AppRootData;

let parsedUsStatesTerritories = [];
Expand Down Expand Up @@ -177,6 +179,15 @@ const App = composeComponents(
value: getServiceProvider(),
},
],
[
SelfieCaptureContext.Provider,
{
value: {
isSelfieCaptureEnabled: getSelfieCaptureEnabled(),
isSelfieDesktopTestMode: String(docAuthSelfieDesktopTestMode) === 'true',
},
},
],
[
FailedCaptureAttemptsContextProvider,
{
Expand All @@ -192,14 +203,6 @@ const App = composeComponents(
},
},
],
[
SelfieCaptureContext.Provider,
{
value: {
isSelfieCaptureEnabled: getSelfieCaptureEnabled(),
},
},
],
[
DocumentCapture,
{
Expand Down
1 change: 1 addition & 0 deletions app/views/idv/shared/_document_capture.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('DocumentCaptureAbandon', () => {
cancelURL: '/cancel',
exitURL: '/exit',
currentStep: 'document_capture',
accountURL: '',
}}
>
<I18nContext.Provider
Expand Down Expand Up @@ -137,6 +138,7 @@ describe('DocumentCaptureAbandon', () => {
cancelURL: '/cancel',
exitURL: '/exit',
currentStep: 'document_capture',
accountURL: '',
}}
>
<I18nContext.Provider
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { DeviceContext, SelfieCaptureContext } from '@18f/identity-document-capture';
import DocumentSideAcuantCapture from '@18f/identity-document-capture/components/document-side-acuant-capture';
import { expect } from 'chai';
import { render } from '../../../support/document-capture';

describe('DocumentSideAcuantCapture', () => {
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(
<DeviceContext.Provider value={{ isMobile: true }}>
<SelfieCaptureContext.Provider
value={{ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false }}
>
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="front" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="back" />
</SelfieCaptureContext.Provider>
</DeviceContext.Provider>,
);

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(
<DeviceContext.Provider value={{ isMobile: true }}>
<SelfieCaptureContext.Provider
value={{ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: true }}
>
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="front" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="back" />
</SelfieCaptureContext.Provider>
</DeviceContext.Provider>,
);

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(
<DeviceContext.Provider value={{ isMobile: false }}>
<SelfieCaptureContext.Provider
value={{ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false }}
>
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="front" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="back" />
</SelfieCaptureContext.Provider>
</DeviceContext.Provider>,
);

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(
<DeviceContext.Provider value={{ isMobile: false }}>
<SelfieCaptureContext.Provider
value={{ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: true }}
>
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="front" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="back" />
</SelfieCaptureContext.Provider>
</DeviceContext.Provider>,
);

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(
<DeviceContext.Provider value={{ isMobile: true }}>
<SelfieCaptureContext.Provider
value={{ isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: false }}
>
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="front" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="back" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="selfie" />
</SelfieCaptureContext.Provider>
</DeviceContext.Provider>,
);

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(
<DeviceContext.Provider value={{ isMobile: true }}>
<SelfieCaptureContext.Provider
value={{ isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: true }}
>
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="front" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="back" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="selfie" />
</SelfieCaptureContext.Provider>
</DeviceContext.Provider>,
);

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(
<DeviceContext.Provider value={{ isMobile: false }}>
<SelfieCaptureContext.Provider
value={{ isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: true }}
>
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="front" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="back" />
<DocumentSideAcuantCapture {...DEFAULT_PROPS} side="selfie" />
</SelfieCaptureContext.Provider>
</DeviceContext.Provider>,
);

const uploadPictureText = queryAllByText('doc_auth.forms.choose_file_html');
expect(uploadPictureText).to.have.lengthOf(3);
});
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down Expand Up @@ -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 }) => (
<SelfieCaptureContext.Provider value={{ isSelfieCaptureEnabled: true }}>
<Provider
maxCaptureAttemptsBeforeNativeCamera={2}
maxSubmissionAttemptsBeforeNativeCamera={2}
>
{children}
</Provider>
</SelfieCaptureContext.Provider>
),
});

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

'IdV: Native camera forced after failed attempts',
);
});
});
});

describe('maxCaptureAttemptsBeforeNativeCamera logging tests', () => {
Expand Down
Loading