From 0e7b529fbc88e8365fd6067e1c3215b901874cdc Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Mon, 12 Aug 2019 10:19:34 +0100 Subject: [PATCH 1/3] Add mock helpers for Range and Selection To add the text from an element to the clipboard you need to: 1. get the current Selection 1. create a Range from the contents of the element 2. clear any existing Ranges from the Selection and add the new Range to the selection 3. execute the 'copy' command To track calls to all the DOM APIs involved in this we need mocks for Range and Selection. Range: https://developer.mozilla.org/en-US/docs/Web/API/Range Selection: https://developer.mozilla.org/en-US/docs/Web/API/Selection Also includes a base class to help building out Web API interface mocks. --- tests/javascripts/support/helpers.js | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/javascripts/support/helpers.js b/tests/javascripts/support/helpers.js index 9e7b056a8..ee56ae375 100644 --- a/tests/javascripts/support/helpers.js +++ b/tests/javascripts/support/helpers.js @@ -227,6 +227,50 @@ class WindowMock { } } +// Base class for mocking an DOM APi interfaces not in JSDOM +class DOMInterfaceMock { + + constructor (jest, spec) { + + // set up methods so their calls can be tracked + // leave implementation/return values to the test + spec.methods.forEach(method => this[method] = jest.fn(() => {})); + + // set up props + // any spies should be relative to the test so not set here + spec.props.forEach(prop => { + + Object.defineProperty(this, prop, { + get: () => this[prop], + set: value => this[prop] = value + }); + + }); + + } + +} + +// Very basic class for stubbing the Range interface +// Only contains methods required for current tests +class RangeMock extends DOMInterfaceMock { + + constructor (jest) { + super(jest, { props: [], methods: ['selectNodeContents'] }); + } + +} + +// Very basic class for stubbing the Selection interface +// Only contains methods required for current tests +class SelectionMock extends DOMInterfaceMock { + + constructor (jest) { + super(jest, { props: [], methods: ['removeAllRanges', 'addRange'] }); + } + +} + // function to ask certain questions of a DOM Element const element = function (el) { return new ElementQuery(el); @@ -236,5 +280,7 @@ exports.triggerEvent = triggerEvent; exports.clickElementWithMouse = clickElementWithMouse; exports.moveSelectionToRadio = moveSelectionToRadio; exports.activateRadioWithSpace = activateRadioWithSpace; +exports.RangeMock = RangeMock; +exports.SelectionMock = SelectionMock; exports.element = element; exports.WindowMock = WindowMock; From cc70759a19621f31433844280d3ab8c90c20f781 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Mon, 12 Aug 2019 10:24:03 +0100 Subject: [PATCH 2/3] Add tests for API key module --- tests/javascripts/apiKey.test.js | 199 +++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/javascripts/apiKey.test.js diff --git a/tests/javascripts/apiKey.test.js b/tests/javascripts/apiKey.test.js new file mode 100644 index 000000000..4d693691b --- /dev/null +++ b/tests/javascripts/apiKey.test.js @@ -0,0 +1,199 @@ +const helpers = require('./support/helpers'); + +afterAll(() => { + + require('./support/teardown.js'); + + // clear up methods in the global space + document.queryCommandSupported = undefined; + +}); + +describe('API key', () => { + + let apiKey; + let thing; + let component; + + beforeEach(() => { + + apiKey = 'admin-service-6658542f-0cad-491f-bec8-ab8457700ead-53c0c274-8e83-48f1-8448-598657bb39af'; + thing = 'API key'; + + // mock sticky JS + window.GOVUK.stickAtBottomWhenScrolling = { + recalculate: jest.fn(() => {}) + } + + }); + + test("If copy command isn't available, nothing should happen", () => { + + // fake support for the copy command not being available + document.queryCommandSupported = jest.fn(command => false); + + require('../../app/assets/javascripts/apiKey.js'); + + // set up DOM + document.body.innerHTML =` +

+ ${thing} +

+
+ ${apiKey} +
`; + + component = document.querySelector('[data-module=api-key]'); + + // start the module + window.GOVUK.modules.start(); + + expect(component.querySelector('input[type=button]')).toBeNull(); + + }); + + describe("If copy command is available", () => { + + beforeAll(() => { + + // assume copy command is available + document.queryCommandSupported = jest.fn(command => command === 'copy'); + + // force module require to not come from cache + jest.resetModules(); + + require('../../app/assets/javascripts/apiKey.js'); + + }); + + beforeEach(() => { + + // set up DOM + document.body.innerHTML =` +

+ ${thing} +

