From 9990a96cd8bccac25ab8d55f40ceb4c2256f45a6 Mon Sep 17 00:00:00 2001 From: Leena Gupte Date: Mon, 28 Feb 2022 17:20:18 +0000 Subject: [PATCH 1/7] Install Jasmine Ajax This will be required to fake responses from XMLHttpRequest once jQuery has been removed from webchat.js and webchat/library.js as we will no longer be able to spyOn ajax and create a fake response. --- spec/javascripts/support/jasmine.yml | 1 + spec/javascripts/vendor/jasmine-ajax-3.4.0.js | 839 ++++++++++++++++++ 2 files changed, 840 insertions(+) create mode 100644 spec/javascripts/vendor/jasmine-ajax-3.4.0.js diff --git a/spec/javascripts/support/jasmine.yml b/spec/javascripts/support/jasmine.yml index 8d63e7fd2..96ed7f147 100644 --- a/spec/javascripts/support/jasmine.yml +++ b/spec/javascripts/support/jasmine.yml @@ -14,6 +14,7 @@ src_files: - "spec/javascripts/vendor/jquery-1.12.4.js" - "spec/javascripts/vendor/jasmine-jquery-2.0.5.js" - "spec/javascripts/vendor/lolex.js" + - "spec/javascripts/vendor/jasmine-ajax-3.4.0.js" - "assets/application.js" - "assets/webchat.js" diff --git a/spec/javascripts/vendor/jasmine-ajax-3.4.0.js b/spec/javascripts/vendor/jasmine-ajax-3.4.0.js new file mode 100644 index 000000000..410712be2 --- /dev/null +++ b/spec/javascripts/vendor/jasmine-ajax-3.4.0.js @@ -0,0 +1,839 @@ +/* + +Jasmine-Ajax - v3.4.0: a set of helpers for testing AJAX requests under the Jasmine +BDD framework for JavaScript. + +http://github.com/jasmine/jasmine-ajax + +Jasmine Home page: http://jasmine.github.io/ + +Copyright (c) 2008-2015 Pivotal Labs + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +*/ +// jshint latedef: nofunc + +//Module wrapper to support both browser and CommonJS environment +(function (root, factory) { + // if (typeof exports === 'object' && typeof exports.nodeName !== 'string') { + // // CommonJS + // var jasmineRequire = require('jasmine-core'); + // module.exports = factory(root, function() { + // return jasmineRequire; + // }); + // } else { + // Browser globals + window.MockAjax = factory(root, getJasmineRequireObj); + // } +}(typeof window !== 'undefined' ? window : global, function (global, getJasmineRequireObj) { + +// +getJasmineRequireObj().ajax = function(jRequire) { + var $ajax = {}; + + $ajax.RequestStub = jRequire.AjaxRequestStub(); + $ajax.RequestTracker = jRequire.AjaxRequestTracker(); + $ajax.StubTracker = jRequire.AjaxStubTracker(); + $ajax.ParamParser = jRequire.AjaxParamParser(); + $ajax.event = jRequire.AjaxEvent(); + $ajax.eventBus = jRequire.AjaxEventBus($ajax.event); + $ajax.fakeRequest = jRequire.AjaxFakeRequest($ajax.eventBus); + $ajax.MockAjax = jRequire.MockAjax($ajax); + + return $ajax.MockAjax; +}; + +getJasmineRequireObj().AjaxEvent = function() { + function now() { + return new Date().getTime(); + } + + function noop() { + } + + // Event object + // https://dom.spec.whatwg.org/#concept-event + function XMLHttpRequestEvent(xhr, type) { + this.type = type; + this.bubbles = false; + this.cancelable = false; + this.timeStamp = now(); + + this.isTrusted = false; + this.defaultPrevented = false; + + // Event phase should be "AT_TARGET" + // https://dom.spec.whatwg.org/#dom-event-at_target + this.eventPhase = 2; + + this.target = xhr; + this.currentTarget = xhr; + } + + XMLHttpRequestEvent.prototype.preventDefault = noop; + XMLHttpRequestEvent.prototype.stopPropagation = noop; + XMLHttpRequestEvent.prototype.stopImmediatePropagation = noop; + + function XMLHttpRequestProgressEvent() { + XMLHttpRequestEvent.apply(this, arguments); + + this.lengthComputable = false; + this.loaded = 0; + this.total = 0; + } + + // Extend prototype + XMLHttpRequestProgressEvent.prototype = XMLHttpRequestEvent.prototype; + + return { + event: function(xhr, type) { + return new XMLHttpRequestEvent(xhr, type); + }, + + progressEvent: function(xhr, type) { + return new XMLHttpRequestProgressEvent(xhr, type); + } + }; +}; +getJasmineRequireObj().AjaxEventBus = function(eventFactory) { + function EventBus(source) { + this.eventList = {}; + this.source = source; + } + + function ensureEvent(eventList, name) { + eventList[name] = eventList[name] || []; + return eventList[name]; + } + + function findIndex(list, thing) { + if (list.indexOf) { + return list.indexOf(thing); + } + + for(var i = 0; i < list.length; i++) { + if (thing === list[i]) { + return i; + } + } + + return -1; + } + + EventBus.prototype.addEventListener = function(event, callback) { + ensureEvent(this.eventList, event).push(callback); + }; + + EventBus.prototype.removeEventListener = function(event, callback) { + var index = findIndex(this.eventList[event], callback); + + if (index >= 0) { + this.eventList[event].splice(index, 1); + } + }; + + EventBus.prototype.trigger = function(event) { + var evt; + + // Event 'readystatechange' is should be a simple event. + // Others are progress event. + // https://xhr.spec.whatwg.org/#events + if (event === 'readystatechange') { + evt = eventFactory.event(this.source, event); + } else { + evt = eventFactory.progressEvent(this.source, event); + } + + var eventListeners = this.eventList[event]; + + if (eventListeners) { + for (var i = 0; i < eventListeners.length; i++) { + eventListeners[i].call(this.source, evt); + } + } + }; + + return function(source) { + return new EventBus(source); + }; +}; + +getJasmineRequireObj().AjaxFakeRequest = function(eventBusFactory) { + function extend(destination, source, propertiesToSkip) { + propertiesToSkip = propertiesToSkip || []; + for (var property in source) { + if (!arrayContains(propertiesToSkip, property)) { + destination[property] = source[property]; + } + } + return destination; + } + + function arrayContains(arr, item) { + for (var i = 0; i < arr.length; i++) { + if (arr[i] === item) { + return true; + } + } + return false; + } + + function wrapProgressEvent(xhr, eventName) { + return function() { + if (xhr[eventName]) { + xhr[eventName].apply(xhr, arguments); + } + }; + } + + function initializeEvents(xhr) { + xhr.eventBus.addEventListener('readystatechange', wrapProgressEvent(xhr, 'onreadystatechange')); + xhr.eventBus.addEventListener('loadstart', wrapProgressEvent(xhr, 'onloadstart')); + xhr.eventBus.addEventListener('load', wrapProgressEvent(xhr, 'onload')); + xhr.eventBus.addEventListener('loadend', wrapProgressEvent(xhr, 'onloadend')); + xhr.eventBus.addEventListener('progress', wrapProgressEvent(xhr, 'onprogress')); + xhr.eventBus.addEventListener('error', wrapProgressEvent(xhr, 'onerror')); + xhr.eventBus.addEventListener('abort', wrapProgressEvent(xhr, 'onabort')); + xhr.eventBus.addEventListener('timeout', wrapProgressEvent(xhr, 'ontimeout')); + } + + function unconvertibleResponseTypeMessage(type) { + var msg = [ + "Can't build XHR.response for XHR.responseType of '", + type, + "'.", + "XHR.response must be explicitly stubbed" + ]; + return msg.join(' '); + } + + function fakeRequest(global, requestTracker, stubTracker, paramParser) { + function FakeXMLHttpRequest() { + requestTracker.track(this); + this.eventBus = eventBusFactory(this); + initializeEvents(this); + this.requestHeaders = {}; + this.overriddenMimeType = null; + } + + function findHeader(name, headers) { + name = name.toLowerCase(); + for (var header in headers) { + if (header.toLowerCase() === name) { + return headers[header]; + } + } + } + + function normalizeHeaders(rawHeaders, contentType) { + var headers = []; + + if (rawHeaders) { + if (rawHeaders instanceof Array) { + headers = rawHeaders; + } else { + for (var headerName in rawHeaders) { + if (rawHeaders.hasOwnProperty(headerName)) { + headers.push({ name: headerName, value: rawHeaders[headerName] }); + } + } + } + } else { + headers.push({ name: "Content-Type", value: contentType || "application/json" }); + } + + return headers; + } + + function parseXml(xmlText, contentType) { + if (global.DOMParser) { + return (new global.DOMParser()).parseFromString(xmlText, 'text/xml'); + } else { + var xml = new global.ActiveXObject("Microsoft.XMLDOM"); + xml.async = "false"; + xml.loadXML(xmlText); + return xml; + } + } + + var xmlParsables = ['text/xml', 'application/xml']; + + function getResponseXml(responseText, contentType) { + if (arrayContains(xmlParsables, contentType.toLowerCase())) { + return parseXml(responseText, contentType); + } else if (contentType.match(/\+xml$/)) { + return parseXml(responseText, 'text/xml'); + } + return null; + } + +extend(FakeXMLHttpRequest, { + UNSENT: 0, + OPENED: 1, + HEADERS_RECEIVED: 2, + LOADING: 3, + DONE: 4 + }); + + var iePropertiesThatCannotBeCopied = ['responseBody', 'responseText', 'responseXML', 'status', 'statusText', 'responseTimeout', 'responseURL']; + extend(FakeXMLHttpRequest.prototype, new global.XMLHttpRequest(), iePropertiesThatCannotBeCopied); + extend(FakeXMLHttpRequest.prototype, { + open: function() { + this.method = arguments[0]; + this.url = arguments[1] + ''; + this.username = arguments[3]; + this.password = arguments[4]; + this.readyState = FakeXMLHttpRequest.OPENED; + this.requestHeaders = {}; + this.eventBus.trigger('readystatechange'); + }, + + setRequestHeader: function(header, value) { + if (this.readyState === 0) { + throw new Error('DOMException: Failed to execute "setRequestHeader" on "XMLHttpRequest": The object\'s state must be OPENED.'); + } + + if(this.requestHeaders.hasOwnProperty(header)) { + this.requestHeaders[header] = [this.requestHeaders[header], value].join(', '); + } else { + this.requestHeaders[header] = value; + } + }, + + overrideMimeType: function(mime) { + this.overriddenMimeType = mime; + }, + + abort: function() { + this.readyState = FakeXMLHttpRequest.UNSENT; + this.status = 0; + this.statusText = "abort"; + this.eventBus.trigger('readystatechange'); + this.eventBus.trigger('progress'); + this.eventBus.trigger('abort'); + this.eventBus.trigger('loadend'); + }, + + readyState: FakeXMLHttpRequest.UNSENT, + + onloadstart: null, + onprogress: null, + onabort: null, + onerror: null, + onload: null, + ontimeout: null, + onloadend: null, + onreadystatechange: null, + + addEventListener: function() { + this.eventBus.addEventListener.apply(this.eventBus, arguments); + }, + + removeEventListener: function(event, callback) { + this.eventBus.removeEventListener.apply(this.eventBus, arguments); + }, + + status: null, + + send: function(data) { + this.params = data; + this.eventBus.trigger('loadstart'); + + var stub = stubTracker.findStub(this.url, data, this.method); + if (stub) { + stub.handleRequest(this); + } + }, + + contentType: function() { + return findHeader('content-type', this.requestHeaders); + }, + + data: function() { + if (!this.params) { + return {}; + } + + return paramParser.findParser(this).parse(this.params); + }, + + getResponseHeader: function(name) { + var resultHeader = null; + if (!this.responseHeaders) { return resultHeader; } + + name = name.toLowerCase(); + for(var i = 0; i < this.responseHeaders.length; i++) { + var header = this.responseHeaders[i]; + if (name === header.name.toLowerCase()) { + if (resultHeader) { + resultHeader = [resultHeader, header.value].join(', '); + } else { + resultHeader = header.value; + } + } + } + return resultHeader; + }, + + getAllResponseHeaders: function() { + if (!this.responseHeaders) { return null; } + + var responseHeaders = []; + for (var i = 0; i < this.responseHeaders.length; i++) { + responseHeaders.push(this.responseHeaders[i].name + ': ' + + this.responseHeaders[i].value); + } + return responseHeaders.join('\r\n') + '\r\n'; + }, + + responseText: null, + response: null, + responseType: null, + responseURL: null, + + responseValue: function() { + switch(this.responseType) { + case null: + case "": + case "text": + return this.readyState >= FakeXMLHttpRequest.LOADING ? this.responseText : ""; + case "json": + return JSON.parse(this.responseText); + case "arraybuffer": + throw unconvertibleResponseTypeMessage('arraybuffer'); + case "blob": + throw unconvertibleResponseTypeMessage('blob'); + case "document": + return this.responseXML; + } + }, + + + respondWith: function(response) { + if (this.readyState === FakeXMLHttpRequest.DONE) { + throw new Error("FakeXMLHttpRequest already completed"); + } + + this.status = response.status; + this.statusText = response.statusText || ""; + this.responseHeaders = normalizeHeaders(response.responseHeaders, response.contentType); + this.readyState = FakeXMLHttpRequest.HEADERS_RECEIVED; + this.eventBus.trigger('readystatechange'); + + this.responseText = response.responseText || ""; + this.responseType = response.responseType || ""; + this.responseURL = response.responseURL || null; + this.readyState = FakeXMLHttpRequest.DONE; + this.responseXML = getResponseXml(response.responseText, this.getResponseHeader('content-type') || ''); + if (this.responseXML) { + this.responseType = 'document'; + } + if (response.responseJSON) { + this.responseText = JSON.stringify(response.responseJSON); + } + + if ('response' in response) { + this.response = response.response; + } else { + this.response = this.responseValue(); + } + + this.eventBus.trigger('readystatechange'); + this.eventBus.trigger('progress'); + this.eventBus.trigger('load'); + this.eventBus.trigger('loadend'); + }, + + responseTimeout: function() { + if (this.readyState === FakeXMLHttpRequest.DONE) { + throw new Error("FakeXMLHttpRequest already completed"); + } + this.readyState = FakeXMLHttpRequest.DONE; + jasmine.clock().tick(30000); + this.eventBus.trigger('readystatechange'); + this.eventBus.trigger('progress'); + this.eventBus.trigger('timeout'); + this.eventBus.trigger('loadend'); + }, + + responseError: function(response) { + if (!response) { + response = {}; + } + if (this.readyState === FakeXMLHttpRequest.DONE) { + throw new Error("FakeXMLHttpRequest already completed"); + } + this.status = response.status; + this.statusText = response.statusText || ""; + this.readyState = FakeXMLHttpRequest.DONE; + this.eventBus.trigger('readystatechange'); + this.eventBus.trigger('progress'); + this.eventBus.trigger('error'); + this.eventBus.trigger('loadend'); + }, + + startStream: function(options) { + if (!options) { + options = {}; + } + + if (this.readyState >= FakeXMLHttpRequest.LOADING) { + throw new Error("FakeXMLHttpRequest already loading or finished"); + } + + this.status = 200; + this.responseText = ""; + this.statusText = ""; + + this.responseHeaders = normalizeHeaders(options.responseHeaders, options.contentType); + this.readyState = FakeXMLHttpRequest.HEADERS_RECEIVED; + this.eventBus.trigger('readystatechange'); + + this.responseType = options.responseType || ""; + this.responseURL = options.responseURL || null; + this.readyState = FakeXMLHttpRequest.LOADING; + this.eventBus.trigger('readystatechange'); + }, + + streamData: function(data) { + if (this.readyState !== FakeXMLHttpRequest.LOADING) { + throw new Error("FakeXMLHttpRequest is not loading yet"); + } + + this.responseText += data; + this.responseXML = getResponseXml(this.responseText, this.getResponseHeader('content-type') || ''); + if (this.responseXML) { + this.responseType = 'document'; + } + + this.response = this.responseValue(); + + this.eventBus.trigger('readystatechange'); + this.eventBus.trigger('progress'); + }, + + cancelStream: function () { + if (this.readyState === FakeXMLHttpRequest.DONE) { + throw new Error("FakeXMLHttpRequest already completed"); + } + + this.status = 0; + this.statusText = ""; + this.readyState = FakeXMLHttpRequest.DONE; + this.eventBus.trigger('readystatechange'); + this.eventBus.trigger('progress'); + this.eventBus.trigger('loadend'); + }, + + completeStream: function(status) { + if (this.readyState === FakeXMLHttpRequest.DONE) { + throw new Error("FakeXMLHttpRequest already completed"); + } + + this.status = status || 200; + this.statusText = ""; + this.readyState = FakeXMLHttpRequest.DONE; + this.eventBus.trigger('readystatechange'); + this.eventBus.trigger('progress'); + this.eventBus.trigger('loadend'); + } + }); + + return FakeXMLHttpRequest; + } + + return fakeRequest; +}; + +getJasmineRequireObj().MockAjax = function($ajax) { + function MockAjax(global) { + var requestTracker = new $ajax.RequestTracker(), + stubTracker = new $ajax.StubTracker(), + paramParser = new $ajax.ParamParser(), + realAjaxFunction = global.XMLHttpRequest, + mockAjaxFunction = $ajax.fakeRequest(global, requestTracker, stubTracker, paramParser); + + this.install = function() { + if (global.XMLHttpRequest !== realAjaxFunction) { + throw new Error("Jasmine Ajax was unable to install over a custom XMLHttpRequest. Is Jasmine Ajax already installed?"); + } + + global.XMLHttpRequest = mockAjaxFunction; + }; + + this.uninstall = function() { + if (global.XMLHttpRequest !== mockAjaxFunction) { + throw new Error("MockAjax not installed."); + } + global.XMLHttpRequest = realAjaxFunction; + + this.stubs.reset(); + this.requests.reset(); + paramParser.reset(); + }; + + this.stubRequest = function(url, data, method) { + var stub = new $ajax.RequestStub(url, data, method); + stubTracker.addStub(stub); + return stub; + }; + + this.withMock = function(closure) { + this.install(); + try { + closure(); + } finally { + this.uninstall(); + } + }; + + this.addCustomParamParser = function(parser) { + paramParser.add(parser); + }; + + this.requests = requestTracker; + this.stubs = stubTracker; + } + + return MockAjax; +}; + +getJasmineRequireObj().AjaxParamParser = function() { + function ParamParser() { + var defaults = [ + { + test: function(xhr) { + return (/^application\/json/).test(xhr.contentType()); + }, + parse: function jsonParser(paramString) { + return JSON.parse(paramString); + } + }, + { + test: function(xhr) { + return true; + }, + parse: function naiveParser(paramString) { + var data = {}; + var params = paramString.split('&'); + + for (var i = 0; i < params.length; ++i) { + var kv = params[i].replace(/\+/g, ' ').split('='); + var key = decodeURIComponent(kv[0]); + data[key] = data[key] || []; + data[key].push(decodeURIComponent(kv[1])); + } + return data; + } + } + ]; + var paramParsers = []; + + this.add = function(parser) { + paramParsers.unshift(parser); + }; + + this.findParser = function(xhr) { + for(var i in paramParsers) { + var parser = paramParsers[i]; + if (parser.test(xhr)) { + return parser; + } + } + }; + + this.reset = function() { + paramParsers = []; + for(var i in defaults) { + paramParsers.push(defaults[i]); + } + }; + + this.reset(); + } + + return ParamParser; +}; + +getJasmineRequireObj().AjaxRequestStub = function() { + var RETURN = 0, + ERROR = 1, + TIMEOUT = 2, + CALL = 3; + + var normalizeQuery = function(query) { + return query ? query.split('&').sort().join('&') : undefined; + }; + + var timeoutRequest = function(request) { + request.responseTimeout(); + }; + + function RequestStub(url, stubData, method) { + if (url instanceof RegExp) { + this.url = url; + this.query = undefined; + } else { + var split = url.split('?'); + this.url = split[0]; + this.query = split.length > 1 ? normalizeQuery(split[1]) : undefined; + } + + this.data = (stubData instanceof RegExp) ? stubData : normalizeQuery(stubData); + this.method = method; + } + + RequestStub.prototype = { + andReturn: function(options) { + options.status = (typeof options.status !== 'undefined') ? options.status : 200; + this.handleRequest = function(request) { + request.respondWith(options); + }; + }, + + andError: function(options) { + if (!options) { + options = {}; + } + options.status = options.status || 500; + this.handleRequest = function(request) { + request.responseError(options); + }; + }, + + andTimeout: function() { + this.handleRequest = timeoutRequest; + }, + + andCallFunction: function(functionToCall) { + this.handleRequest = function(request) { + functionToCall(request); + }; + }, + + matches: function(fullUrl, data, method) { + var urlMatches = false; + fullUrl = fullUrl.toString(); + if (this.url instanceof RegExp) { + urlMatches = this.url.test(fullUrl); + } else { + var urlSplit = fullUrl.split('?'), + url = urlSplit[0], + query = urlSplit[1]; + urlMatches = this.url === url && this.query === normalizeQuery(query); + } + var dataMatches = false; + if (this.data instanceof RegExp) { + dataMatches = this.data.test(data); + } else { + dataMatches = !this.data || this.data === normalizeQuery(data); + } + return urlMatches && dataMatches && (!this.method || this.method === method); + } + }; + + return RequestStub; +}; + +getJasmineRequireObj().AjaxRequestTracker = function() { + function RequestTracker() { + var requests = []; + + this.track = function(request) { + requests.push(request); + }; + + this.first = function() { + return requests[0]; + }; + + this.count = function() { + return requests.length; + }; + + this.reset = function() { + requests = []; + }; + + this.mostRecent = function() { + return requests[requests.length - 1]; + }; + + this.at = function(index) { + return requests[index]; + }; + + this.filter = function(url_to_match) { + var matching_requests = []; + + for (var i = 0; i < requests.length; i++) { + if (url_to_match instanceof RegExp && + url_to_match.test(requests[i].url)) { + matching_requests.push(requests[i]); + } else if (url_to_match instanceof Function && + url_to_match(requests[i])) { + matching_requests.push(requests[i]); + } else { + if (requests[i].url === url_to_match) { + matching_requests.push(requests[i]); + } + } + } + + return matching_requests; + }; + } + + return RequestTracker; +}; + +getJasmineRequireObj().AjaxStubTracker = function() { + function StubTracker() { + var stubs = []; + + this.addStub = function(stub) { + stubs.push(stub); + }; + + this.reset = function() { + stubs = []; + }; + + this.findStub = function(url, data, method) { + for (var i = stubs.length - 1; i >= 0; i--) { + var stub = stubs[i]; + if (stub.matches(url, data, method)) { + return stub; + } + } + }; + } + + return StubTracker; +}; + + + var jRequire = getJasmineRequireObj(); + var MockAjax = jRequire.ajax(jRequire); + jasmine.Ajax = new MockAjax(global); + + return MockAjax; +})); \ No newline at end of file From a9378e43bbcc2194b722c3353742bcf26224f085 Mon Sep 17 00:00:00 2001 From: Leena Gupte Date: Thu, 17 Feb 2022 13:45:31 +0000 Subject: [PATCH 2/7] Replace jQuery map function in webchat.js The linter threw an error as the creation of a new GOVUK.Webchat object is not being stored in a variable. Storing a variable isn't appropriate in this case as it would not be used, so the linter is being ignored. See: https://stackoverflow.com/questions/33287045/eslint-suppress-do-not-use-new-for-side-effects --- app/assets/javascripts/webchat.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/webchat.js b/app/assets/javascripts/webchat.js index 9c27c1a0d..5573b22ba 100644 --- a/app/assets/javascripts/webchat.js +++ b/app/assets/javascripts/webchat.js @@ -5,10 +5,10 @@ var $ = window.$ $(document).ready(function () { var GOVUK = window.GOVUK if (GOVUK.Webchat) { - $('.js-webchat').map(function () { - return new GOVUK.Webchat({ - $el: $(this) - }) - }) + var webchats = document.querySelectorAll('.js-webchat') + for (var i = 0; i < webchats.length; i++) { + /* eslint-disable no-new */ + new GOVUK.Webchat(webchats[i]) + } } }) From 16fbf631597e2ab9ceab302fa0fdc899239f1a70 Mon Sep 17 00:00:00 2001 From: Leena Gupte Date: Fri, 4 Mar 2022 15:10:28 +0000 Subject: [PATCH 3/7] Remove jQuery ready function The "ready" function in jQuery waits for the DOM tree to load, i.e. be "ready" before moving on. The equivalent to this in vanilla JS is to listen for the "DOMContentLoaded" event. However, this script works without an event listener on "DOMContentLoaded", so the ready function has just been removed. --- app/assets/javascripts/webchat.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/webchat.js b/app/assets/javascripts/webchat.js index 5573b22ba..3c21dd50f 100644 --- a/app/assets/javascripts/webchat.js +++ b/app/assets/javascripts/webchat.js @@ -1,14 +1,10 @@ //= require ./webchat/library.js -var $ = window.$ - -$(document).ready(function () { - var GOVUK = window.GOVUK - if (GOVUK.Webchat) { - var webchats = document.querySelectorAll('.js-webchat') - for (var i = 0; i < webchats.length; i++) { - /* eslint-disable no-new */ - new GOVUK.Webchat(webchats[i]) - } +var GOVUK = window.GOVUK +if (GOVUK.Webchat) { + var webchats = document.querySelectorAll('.js-webchat') + for (var i = 0; i < webchats.length; i++) { + /* eslint-disable no-new */ + new GOVUK.Webchat(webchats[i]) } -}) +} From 66d7a90287aa4caddbadd45a4526e112feb86d43 Mon Sep 17 00:00:00 2001 From: Leena Gupte Date: Fri, 4 Mar 2022 15:11:12 +0000 Subject: [PATCH 4/7] Start removing jQuery from webchat/library.js This commit replaces like for like jQuery methods with vanilla JS. Ajax will be removed in a separate commit. --- app/assets/javascripts/webchat/library.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/webchat/library.js b/app/assets/javascripts/webchat/library.js index cc7c3cace..82ea018ad 100644 --- a/app/assets/javascripts/webchat/library.js +++ b/app/assets/javascripts/webchat/library.js @@ -1,10 +1,7 @@ (function (global) { - 'use strict' - var $ = global.jQuery - if (typeof global.GOVUK === 'undefined') { global.GOVUK = {} } - var GOVUK = global.GOVUK + var GOVUK = global.GOVUK || {} - function Webchat (options) { + function Webchat (el) { var POLL_INTERVAL = 5 * 1000 var AJAX_TIMEOUT = 5 * 1000 var API_STATES = [ @@ -15,10 +12,9 @@ 'OFFLINE', 'ONLINE' ] - var $el = $(options.$el) - var openUrl = $el.attr('data-open-url') - var availabilityUrl = $el.attr('data-availability-url') - var $openButton = $el.find('.js-webchat-open-button') + var openUrl = el.getAttribute('data-open-url') + var availabilityUrl = el.getAttribute('data-availability-url') + var openButton = document.querySelector('.js-webchat-open-button') var webchatStateClass = 'js-webchat-advisers-' var intervalID = null var lastRecordedState = null @@ -27,14 +23,18 @@ if (!availabilityUrl || !openUrl) { throw Error.new('urls for webchat not defined') } - $openButton.on('click', handleOpenChat) + + if (openButton) { + openButton.addEventListener('click', handleOpenChat) + } intervalID = setInterval(checkAvailability, POLL_INTERVAL) checkAvailability() } function handleOpenChat (evt) { evt.preventDefault() - this.dataset.redirect === 'true' ? window.location.href = openUrl : global.open(openUrl, 'newwin', 'width=366,height=516') + var redirect = this.getAttribute('data-redirect') + redirect === 'true' ? window.location.href = openUrl : window.open(openUrl, 'newwin', 'width=366,height=516') trackEvent('opened') } From 55161938ac4e03e0bbdc28055a4270ebc2532120 Mon Sep 17 00:00:00 2001 From: Leena Gupte Date: Fri, 4 Mar 2022 15:19:43 +0000 Subject: [PATCH 5/7] Remove jQuery from advisorStateChange function --- app/assets/javascripts/webchat/library.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/webchat/library.js b/app/assets/javascripts/webchat/library.js index 82ea018ad..c8d0587cc 100644 --- a/app/assets/javascripts/webchat/library.js +++ b/app/assets/javascripts/webchat/library.js @@ -90,9 +90,13 @@ function advisorStateChange (state) { state = state.toLowerCase() - var currentState = $el.find('.' + webchatStateClass + state) - $el.find('[class^="' + webchatStateClass + '"]').addClass('govuk-!-display-none') - currentState.removeClass('govuk-!-display-none') + var currentState = el.querySelector('[class^="' + webchatStateClass + state + '"]') + var allStates = el.querySelectorAll('[class^="' + webchatStateClass + '"]') + + for (var index = 0; index < allStates.length; index++) { + allStates[index].classList.add('govuk-!-display-none') + } + currentState.classList.remove('govuk-!-display-none') trackEvent(state) } From 82e1917e6919c7ecadda7023643cdf7352c2946d Mon Sep 17 00:00:00 2001 From: Leena Gupte Date: Fri, 4 Mar 2022 16:23:32 +0000 Subject: [PATCH 6/7] Remove ajax from library/webchat.js Creates an XMLHttpRequest object to make the request to the availability url. We need to use XMLHttpRequest rather than the new "await" and "fetch" functions as they are not supported in Internet Explorer. There is only one webchat page that this script runs on: /government/organisations/hm-passport-office/contact/hm-passport-office-webchat The [availabilityURL] always returns a response of: ``` { "status": "success", "response": "UNAVAILABLE" } ``` with an http status of 200. This seems incorrect as the [openUrl] states that all advisors are busy, so we would expect the availabilityURL to return a status of "BUSY". There is an [open ticket] to change the URLs, so hopefully that will fix the problem. The removal of jQuery in this change has not negatively affected the workings of Webchat. The behaviour hasn't been affected, even if that behaviour is incorrect. The XMLHttpRequest is still being called asynchronously like the old ajax call ("true" in the call to request.open). `readyState` and the `status` returned by the XMLHttpRequest object are used to determine whether the request has been successful. That means how the response is being stubbed in the tests has been changed to match. The "mount" function is doing the equivalent of the webchat.js script, so it has been updated to match that script. The jsonNormalisedError object had to be updated in the tests, as a string without a status was not an accepted response for XMLHttpRequest. As ajax has been removed, it can no longer be spied on in the tests. However, the tests still use jQuery, so jasmine Ajax (installed in a prior commit) can be used define fake responses from XMLHttpRequest. XMLHttpRequest makes two calls, to open and send, it was easier to stub the response of the most recent call rather than trying to mock both. This means that in test "should only track once per state change" that tests the URL polling, we only need to change the "most recent" ajax stub when the response being returned changes. It was much simpler to just call the "most recent" stub twice than try to replicate the operation of the fake function. [availabilityURL]: https://hmpowebchat.klick2contact.com/v03/providers/HMPO/api/availability.php [openUrl]: https://hmpowebchat.klick2contact.com/v03/launcherV3.php?p=HMPO&d=717&ch=CH&psk=chat_a1&iid=STC&srbp=0&fcl=0&r=Chat&s=https://hmpowebchat.klick2contact.com/v03&u=&wo=&uh=&pid=2&iif=0 [open ticket]: https://github.com/alphagov/government-frontend/pull/2358 --- app/assets/javascripts/webchat/library.js | 21 +++++--- spec/javascripts/webchat.spec.js | 62 +++++++++-------------- 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/app/assets/javascripts/webchat/library.js b/app/assets/javascripts/webchat/library.js index c8d0587cc..fe3e169cc 100644 --- a/app/assets/javascripts/webchat/library.js +++ b/app/assets/javascripts/webchat/library.js @@ -39,14 +39,21 @@ } function checkAvailability () { - var ajaxConfig = { - url: availabilityUrl, - type: 'GET', - timeout: AJAX_TIMEOUT, - success: apiSuccess, - error: apiError + var done = function () { + if (request.readyState === 4 && request.status === 200) { + apiSuccess(JSON.parse(request.response)) + } else { + apiError() + } } - $.ajax(ajaxConfig) + + var request = new XMLHttpRequest() + request.open('GET', availabilityUrl, true) + request.setRequestHeader('Content-type', 'application/x-www-form-urlencoded') + request.addEventListener('load', done.bind(this)) + request.timeout = AJAX_TIMEOUT + + request.send() } function apiSuccess (result) { diff --git a/spec/javascripts/webchat.spec.js b/spec/javascripts/webchat.spec.js index e3860c275..77860ec4e 100644 --- a/spec/javascripts/webchat.spec.js +++ b/spec/javascripts/webchat.spec.js @@ -26,17 +26,18 @@ describe('Webchat', function () { var jsonNormalised = function (status, response) { return { - status: status, - response: response + status: 200, + response: '{"status":"' + status + '","response":"' + response + '"}' } } var jsonNormalisedAvailable = jsonNormalised('success', 'AVAILABLE') var jsonNormalisedUnavailable = jsonNormalised('success', 'UNAVAILABLE') var jsonNormalisedBusy = jsonNormalised('success', 'BUSY') - var jsonNormalisedError = '404 not found' + var jsonNormalisedError = [404, {}, '404 not found'] beforeEach(function () { + jasmine.Ajax.install() setFixtures(INSERTION_HOOK) $webchat = $('.js-webchat') $advisersUnavailable = $webchat.find('.js-webchat-advisers-unavailable') @@ -45,30 +46,31 @@ describe('Webchat', function () { $advisersError = $webchat.find('.js-webchat-advisers-error') }) + afterEach(function () { + jasmine.Ajax.uninstall() + }) + describe('on valid application locations', function () { function mount () { - $webchat.map(function () { - return new GOVUK.Webchat({ - $el: $(this), - location: '/government/organisations/hm-revenue-customs/contact/child-benefit', - pollingEnabled: true - }) - }) + var webchats = document.querySelectorAll('.js-webchat') + for (var i = 0; i < webchats.length; i++) { + /* eslint-disable no-new */ + new GOVUK.Webchat(webchats[i]) + } } it('should poll for availability', function () { - spyOn($, 'ajax') + spyOn(XMLHttpRequest.prototype, 'open').and.callThrough() + spyOn(XMLHttpRequest.prototype, 'send').and.callThrough() mount() expect( - $.ajax - ).toHaveBeenCalledWith({ url: CHILD_BENEFIT_API_URL, type: 'GET', timeout: jasmine.any(Number), success: jasmine.any(Function), error: jasmine.any(Function) }) + XMLHttpRequest.prototype.open + ).toHaveBeenCalledWith('GET', CHILD_BENEFIT_API_URL, true) }) it('should inform user whether advisors are available', function () { - spyOn($, 'ajax').and.callFake(function (options) { - options.success(jsonNormalisedAvailable) - }) mount() + jasmine.Ajax.requests.mostRecent().respondWith(jsonNormalisedAvailable) expect($advisersAvailable.hasClass('govuk-!-display-none')).toBe(false) expect($advisersBusy.hasClass('govuk-!-display-none')).toBe(true) @@ -77,10 +79,8 @@ describe('Webchat', function () { }) it('should inform user whether advisors are unavailable', function () { - spyOn($, 'ajax').and.callFake(function (options) { - options.success(jsonNormalisedUnavailable) - }) mount() + jasmine.Ajax.requests.mostRecent().respondWith(jsonNormalisedUnavailable) expect($advisersUnavailable.hasClass('govuk-!-display-none')).toBe(false) expect($advisersAvailable.hasClass('govuk-!-display-none')).toBe(true) @@ -89,10 +89,8 @@ describe('Webchat', function () { }) it('should inform user whether advisors are busy', function () { - spyOn($, 'ajax').and.callFake(function (options) { - options.success(jsonNormalisedBusy) - }) mount() + jasmine.Ajax.requests.mostRecent().respondWith(jsonNormalisedBusy) expect($advisersBusy.hasClass('govuk-!-display-none')).toBe(false) expect($advisersAvailable.hasClass('govuk-!-display-none')).toBe(true) @@ -101,10 +99,9 @@ describe('Webchat', function () { }) it('should inform user whether there was an error', function () { - spyOn($, 'ajax').and.callFake(function (options) { - options.success(jsonNormalisedError) - }) mount() + jasmine.Ajax.requests.mostRecent().respondWith(jsonNormalisedError) + expect($advisersError.hasClass('govuk-!-display-none')).toBe(false) expect($advisersAvailable.hasClass('govuk-!-display-none')).toBe(true) @@ -113,22 +110,10 @@ describe('Webchat', function () { }) it('should only track once per state change', function () { - var returns = [ - jsonNormalisedAvailable, - jsonNormalisedError, - jsonNormalisedError, - jsonNormalisedError, - jsonNormalisedError - ] var analyticsExpects = ['available', 'error'] var analyticsReceived = [] - var returnsNumber = 0 var analyticsCalled = 0 var clock = lolex.install() - spyOn($, 'ajax').and.callFake(function (options) { - options.success(returns[returnsNumber]) - returnsNumber++ - }) spyOn(GOVUK.analytics, 'trackEvent').and.callFake(function (webchatKey, webchatValue) { analyticsReceived.push(webchatValue) @@ -136,6 +121,8 @@ describe('Webchat', function () { }) mount() + jasmine.Ajax.requests.mostRecent().respondWith(jsonNormalisedAvailable) + expect($advisersAvailable.hasClass('govuk-!-display-none')).toBe(false) expect($advisersBusy.hasClass('govuk-!-display-none')).toBe(true) @@ -143,6 +130,7 @@ describe('Webchat', function () { expect($advisersUnavailable.hasClass('govuk-!-display-none')).toBe(true) clock.tick(POLL_INTERVAL) + jasmine.Ajax.requests.mostRecent().respondWith(jsonNormalisedError) expect($advisersError.hasClass('govuk-!-display-none')).toBe(false) expect($advisersAvailable.hasClass('govuk-!-display-none')).toBe(true) From 5a5e77d13d22ebb4379834fc29c823e044cdfdd0 Mon Sep 17 00:00:00 2001 From: Leena Gupte Date: Tue, 8 Mar 2022 12:52:30 +0000 Subject: [PATCH 7/7] Add detail to error if webchat url doesn't exist This will allow us to pinpoint which GOV.UK pages are trying to open a webchat url, without having any urls defined in [webchat.yaml] [webchat.yaml]: https://github.com/alphagov/government-frontend/blob/5974c4ec2c7e7a4eedcfda8f48370cce79c8efa0/lib/webchat.yaml --- app/assets/javascripts/webchat/library.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/webchat/library.js b/app/assets/javascripts/webchat/library.js index fe3e169cc..3021b7bc2 100644 --- a/app/assets/javascripts/webchat/library.js +++ b/app/assets/javascripts/webchat/library.js @@ -21,7 +21,7 @@ function init () { if (!availabilityUrl || !openUrl) { - throw Error.new('urls for webchat not defined') + throw Error.new('urls for webchat not defined', window.location.href) } if (openButton) {