diff --git a/ci/pipeline.yml b/ci/pipeline.yml index 7366662de..80ea954b6 100644 --- a/ci/pipeline.yml +++ b/ci/pipeline.yml @@ -378,6 +378,9 @@ jobs: - task: code-coverage-diff image: node file: src/ci/partials/code-coverage-diff.yml + params: + APP_ENV: ((deploy-env)) + APP_HOSTNAME: https://((((deploy-env))-pages-domain)) #@ if/end env != 'production': - name: e2e-test diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 00ee2c408..2ae5d8697 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -561,3 +561,4 @@ Best practices for writing tests: - Prefer click events to direct function calls - [Query for elements](https://testing-library.com/docs/queries/about/#priority) based on role, label, or text before falling back to `test ids` (and never classes or ids) - Simulated data needed for tests ("fixtures") should be located outside the test file itself, except in the case of single values (e.g. fake `Build` model data should be in a separate file but having an inline variable like `const testText = 'sample-data'` is fine) +- User events should use `@testing-library/user-event` since it simulates user interactions by dispatching the events that would happen if the interaction took place in a browser. Using the built-in `fireEvent` provides a browser's low-level `dispatchEvent` API but it cannot properly simulate more complex interactions like typing into a text box. diff --git a/frontend/shared/GithubAuthButton.jsx b/frontend/shared/GithubAuthButton.jsx index f7d9c3c47..45f155a38 100644 --- a/frontend/shared/GithubAuthButton.jsx +++ b/frontend/shared/GithubAuthButton.jsx @@ -53,7 +53,7 @@ function authorize(revokeFirst) { return authPromise(); } -const GithubAuthButton = ({ onFailure, onSuccess, text, revokeFirst }) => ( +const GithubAuthButton = ({ onFailure, onSuccess, text, revokeFirst = false }) => (

