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
25 changes: 25 additions & 0 deletions app/javascript/packages/form-steps/form-steps.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,31 @@ describe('FormSteps', () => {
expect(document.activeElement).to.equal(inputOne);
});

it('supports ref assignment to arbitrary (non-input) elements', async () => {
const onComplete = sandbox.stub();
const { getByRole } = render(
<FormSteps
onComplete={onComplete}
steps={[
{
name: 'first',
form({ registerField }) {
return (
<div ref={registerField('element')}>
<FormStepsButton.Submit />
</div>
);
},
},
]}
/>,
);

await userEvent.click(getByRole('button', { name: 'forms.buttons.submit.default' }));

expect(onComplete).to.have.been.called();
});

it('distinguishes empty errors from progressive error removal', async () => {
const { getByText, getByLabelText, container } = render(<FormSteps steps={STEPS} />);

Expand Down
14 changes: 9 additions & 5 deletions app/javascript/packages/form-steps/form-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ interface FieldsRefEntry {
/**
* Ref callback.
*/
refCallback: RefCallback<HTMLInputElement>;
refCallback: RefCallback<HTMLElement>;

/**
* Whether field is required.
Expand All @@ -110,7 +110,7 @@ interface FieldsRefEntry {
/**
* Element assigned by ref callback.
*/
element: HTMLInputElement | null;
element: HTMLElement | null;
}

interface FormStepsProps {
Expand Down Expand Up @@ -243,7 +243,9 @@ function FormSteps({
if (activeErrors.length && didSubmitWithErrors.current) {
const activeErrorFieldElement = getFieldActiveErrorFieldElement(activeErrors, fields.current);
if (activeErrorFieldElement) {
activeErrorFieldElement.reportValidity();
if (activeErrorFieldElement instanceof HTMLInputElement) {
activeErrorFieldElement.reportValidity();
}
activeErrorFieldElement.focus();
}
}
Expand Down Expand Up @@ -296,9 +298,11 @@ function FormSteps({

let error: Error | undefined;
if (isActive) {
element.checkValidity();
if (element instanceof HTMLInputElement) {
element.checkValidity();
}

if (element.validationMessage) {
if (element instanceof HTMLInputElement && element.validationMessage) {
error = new Error(element.validationMessage);
} else if (isRequired && !values[key]) {
error = new RequiredValueMissingError();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sinon from 'sinon';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/dom';
import { render as baseRender, fireEvent } from '@testing-library/react';
import { render as baseRender, fireEvent, cleanup } from '@testing-library/react';
import httpUpload, {
UploadFormEntriesError,
toFormEntryError,
Expand All @@ -16,12 +16,13 @@ import DocumentCapture, {
except,
} from '@18f/identity-document-capture/components/document-capture';
import { expect } from 'chai';
import { useSandbox } from '@18f/identity-test-helpers';
import { useSandbox, useDefineProperty } from '@18f/identity-test-helpers';
import { render, useAcuant, useDocumentCaptureForm } from '../../../support/document-capture';
import { getFixture, getFixtureFile } from '../../../support/file';

describe('document-capture/components/document-capture', () => {
const onSubmit = useDocumentCaptureForm();
const defineProperty = useDefineProperty();
const sandbox = useSandbox();
const { initialize } = useAcuant();

Expand Down Expand Up @@ -560,4 +561,67 @@ describe('document-capture/components/document-capture', () => {
});
});
});

context('desktop selfie capture', () => {
beforeEach(() => {
function MediaStream() {}
MediaStream.prototype = { play() {}, getTracks() {} };
sandbox.stub(MediaStream.prototype, 'play');
sandbox.stub(MediaStream.prototype, 'getTracks').returns([{ stop() {} }]);
sandbox.stub(window.HTMLMediaElement.prototype, 'play');

defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: () => Promise.resolve(new MediaStream()),
},
});
defineProperty(window, 'MediaStream', {
configurable: true,
value: MediaStream,
});
defineProperty(navigator, 'permissions', {
configurable: true,
value: {
query: sinon
.stub()
.withArgs({ name: 'camera' })
.returns(Promise.resolve({ state: 'granted' })),
},
});
sandbox.stub(window.HTMLCanvasElement.prototype, 'getContext').returns({ drawImage() {} });
sandbox.stub(window.HTMLCanvasElement.prototype, 'toDataURL').returns('data:,');
});

// DOM globals are stubbed with sandbox, so run cleanup before sandbox is restored, as otherwise
// it will attempt to reference globals which are already restored to undefined.
afterEach(cleanup);

it('progresses through steps to completion', async () => {
const { getByLabelText, getByText } = render(
<DeviceContext.Provider value={{ isMobile: false }}>
<AcuantContextProvider sdkSrc="about:blank" cameraSrc="about:blank">
<DocumentCapture />
</AcuantContextProvider>
</DeviceContext.Provider>,
);

await userEvent.upload(
getByLabelText('doc_auth.headings.document_capture_front'),
validUpload,
);
await userEvent.upload(
getByLabelText('doc_auth.headings.document_capture_back'),
validUpload,
);

await userEvent.click(getByText('forms.buttons.continue'));
await userEvent.click(getByLabelText('doc_auth.buttons.take_picture'));

await new Promise((resolve) => {
onSubmit.callsFake(resolve);
userEvent.click(getByText('forms.buttons.submit.default'));
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cleanup } from '@testing-library/react';
import { I18nContext } from '@18f/identity-react-i18n';
import { I18n } from '@18f/identity-i18n';
import SelfieCapture from '@18f/identity-document-capture/components/selfie-capture';
import { useSandbox } from '@18f/identity-test-helpers';
import { useSandbox, useDefineProperty } from '@18f/identity-test-helpers';
import { render } from '../../../support/document-capture';
import { getFixtureFile } from '../../../support/file';

Expand All @@ -14,6 +14,7 @@ describe('document-capture/components/selfie-capture', () => {
afterEach(cleanup);

const sandbox = useSandbox();
const defineProperty = useDefineProperty();

const wrapper = ({ children }) => (
<I18nContext.Provider
Expand All @@ -36,63 +37,44 @@ describe('document-capture/components/selfie-capture', () => {
value = await getFixtureFile('doc_auth_images/selfie.jpg');
});

let originalMediaDevices;
let originalMediaStream;
let originalPermissions;
beforeEach(() => {
originalMediaDevices = navigator.mediaDevices;
originalPermissions = navigator.permissions;

function MediaStream() {}
MediaStream.prototype = { play() {}, getTracks() {} };
sandbox.stub(MediaStream.prototype, 'play');
sandbox.stub(MediaStream.prototype, 'getTracks').returns([track]);

sandbox.stub(window.HTMLMediaElement.prototype, 'play');

navigator.mediaDevices = {
getUserMedia: () => Promise.resolve(new MediaStream()),
};

originalMediaStream = window.MediaStream;
window.MediaStream = MediaStream;
defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: () => Promise.resolve(new MediaStream()),
},
});
defineProperty(window, 'MediaStream', {
configurable: true,
value: MediaStream,
});

track.stop.resetHistory();
});

afterEach(() => {
if (originalMediaDevices === undefined) {
delete navigator.mediaDevices;
} else {
navigator.mediaDevices = originalMediaDevices;
}

if (originalMediaStream === undefined) {
delete window.MediaStream;
} else {
window.MediaStream = originalMediaStream;
}

if (originalPermissions === undefined) {
delete navigator.permissions;
} else {
navigator.permissions = originalPermissions;
}
});

it('renders a consent prompt', () => {
const { getByText } = render(<SelfieCapture />);

expect(getByText('doc_auth.instructions.document_capture_selfie_consent_banner')).to.be.ok();
});

it('renders video element that auto-plays if previous consent granted', async () => {
navigator.permissions = {
query: sinon
.stub()
.withArgs({ name: 'camera' })
.returns(Promise.resolve({ state: 'granted' })),
};
defineProperty(navigator, 'permissions', {
configurable: true,
value: {
query: sinon
.stub()
.withArgs({ name: 'camera' })
.returns(Promise.resolve({ state: 'granted' })),
},
});
const { getByLabelText, findByLabelText } = render(<SelfieCapture />);

await findByLabelText('doc_auth.buttons.take_picture');
Expand All @@ -119,12 +101,15 @@ describe('document-capture/components/selfie-capture', () => {
});

it('renders error state if previous consent denied', async () => {
navigator.permissions = {
query: sinon
.stub()
.withArgs({ name: 'camera' })
.returns(Promise.resolve({ state: 'denied' })),
};
defineProperty(navigator, 'permissions', {
configurable: true,
value: {
query: sinon
.stub()
.withArgs({ name: 'camera' })
.returns(Promise.resolve({ state: 'denied' })),
},
});
const { findByText } = render(<SelfieCapture />);

await findByText('doc_auth.instructions.document_capture_selfie_consent_blocked');
Expand Down