+
+ ${apiKey} +
`; + + component = document.querySelector('[data-module=api-key]'); + + // start the module + window.GOVUK.modules.start(); + + }); + + describe("On page load", () => { + + test("It should add a button for copying the key to the clipboard", () => { + + expect(component.querySelector('input[type=button]')).not.toBeNull(); + + }); + + test("It should add the 'api-key' class", () => { + + expect(component.classList.contains('api-key')).toBe(true); + + }); + + test("It should change aria-live to 'polite'", () => { + + expect(component.getAttribute('aria-live')).toEqual('polite'); + + }); + + test("It should tell any sticky JS present the page has changed", () => { + + // recalculate forces the sticky JS to recalculate any stored DOM position/dimensions + expect(window.GOVUK.stickAtBottomWhenScrolling.recalculate).toHaveBeenCalled(); + + }); + + }); + + describe("If you click the 'Copy API key to clipboard' button", () => { + + let selectionMock; + let rangeMock; + let keyEl; + let copyButton; + + beforeEach(() => { + + keyEl = component.querySelector('span'); + copyButton = component.querySelector('input[type=button]'); + + // mock objects used to manipulate the page selection + selectionMock = new helpers.SelectionMock(jest); + rangeMock = new helpers.RangeMock(jest); + + // plug gaps in JSDOM's API for manipulation of selections + window.getSelection = jest.fn(() => selectionMock); + document.createRange = jest.fn(() => rangeMock); + + // plug JSDOM not having execCommand + document.execCommand = jest.fn(() => {}); + + helpers.triggerEvent(copyButton, 'click'); + + }); + + test("It should change the text to confirm the copy action", () => { + + expect(component.querySelector('span').textContent.trim()).toEqual('Copied to clipboard'); + + }); + + test("It should swap the button for one to show the API key", () => { + + expect(component.querySelector('input[type=button]').getAttribute('value')).toEqual('Show API key'); + + }); + + test("It should copy the key to the clipboard", () => { + + // it should make a selection (a range) from the contents of the element containing the API key + expect(rangeMock.selectNodeContents.mock.calls[0]).toEqual([keyEl]); + + // that selection (a range) should be added to that for the page (a selection) + expect(selectionMock.addRange.mock.calls[0]).toEqual([rangeMock]); + + expect(document.execCommand).toHaveBeenCalled(); + expect(document.execCommand.mock.calls[0]).toEqual(['copy']); + + // reset any methods in the global space + window.queryCommandSupported = undefined; + window.getSelection = undefined; + document.createRange = undefined; + + }); + + describe("If you then click the 'Show API key'", () => { + + beforeEach(() => { + + helpers.triggerEvent(component.querySelector('input[type=button]'), 'click'); + + }); + + test("It should change the text to show the API key", () => { + + expect(component.querySelector('span').textContent.trim()).toEqual(apiKey); + + }); + + test("It should swap the button for one to copy the key to the clipboard", () => { + + expect(component.querySelector('input[type=button]').getAttribute('value')).toEqual('Copy API key to clipboard'); + + }) + + }); + + }); + + }); + +}); From ddd8da016302ce86a4d1fbb877b4294c2331a978 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 20 Aug 2019 13:45:56 +0100 Subject: [PATCH 3/3] Add test for controlling height between states The button shouldn't change its vertical position when the state changes. The text confirming the copy is just one line so setting height for both based on the API key, which can run to 2 lines makes sense. Explained in this PR: https://github.com/alphagov/notifications-admin/pull/2428 --- tests/javascripts/apiKey.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/javascripts/apiKey.test.js b/tests/javascripts/apiKey.test.js index 4d693691b..34f7509c8 100644 --- a/tests/javascripts/apiKey.test.js +++ b/tests/javascripts/apiKey.test.js @@ -54,6 +54,8 @@ describe('API key', () => { describe("If copy command is available", () => { + let componentHeightOnLoad; + beforeAll(() => { // assume copy command is available @@ -79,6 +81,10 @@ describe('API key', () => { component = document.querySelector('[data-module=api-key]'); + // mock DOM API called for element height + componentHeightOnLoad = 50; + jest.spyOn(component, 'offsetHeight', 'get').mockImplementation(() => componentHeightOnLoad); + // start the module window.GOVUK.modules.start(); @@ -111,6 +117,13 @@ describe('API key', () => { }); + test("It should set the component's minimum height based on its height when the page loads", () => { + + // to prevent the position of the button moving when the state changes + expect(window.getComputedStyle(component)['min-height']).toEqual(`${componentHeightOnLoad}px`); + + }); + }); describe("If you click the 'Copy API key to clipboard' button", () => {