{text}

); GithubAuthButton.propTypes = { - onFailure: PropTypes.func, - onSuccess: PropTypes.func, - text: PropTypes.string.isRequired, + onFailure: PropTypes.func.isRequired, + onSuccess: PropTypes.func.isRequired, revokeFirst: PropTypes.bool, -}; - -GithubAuthButton.defaultProps = { - onFailure: () => {}, - onSuccess: () => {}, - revokeFirst: false, + text: PropTypes.string.isRequired, }; export default GithubAuthButton; diff --git a/frontend/shared/GithubAuthButton.test.jsx b/frontend/shared/GithubAuthButton.test.jsx new file mode 100644 index 000000000..17d1805d1 --- /dev/null +++ b/frontend/shared/GithubAuthButton.test.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { spy, stub, restore } from 'sinon'; +import '@testing-library/jest-dom'; +import globals from '../globals'; + +import api from '@util/federalistApi'; +import GithubAuthButton from './GithubAuthButton'; + +const BUTTON_TEXT = `Connect with GitHub`; +const text = 'Hello World'; + +// We need to create the MessageEvent to set the origin +// and data when testing +// https://github.com/jsdom/jsdom/issues/2745#issuecomment-1207414024 +function createPostMessageEvent(data, origin) { + return new MessageEvent('message', { + source: window, + origin, + data, + }); +} + +function createStubs() { + const onFailure = spy(); + const onSuccess = spy(); + const apiStub = stub(api, 'revokeApplicationGrant').resolves(); + const focusStub = stub().resolves(); + const closeStub = stub().returns(); + const windowStub = stub(window, 'open').returns({ + focus: focusStub, + close: closeStub, + }); + + return { + onFailure, + onSuccess, + apiStub, + focusStub, + closeStub, + windowStub, + }; +} + +describe('', () => { + let onFailure; + let onSuccess; + let apiStub; + let focusStub; + let closeStub; + let windowStub; + + const user = userEvent.setup(); + + beforeAll(() => { + // Set window origin based on globals APP_HOSTNAME + window['origin'] = globals.APP_HOSTNAME; + + // Set screen height and width + window['screen'] = { + width: 1200, + height: 1000, + }; + }); + + beforeEach(() => { + ({ onFailure, onSuccess, apiStub, focusStub, closeStub, windowStub } = createStubs()); + }); + + afterEach(() => restore()); + + it('renders', () => { + const props = { onFailure, onSuccess, text }; + render(); + + expect(screen.getByText(text)).toBeTruthy(); + + const button = screen.getByRole('button'); + expect(button).toHaveTextContent(BUTTON_TEXT); + + expect(onFailure.notCalled).toBe(true); + expect(onSuccess.notCalled).toBe(true); + }); + + it('opens window, calls the revokeFirst and then succeeds', async () => { + const props = { onFailure, onSuccess, text, revokeFirst: true }; + render(); + + await user.click(screen.getByRole('button')); + + expect(apiStub.calledOnce).toBe(true); + await waitFor(() => expect(windowStub.calledOnce).toBe(true)); + await waitFor(() => expect(focusStub.calledOnce).toBe(true)); + await waitFor(() => + window.dispatchEvent(createPostMessageEvent('success', window.origin)), + ); + + expect(closeStub.calledOnce).toBe(true); + expect(onSuccess.calledOnce).toBe(true); + expect(onFailure.notCalled).toBe(true); + }); + + it('opens window with revokeFirst and fails authorize', async () => { + const props = { onFailure, onSuccess, text, revokeFirst: true }; + render(); + + await user.click(screen.getByRole('button')); + + expect(apiStub.calledOnce).toBe(true); + await waitFor(() => expect(windowStub.calledOnce).toBe(true)); + await waitFor(() => expect(focusStub.calledOnce).toBe(true)); + await waitFor(() => + window.dispatchEvent(createPostMessageEvent('fail', window.origin)), + ); + + expect(closeStub.notCalled).toBe(true); + expect(onSuccess.notCalled).toBe(true); + expect(onFailure.calledOnce).toBe(true); + }); + + it('opens window with revokeFirst and fails origin', async () => { + const props = { onFailure, onSuccess, text, revokeFirst: true }; + render(); + + await user.click(screen.getByRole('button')); + + expect(apiStub.calledOnce).toBe(true); + await waitFor(() => expect(windowStub.calledOnce).toBe(true)); + await waitFor(() => expect(focusStub.calledOnce).toBe(true)); + await waitFor(() => + window.dispatchEvent(createPostMessageEvent('success', 'http://wrongorigin')), + ); + + expect(closeStub.notCalled).toBe(true); + expect(onSuccess.notCalled).toBe(true); + expect(onFailure.calledOnce).toBe(true); + }); + + it('opens window without revokeFirst and then succeeds', async () => { + const props = { onFailure, onSuccess, text }; + render(); + + await user.click(screen.getByRole('button')); + + expect(apiStub.notCalled).toBe(true); + await waitFor(() => expect(windowStub.calledOnce).toBe(true)); + await waitFor(() => expect(focusStub.calledOnce).toBe(true)); + await waitFor(() => + window.dispatchEvent(createPostMessageEvent('success', window.origin)), + ); + + expect(closeStub.calledOnce).toBe(true); + expect(onSuccess.calledOnce).toBe(true); + expect(onFailure.notCalled).toBe(true); + }); + + it('opens window without revokeFirst and fails authorize', async () => { + const props = { onFailure, onSuccess, text }; + render(); + + await user.click(screen.getByRole('button')); + + expect(apiStub.notCalled).toBe(true); + await waitFor(() => expect(windowStub.calledOnce).toBe(true)); + await waitFor(() => expect(focusStub.calledOnce).toBe(true)); + await waitFor(() => + window.dispatchEvent(createPostMessageEvent('fail', window.origin)), + ); + + expect(closeStub.notCalled).toBe(true); + expect(onSuccess.notCalled).toBe(true); + expect(onFailure.calledOnce).toBe(true); + }); + + it('opens window without revokeFirst and fails origin', async () => { + const props = { onFailure, onSuccess, text }; + render(); + + await user.click(screen.getByRole('button')); + + expect(apiStub.notCalled).toBe(true); + await waitFor(() => expect(windowStub.calledOnce).toBe(true)); + await waitFor(() => expect(focusStub.calledOnce).toBe(true)); + await waitFor(() => + window.dispatchEvent(createPostMessageEvent('success', 'http://wrongorigin')), + ); + + expect(closeStub.notCalled).toBe(true); + expect(onSuccess.notCalled).toBe(true); + expect(onFailure.calledOnce).toBe(true); + }); +});