From 5f15ed54f95171a3499ac271828aff978d114869 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 26 Sep 2019 00:23:17 +0800 Subject: [PATCH] Initial unit tests for front-end framework (#4576) Credit to @bennothommo --- .babelrc | 13 + .github/workflows/frontend-tests.yaml | 24 + .gitignore | 2 + .jshintrc | 5 + modules/system/assets/js/framework-min.js | 2 +- .../assets/js/framework.combined-min.js | 2 +- modules/system/assets/js/framework.js | 2 +- package.json | 48 ++ tests/js/cases/system/framework.test.js | 673 ++++++++++++++++++ tests/js/helpers/fakeDom.js | 40 ++ 10 files changed, 808 insertions(+), 3 deletions(-) create mode 100644 .babelrc create mode 100644 .github/workflows/frontend-tests.yaml create mode 100644 .jshintrc create mode 100644 package.json create mode 100644 tests/js/cases/system/framework.test.js create mode 100644 tests/js/helpers/fakeDom.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..71464e186 --- /dev/null +++ b/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": ["@babel/preset-env"], + "plugins": [ + [ + "module-resolver", { + "root": ["."], + "alias": { + "helpers": "./tests/js/helpers" + } + } + ] + ] +} diff --git a/.github/workflows/frontend-tests.yaml b/.github/workflows/frontend-tests.yaml new file mode 100644 index 000000000..9d0068a0a --- /dev/null +++ b/.github/workflows/frontend-tests.yaml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: + - master + - develop + pull_request: + +jobs: + frontendTests: + runs-on: ubuntu-latest + name: JavaScript + steps: + - name: Checkout changes + uses: actions/checkout@v1 + - name: Install Node + uses: actions/setup-node@v1 + with: + node-version: 8 + - name: Install Node dependencies + run: npm install + - name: Run tests + run: npm run test diff --git a/.gitignore b/.gitignore index 3f3fe3b84..4cd08cf9a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ sftp-config.json .ftpconfig selenium.php composer.lock +package-lock.json +/node_modules _ide_helper.php # for netbeans diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 000000000..bb55890a9 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,5 @@ +{ + "esversion": 6, + "curly": true, + "asi": true +} diff --git a/modules/system/assets/js/framework-min.js b/modules/system/assets/js/framework-min.js index e519b55f9..43fe38ba4 100644 --- a/modules/system/assets/js/framework-min.js +++ b/modules/system/assets/js/framework-min.js @@ -66,7 +66,7 @@ var fieldElement=$form.find('[name="'+fieldName+'"], [name="'+fieldName+'[]"], [ if(fieldElement.length>0){var _event=jQuery.Event('ajaxInvalidField') $(window).trigger(_event,[fieldElement.get(0),fieldName,fieldMessages,isFirstInvalidField]) if(isFirstInvalidField){if(!_event.isDefaultPrevented())fieldElement.focus() -isFirstInvalidField=false}}})},handleFlashMessage:function(message,type){},handleRedirectResponse:function(url){window.location.href=url},handleUpdateResponse:function(data,textStatus,jqXHR){var updatePromise=$.Deferred().done(function(){for(var partial in data){var selector=(options.update[partial])?options.update[partial]:partial +isFirstInvalidField=false}}})},handleFlashMessage:function(message,type){},handleRedirectResponse:function(url){window.location.assign(url)},handleUpdateResponse:function(data,textStatus,jqXHR){var updatePromise=$.Deferred().done(function(){for(var partial in data){var selector=(options.update[partial])?options.update[partial]:partial if($.type(selector)=='string'&&selector.charAt(0)=='@'){$(selector.substring(1)).append(data[partial]).trigger('ajaxUpdate',[context,data,textStatus,jqXHR])} else if($.type(selector)=='string'&&selector.charAt(0)=='^'){$(selector.substring(1)).prepend(data[partial]).trigger('ajaxUpdate',[context,data,textStatus,jqXHR])} else{$(selector).trigger('ajaxBeforeReplace') diff --git a/modules/system/assets/js/framework.combined-min.js b/modules/system/assets/js/framework.combined-min.js index ea2c3ea7a..21bf12283 100644 --- a/modules/system/assets/js/framework.combined-min.js +++ b/modules/system/assets/js/framework.combined-min.js @@ -66,7 +66,7 @@ var fieldElement=$form.find('[name="'+fieldName+'"], [name="'+fieldName+'[]"], [ if(fieldElement.length>0){var _event=jQuery.Event('ajaxInvalidField') $(window).trigger(_event,[fieldElement.get(0),fieldName,fieldMessages,isFirstInvalidField]) if(isFirstInvalidField){if(!_event.isDefaultPrevented())fieldElement.focus() -isFirstInvalidField=false}}})},handleFlashMessage:function(message,type){},handleRedirectResponse:function(url){window.location.href=url},handleUpdateResponse:function(data,textStatus,jqXHR){var updatePromise=$.Deferred().done(function(){for(var partial in data){var selector=(options.update[partial])?options.update[partial]:partial +isFirstInvalidField=false}}})},handleFlashMessage:function(message,type){},handleRedirectResponse:function(url){window.location.assign(url)},handleUpdateResponse:function(data,textStatus,jqXHR){var updatePromise=$.Deferred().done(function(){for(var partial in data){var selector=(options.update[partial])?options.update[partial]:partial if($.type(selector)=='string'&&selector.charAt(0)=='@'){$(selector.substring(1)).append(data[partial]).trigger('ajaxUpdate',[context,data,textStatus,jqXHR])} else if($.type(selector)=='string'&&selector.charAt(0)=='^'){$(selector.substring(1)).prepend(data[partial]).trigger('ajaxUpdate',[context,data,textStatus,jqXHR])} else{$(selector).trigger('ajaxBeforeReplace') diff --git a/modules/system/assets/js/framework.js b/modules/system/assets/js/framework.js index b13a05fa9..a7c147621 100644 --- a/modules/system/assets/js/framework.js +++ b/modules/system/assets/js/framework.js @@ -267,7 +267,7 @@ if (window.jQuery.request !== undefined) { * Custom function, redirect the browser to another location */ handleRedirectResponse: function(url) { - window.location.href = url + window.location.assign(url) }, /* diff --git a/package.json b/package.json new file mode 100644 index 000000000..8ba2b08a6 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "octobercms", + "description": "Free, open-source, self-hosted CMS platform based on the Laravel PHP Framework.", + "directories": { + "test": "tests/js/cases", + "helpers": "tests/js/helpers" + }, + "scripts": { + "test": "mocha --require @babel/register tests/js/cases/**/*.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/octobercms/october.git" + }, + "contributors": [ + { + "name": "Alexey Bobkov", + "email": "aleksey.bobkov@gmail.com" + }, + { + "name": "Samuel Georges", + "email": "daftspunky@gmail.com" + }, + { + "name": "Luke Towers", + "email": "octobercms@luketowers.ca", + "url": "https://luketowers.ca" + } + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/octobercms/october/issues" + }, + "homepage": "https://octobercms.com/", + "devDependencies": { + "@babel/cli": "^7.5.5", + "@babel/core": "^7.5.5", + "@babel/node": "^7.5.5", + "@babel/preset-env": "^7.5.5", + "@babel/register": "^7.5.5", + "babel-plugin-module-resolver": "^3.2.0", + "chai": "^4.2.0", + "jquery": "^3.4.1", + "jsdom": "^15.1.1", + "mocha": "^6.2.0", + "sinon": "^7.4.1" + } +} diff --git a/tests/js/cases/system/framework.test.js b/tests/js/cases/system/framework.test.js new file mode 100644 index 000000000..ec7323d34 --- /dev/null +++ b/tests/js/cases/system/framework.test.js @@ -0,0 +1,673 @@ +import { assert } from 'chai' +import fakeDom from 'helpers/fakeDom' +import sinon from 'sinon' + +describe('modules/system/assets/js/framework.js', function () { + describe('ajaxRequests through JS', function () { + let dom, + window, + xhr, + requests = [] + + this.timeout(1000) + + beforeEach(() => { + // Load framework.js in the fake DOM + dom = fakeDom( + '
Initial content
' + + '' + + '', + { + beforeParse: (window) => { + // Mock XHR for tests below + xhr = sinon.useFakeXMLHttpRequest() + xhr.onCreate = (request) => { + requests.push(request) + } + window.XMLHttpRequest = xhr + + // Allow window.location.assign() to be stubbed + delete window.location + window.location = { + href: 'https://october.example.org/', + assign: sinon.stub() + } + } + } + ) + window = dom.window + + // Enable CORS on jQuery + window.jqueryScript.onload = () => { + window.jQuery.support.cors = true + } + }) + + afterEach(() => { + // Close window and restore XHR functionality to default + window.XMLHttpRequest = sinon.xhr.XMLHttpRequest + window.close() + requests = [] + }) + + it('can make a successful AJAX request', function (done) { + window.frameworkScript.onload = () => { + window.$.request('test::onTest', { + success: function () { + done() + }, + error: function () { + done(new Error('AJAX call failed')) + } + }) + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a successful response from the server + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + 'successful': true + }) + ) + } + }) + + it('can make an unsuccessful AJAX request', function (done) { + window.frameworkScript.onload = () => { + window.$.request('test::onTest', { + success: function () { + done(new Error('AJAX call succeeded')) + }, + error: function () { + done() + } + }) + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a 404 Not Found response from the server + requests[1].respond( + 404, + { + 'Content-Type': 'text/html' + }, + '' + ) + } + }) + + it('can update a partial via an ID selector', function (done) { + window.frameworkScript.onload = () => { + window.$.request('test::onTest', { + complete: function () { + let partialContent = dom.window.document.getElementById('partialId').textContent + try { + assert( + partialContent === 'Content passed through AJAX', + 'Partial content incorrect - ' + + 'expected "Content passed through AJAX", ' + + 'found "' + partialContent + '"' + ) + done() + } catch (e) { + done(e) + } + } + }) + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a response from the server that includes a partial change via ID + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + '#partialId': 'Content passed through AJAX' + }) + ) + } + }) + + it('can update a partial via a class selector', function (done) { + window.frameworkScript.onload = () => { + window.$.request('test::onTest', { + complete: function () { + let partialContent = dom.window.document.getElementById('partialId').textContent + try { + assert( + partialContent === 'Content passed through AJAX', + 'Partial content incorrect - ' + + 'expected "Content passed through AJAX", ' + + 'found "' + partialContent + '"' + ) + done() + } catch (e) { + done(e) + } + } + }) + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a response from the server that includes a partial change via a class + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + '.partialClass': 'Content passed through AJAX' + }) + ) + } + }) + + it('can redirect after a successful AJAX request', function (done) { + this.timeout(1000) + + // Detect a redirect + window.location.assign.callsFake((url) => { + try { + assert( + url === '/test/success', + 'Non-matching redirect URL' + ) + done() + } catch (e) { + done(e) + } + }) + + window.frameworkScript.onload = () => { + window.$.request('test::onTest', { + redirect: '/test/success', + }) + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a successful response from the server + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + 'successful': true + }) + ) + } + }) + + it('can send extra data with the AJAX request', function (done) { + this.timeout(1000) + + window.frameworkScript.onload = () => { + window.$.request('test::onTest', { + data: { + test1: 'First', + test2: 'Second' + }, + success: function () { + done() + } + }) + + try { + assert( + requests[1].requestBody === 'test1=First&test2=Second', + 'Data incorrect or not included in request' + ) + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a successful response from the server + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + 'successful': true + }) + ) + } + }) + + it('can call a beforeUpdate handler', function (done) { + const beforeUpdate = function (data, status, jqXHR) { + } + const beforeUpdateSpy = sinon.spy(beforeUpdate) + + window.frameworkScript.onload = () => { + window.$.request('test::onTest', { + beforeUpdate: beforeUpdateSpy + }) + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a successful response from the server + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + 'successful': true + }) + ) + + try { + assert( + beforeUpdateSpy.withArgs( + { + 'successful': true + }, + 'success' + ).calledOnce + ) + done() + } catch (e) { + done(e) + } + } + }) + }) + + describe('ajaxRequests through HTML attributes', function () { + let dom, + window, + xhr, + requests = [] + + this.timeout(1000) + + beforeEach(() => { + // Load framework.js in the fake DOM + dom = fakeDom( + '' + + '' + + '' + + '
Initial content
' + + '' + + '', + { + beforeParse: (window) => { + // Mock XHR for tests below + xhr = sinon.useFakeXMLHttpRequest() + xhr.onCreate = (request) => { + requests.push(request) + } + window.XMLHttpRequest = xhr + + // Add a stub for the request handlers + window.test = sinon.stub() + + // Add a spy for the beforeUpdate handler + window.beforeUpdate = function (element, data, status) { + } + window.beforeUpdateSpy = sinon.spy(window.beforeUpdate) + + // Stub out window.alert + window.alert = sinon.stub() + + // Allow window.location.assign() to be stubbed + delete window.location + window.location = { + href: 'https://october.example.org/', + assign: sinon.stub() + } + } + } + ) + window = dom.window + + // Enable CORS on jQuery + window.jqueryScript.onload = () => { + window.jQuery.support.cors = true + } + }) + + afterEach(() => { + // Close window and restore XHR functionality to default + window.XMLHttpRequest = sinon.xhr.XMLHttpRequest + window.close() + requests = [] + }) + + it('can make a successful AJAX request', function (done) { + window.frameworkScript.onload = () => { + window.test.callsFake((response) => { + assert(response === 'success', 'Response handler was not "success"') + done() + }) + + window.$('a#standard').click() + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a successful response from the server + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + 'successful': true + }) + ) + } + }) + + it('can make an unsuccessful AJAX request', function (done) { + window.frameworkScript.onload = () => { + window.test.callsFake((response) => { + assert(response === 'failure', 'Response handler was not "failure"') + done() + }) + + window.$('a#standard').click() + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a 404 Not Found response from the server + requests[1].respond( + 404, + { + 'Content-Type': 'text/html' + }, + '' + ) + } + }) + + + it('can update a partial via an ID selector', function (done) { + window.frameworkScript.onload = () => { + window.test.callsFake(() => { + let partialContent = dom.window.document.getElementById('partialId').textContent + try { + assert( + partialContent === 'Content passed through AJAX', + 'Partial content incorrect - ' + + 'expected "Content passed through AJAX", ' + + 'found "' + partialContent + '"' + ) + done() + } catch (e) { + done(e) + } + }) + + window.$('a#standard').click() + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a response from the server that includes a partial change via ID + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + '#partialId': 'Content passed through AJAX' + }) + ) + } + }) + + it('can update a partial via a class selector', function (done) { + window.frameworkScript.onload = () => { + window.test.callsFake(() => { + let partialContent = dom.window.document.getElementById('partialId').textContent + try { + assert( + partialContent === 'Content passed through AJAX', + 'Partial content incorrect - ' + + 'expected "Content passed through AJAX", ' + + 'found "' + partialContent + '"' + ) + done() + } catch (e) { + done(e) + } + }) + + window.$('a#standard').click() + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a response from the server that includes a partial change via a class + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + '.partialClass': 'Content passed through AJAX' + }) + ) + } + }) + + it('can redirect after a successful AJAX request', function (done) { + this.timeout(1000) + + // Detect a redirect + window.location.assign.callsFake((url) => { + try { + assert( + url === '/test/success', + 'Non-matching redirect URL' + ) + done() + } catch (e) { + done(e) + } + }) + + window.frameworkScript.onload = () => { + window.$('a#redirect').click() + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a successful response from the server + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + 'succesful': true + }) + ) + } + }) + + it('can send extra data with the AJAX request', function (done) { + this.timeout(1000) + + window.frameworkScript.onload = () => { + window.test.callsFake((response) => { + assert(response === 'success', 'Response handler was not "success"') + done() + }) + + window.$('a#dataLink').click() + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a successful response from the server + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + 'succesful': true + }) + ) + } + }) + + it('can call a beforeUpdate handler', function (done) { + this.timeout(1000) + + window.frameworkScript.onload = () => { + window.test.callsFake((response) => { + assert(response === 'success', 'Response handler was not "success"') + }) + + window.$('a#dataLink').click() + + try { + assert( + requests[1].requestHeaders['X-OCTOBER-REQUEST-HANDLER'] === 'test::onTest', + 'Incorrect October request handler' + ) + } catch (e) { + done(e) + } + + // Mock a successful response from the server + requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + 'successful': true + }) + ) + + try { + assert( + window.beforeUpdateSpy.withArgs( + window.$('a#dataLink').get(), + { + 'successful': true + }, + 'success' + ).calledOnce, + 'beforeUpdate handler never called, or incorrect arguments provided' + ) + done() + } catch (e) { + done(e) + } + } + }) + }) +}) diff --git a/tests/js/helpers/fakeDom.js b/tests/js/helpers/fakeDom.js new file mode 100644 index 000000000..1d5c84743 --- /dev/null +++ b/tests/js/helpers/fakeDom.js @@ -0,0 +1,40 @@ +import { JSDOM } from 'jsdom' + +const defaults = { + url: 'https://october.example.org/', + referer: null, + contentType: 'text/html', + head: 'Fake document', + bodyStart: '', + bodyEnd: '', + foot: '', + beforeParse: null +} + +const fakeDom = (content, options) => { + const settings = Object.assign({}, defaults, options) + + const dom = new JSDOM( + settings.head + + settings.bodyStart + + (content + '') + + settings.bodyEnd + + settings.foot, + { + url: settings.url, + referrer: settings.referer || undefined, + contentType: settings.contenType, + includeNodeLocations: true, + runScripts: 'dangerously', + resources: 'usable', + pretendToBeVisual: true, + beforeParse: (typeof settings.beforeParse === 'function') + ? settings.beforeParse + : undefined + } + ) + + return dom +} + +export default fakeDom