diff --git a/client/commonFramework/add-faux-stream.js b/client/commonFramework/add-faux-stream.js deleted file mode 100644 index 17a57e212c..0000000000 --- a/client/commonFramework/add-faux-stream.js +++ /dev/null @@ -1,24 +0,0 @@ -window.common = (function(global) { - const { - common = { init: [] } - } = global; - - - const faux$ = common.getScriptContent$('/js/faux.js').shareReplay(); - - common.hasJs = function hasJs(code = '') { - return code.match(/\<\s?script\s?\>/gi) && - code.match(/\<\s?\/\s?script\s?\>/gi); - }; - - common.addFaux$ = function addFaux$(code) { - // grab user javaScript - var scriptCode = code - .split(/\<\s?script\s?\>/gi)[1] - .split(/\<\s?\/\s?script\s?\>/gi)[0]; - - return faux$.map(faux => faux + scriptCode); - }; - - return common; -}(window)); diff --git a/client/commonFramework/add-loop-protect.js b/client/commonFramework/add-loop-protect.js new file mode 100644 index 0000000000..65aebd0ed4 --- /dev/null +++ b/client/commonFramework/add-loop-protect.js @@ -0,0 +1,17 @@ +window.common = (function(global) { + const { + loopProtect, + common = { init: [] } + } = global; + + loopProtect.hit = function hit(line) { + var err = `Error: Exiting potential infinite loop at line ${line}.`; + console.error(err); + }; + + common.addLoopProtect = function addLoopProtect(code = '') { + return loopProtect(code); + }; + + return common; +})(window); diff --git a/client/commonFramework/add-test-to-string.js b/client/commonFramework/add-test-to-string.js deleted file mode 100644 index 52e78a82b1..0000000000 --- a/client/commonFramework/add-test-to-string.js +++ /dev/null @@ -1,38 +0,0 @@ -window.common = (function({ common = { init: [] }}) { - - var BDDregex = new RegExp( - '(expect(\\s+)?\\(.*\\;)|' + - '(assert(\\s+)?\\(.*\\;)|' + - '(assert\\.\\w.*\\;)|' + - '(.*\\.should\\..*\\;)/' - ); - - common.addTestsToString = function({ code, tests = [], ...rest }) { - const userTests = []; - - code = tests.reduce((code, test) => code + test + '\n', code + '\n'); - - var counter = 0; - var match = BDDregex.exec(code); - - while (match) { - var replacement = '//' + counter + common.salt; - code = code.substring(0, match.index) + - replacement + - code.substring(match.index + match[0].length); - - userTests.push({ - err: null, - text: match[0], - line: counter - }); - - counter++; - match = BDDregex.exec(code); - } - - return { ...rest, code, userTests }; - }; - - return common; -}(window)); diff --git a/client/commonFramework/detect-loops-stream.js b/client/commonFramework/detect-loops-stream.js deleted file mode 100644 index e60c49eea9..0000000000 --- a/client/commonFramework/detect-loops-stream.js +++ /dev/null @@ -1,79 +0,0 @@ -window.common = (function(global) { - const { - jailed, - document: doc, - Rx: { Observable, Disposable }, - common = { init: [] } - } = global; - - if (!jailed) { - return (code, cb) => cb(new Error('Could not load jailed plugin')); - } - - // obtaining absolute path of this script - var scripts = doc.getElementsByTagName('script'); - var path = scripts[scripts.length - 1].src - .split('?')[0] - .split('/') - .slice(0, -1) - .join('/') + '/'; - - var Sandbox = { - startTimeout() { - this.timeoutId = setTimeout(() => { - this.error = new Error('Plugin failed to initialize'); - this.destroyPlugin(); - }, 3000); - }, - cancelTimout() { - if (this.timeoutId) { - clearTimeout(this.timeoutId); - this.timeoutId = null; - } - }, - createPlugin() { - this.plugin = new jailed.Plugin(path + 'plugin.js'); - this.startTimeout(); - this.plugin.whenConnected(() => { - this.cancelTimout(); - }); - }, - destroyPlugin() { - this.plugin.disconnect(); - } - }; - - - // sends the input to the plugin for evaluation - common.detectLoops$ = function detectLoops$({ code = '', ...rest }) { - return Observable.create(function(observer) { - const sandbox = Object.create(Sandbox); - - sandbox.createPlugin(); - sandbox.plugin.whenConnected(() => { - sandbox.plugin.remote.run(code, (err, data) => { - observer.onNext({ ...rest, err, code, data }); - observer.onCompleted(); - }); - }); - - sandbox.plugin.whenDisconnected(() => { - if (sandbox.disposed) { - return null; - } - - if (sandbox.error) { - observer.onNext({ ...rest, err: sandbox.error, code, data: {} }); - } - observer.onCompleted(); - }); - - return new Disposable(() => { - sandbox.disposed = true; - sandbox.destroyPlugin(); - }); - }); - }; - - return common; -}(window)); diff --git a/client/commonFramework/end.js b/client/commonFramework/end.js index 0c9e914521..811e154192 100644 --- a/client/commonFramework/end.js +++ b/client/commonFramework/end.js @@ -28,18 +28,7 @@ $(document).ready(function() { // only run for HTML .filter(() => common.challengeType === challengeTypes.HTML) .flatMap(code => { - if ( - common.hasJs(code) - ) { - return common.detectUnsafeCode$(code) - .flatMap(code => common.detectLoops$(code)) - .flatMap( - ({ err }) => err ? Observable.throw(err) : Observable.just(code) - ) - .flatMap(code => common.updatePreview$(code)) - .catch(err => Observable.just({ err })); - } - return Observable.just(code) + return common.detectUnsafeCode$(code) .flatMap(code => common.updatePreview$(code)) .catch(err => Observable.just({ err })); }) @@ -66,12 +55,12 @@ $(document).ready(function() { .catch(err => Observable.just({ err })); }) .subscribe( - ({ err, output, original }) => { + ({ err, output, originalCode }) => { if (err) { console.error(err); return common.updateOutputDisplay('' + err); } - common.codeStorage.updateStorage(challengeName, original); + common.codeStorage.updateStorage(challengeName, originalCode); common.updateOutputDisplay('' + output); }, (err) => { @@ -102,11 +91,11 @@ $(document).ready(function() { if (common.challengeType === common.challengeTypes.HTML) { return common.updatePreview$(`

${err}

- `).subscribe(() => {}); + `).first().subscribe(() => {}); } return common.updateOutputDisplay('' + err); } - common.updateOutputDisplay(output); + common.updateOutputDisplay('' + output); common.displayTestResults(tests); if (solved) { common.showCompletion(); @@ -153,12 +142,12 @@ $(document).ready(function() { .flatMap(() => common.executeChallenge$()) .catch(err => Observable.just({ err })) .subscribe( - ({ err, original, tests }) => { + ({ err, originalCode, tests }) => { if (err) { console.error(err); return common.updateOutputDisplay('' + err); } - common.codeStorage.updateStorage(challengeName, original); + common.codeStorage.updateStorage(challengeName, originalCode); common.displayTestResults(tests); }, (err) => { diff --git a/client/commonFramework/execute-challenge-stream.js b/client/commonFramework/execute-challenge-stream.js index 181868e581..2a29b9edf6 100644 --- a/client/commonFramework/execute-challenge-stream.js +++ b/client/commonFramework/execute-challenge-stream.js @@ -1,16 +1,18 @@ -// what are the executeChallenge functions? -// Should be responsible for starting after a submit action -// Should not be responsible for displaying results -// Should return results -// should grab editor value -// depends on main editor window.common = (function(global) { const { ga, - Rx: { Observable }, common = { init: [] } } = global; + const { + addLoopProtect, + getJsFromHtml, + detectUnsafeCode$, + updatePreview$, + challengeType, + challengeTypes + } = common; + let attempts = 0; common.executeChallenge$ = function executeChallenge$() { @@ -18,67 +20,39 @@ window.common = (function(global) { const originalCode = code; const head = common.arrayToNewLineString(common.head); const tail = common.arrayToNewLineString(common.tail); + const combinedCode = head + code + tail; attempts++; ga('send', 'event', 'Challenge', 'ran-code', common.challengeName); // run checks for unsafe code - return common.detectUnsafeCode$(code) + return detectUnsafeCode$(code) // add head and tail and detect loops - .map(code => head + code + tail) - .flatMap(code => { - if (common.challengeType === common.challengeTypes.HTML) { - - if (common.hasJs(code)) { - // html has a script code - // add faux code and test in webworker - return common.addFaux$(code) - .flatMap(code => common.detectLoops$(code)) - .flatMap(({ err }) => { - if (err) { - return Observable.throw(err); - } - return common.updatePreview$(code) - .flatMap(() => common.runPreviewTests$({ - code, - tests: common.tests.slice() - })); - }); - } - - // no script code detected in html code - // Update preview and run tests in iframe - return common.updatePreview$(code) - .flatMap(code => common.runPreviewTests$({ - code, - tests: common.tests.slice() - })); + .map(() => { + if (challengeType !== challengeTypes.HTML) { + return ``; } - // js challenge - // remove comments and add tests to string - return Observable.just(common.addTestsToString(Object.assign( - { - code: common.removeComments(code), - tests: common.tests.slice() - } - ))) - .flatMap(common.detectLoops$) - .flatMap(({ err, code, data, userTests }) => { - if (err) { - return Observable.throw(err); - } + return addLoopProtect(combinedCode); + }) + .flatMap(code => updatePreview$(code)) + .flatMap(code => { + let output; - // run tests - // for now these are running in the browser - return common.runTests$({ - data, - code, - userTests, - originalCode, - output: data.output.replace(/\\\"/gi, '') - }); + if ( + challengeType === challengeTypes.HTML && + common.hasJs(code) + ) { + output = common.getJsOutput(getJsFromHtml(code)); + } else if (challengeType !== challengeTypes.HTML) { + output = common.getJsOutput(addLoopProtect(combinedCode)); + } + + return common.runPreviewTests$({ + tests: common.tests.slice(), + originalCode, + output }); }); }; diff --git a/client/commonFramework/get-iframe.js b/client/commonFramework/get-iframe.js new file mode 100644 index 0000000000..012dce7b50 --- /dev/null +++ b/client/commonFramework/get-iframe.js @@ -0,0 +1,23 @@ +window.common = (function(global) { + const { + common = { init: [] }, + document: doc + } = global; + + common.getIframe = function getIframe(id = 'preview') { + let previewFrame = doc.getElementById(id); + + // create and append a hidden preview frame + if (!previewFrame) { + previewFrame = doc.createElement('iframe'); + previewFrame.id = id; + previewFrame.setAttribute('style', 'display: none'); + doc.body.appendChild(previewFrame); + } + + return previewFrame.contentDocument || + previewFrame.contentWindow.document; + }; + + return common; +})(window); diff --git a/client/commonFramework/init.js b/client/commonFramework/init.js index 09add87bf7..ff4130efd1 100644 --- a/client/commonFramework/init.js +++ b/client/commonFramework/init.js @@ -102,5 +102,19 @@ window.common = (function(global) { }); }; + const openScript = /\<\s?script\s?\>/gi; + const closingScript = /\<\s?\/\s?script\s?\>/gi; + + // detects if there is JavaScript in the first script tag + common.hasJs = function hasJs(code) { + return !!common.getJsFromHtml(code); + }; + + // grabs the content from the first script tag in the code + common.getJsFromHtml = function getJsFromHtml(code) { + // grab user javaScript + return (code.split(openScript)[1] || '').split(closingScript)[0] || ''; + }; + return common; })(window); diff --git a/client/commonFramework/update-preview.js b/client/commonFramework/update-preview.js index fb4e36d605..ea30f4abf2 100644 --- a/client/commonFramework/update-preview.js +++ b/client/commonFramework/update-preview.js @@ -4,7 +4,15 @@ window.common = (function(global) { common = { init: [] } } = global; + // the first script tag here is to proxy jQuery + // We use the same jQuery on the main window but we change the + // context to that of the iframe. var libraryIncludes = ` + - @@ -34,15 +41,10 @@ window.common = (function(global) { // runPreviewTests$ should be set up in the preview window common.runPreviewTests$ = - () => Observable.throw({ err: new Error('run preview not enabled') }); + () => Observable.throw(new Error('run preview not enabled')); common.updatePreview$ = function updatePreview$(code = '') { - const previewFrame = document.getElementById('preview'); - const preview = previewFrame.contentDocument || - previewFrame.contentWindow.document; - if (!preview) { - return Observable.just(code); - } + const preview = common.getIframe('preview'); return iFrameScript$ .map(script => ``) @@ -56,7 +58,10 @@ window.common = (function(global) { // now we filter false values and wait for the first true return common.previewReady$ .filter(ready => ready) - .first(); + .first() + // the delay here is to give code within the iframe + // control to run + .delay(400); }) .map(() => code); }; diff --git a/client/faux.js b/client/faux.js deleted file mode 100644 index ca4563f0d8..0000000000 --- a/client/faux.js +++ /dev/null @@ -1,275 +0,0 @@ -/* eslint-disable no-unused-vars */ -var document = {}; -var navigator = function() { - this.geolocation = function() { - this.getCurrentPosition = function() { - this.coords = {latitude: '', longitude: '' }; - return this; - }; - return this; - }; - return this; -}; -function $() { - if (!(this instanceof $)) { - return new $(); - } -} -function returnThis() { return this; } -function return$() { return $; } -var methods = [ - 'add', - 'addBack', - 'addClass', - 'after', - 'ajaxComplete', - 'ajaxError', - 'ajaxSend', - 'ajaxStart', - 'ajaxStop', - 'ajaxSuccess', - 'andSelf', - 'animate', - 'append', - 'appendTo', - 'attr', - 'before', - 'bind', - 'blur', - 'callbacksadd', - 'callbacksdisable', - 'callbacksdisabled', - 'callbacksempty', - 'callbacksfire', - 'callbacksfired', - 'callbacksfireWith', - 'callbackshas', - 'callbackslock', - 'callbackslocked', - 'callbacksremove', - 'change', - 'children', - 'clearQueue', - 'click', - 'clone', - 'closest', - 'contents', - 'context', - 'css', - 'data', - 'dblclick', - 'delay', - 'delegate', - 'dequeue', - 'detach', - 'die', - 'each', - 'empty', - 'end', - 'eq', - 'error', - 'fadeIn', - 'fadeOut', - 'fadeTo', - 'fadeToggle', - 'filter', - 'find', - 'finish', - 'first', - 'focus', - 'focusin', - 'focusout', - 'get', - 'has', - 'hasClass', - 'height', - 'hide', - 'hover', - 'html', - 'index', - 'innerHeight', - 'innerWidth', - 'insertAfter', - 'insertBefore', - 'is', - 'jQuery', - 'jquery', - 'keydown', - 'keypress', - 'keyup', - 'last', - 'length', - 'live', - 'load', - 'load', - 'map', - 'mousedown', - 'mouseenter', - 'mouseleave', - 'mousemove', - 'mouseout', - 'mouseover', - 'mouseup', - 'next', - 'nextAll', - 'nextUntil', - 'not', - 'off', - 'offset', - 'offsetParent', - 'on', - 'one', - 'outerHeight', - 'outerWidth', - 'parent', - 'parents', - 'parentsUntil', - 'position', - 'prepend', - 'prependTo', - 'prev', - 'prevAll', - 'prevUntil', - 'promise', - 'prop', - 'pushStack', - 'queue', - 'ready', - 'remove', - 'removeAttr', - 'removeClass', - 'removeData', - 'removeProp', - 'replaceAll', - 'replaceWith', - 'resize', - 'scroll', - 'scrollLeft', - 'scrollTop', - 'select', - 'selector', - 'serialize', - 'serializeArray', - 'show', - 'siblings', - 'size', - 'slice', - 'slideDown', - 'slideToggle', - 'slideUp', - 'stop', - 'submit', - 'text', - 'toArray', - 'toggle', - 'toggle', - 'toggleClass', - 'trigger', - 'triggerHandler', - 'unbind', - 'undelegate', - 'unload', - 'unwrap', - 'val', - 'width', - 'wrap', - 'wrapAll', - 'wrapInner' -]; - -var statics = [ - 'ajax', - 'ajaxPrefilter', - 'ajaxSetup', - 'ajaxTransport', - 'boxModel', - 'browser', - 'Callbacks', - 'contains', - 'cssHooks', - 'cssNumber', - 'data', - 'Deferred', - 'dequeue', - 'each', - 'error', - 'extend', - 'fnextend', - 'fxinterval', - 'fxoff', - 'get', - 'getJSON', - 'getScript', - 'globalEval', - 'grep', - 'hasData', - 'holdReady', - 'inArray', - 'isArray', - 'isEmptyObject', - 'isFunction', - 'isNumeric', - 'isPlainObject', - 'isWindow', - 'isXMLDoc', - 'makeArray', - 'map', - 'merge', - 'noConflict', - 'noop', - 'now', - 'param', - 'parseHTML', - 'parseJSON', - 'parseXML', - 'post', - 'proxy', - 'queue', - 'removeData', - 'sub', - 'support', - 'trim', - 'type', - 'unique', - 'when', - 'always', - 'done', - 'fail', - 'isRejected', - 'isResolved', - 'notify', - 'notifyWith', - 'pipe', - 'progress', - 'promise', - 'reject', - 'rejectWith', - 'resolve', - 'resolveWith', - 'state', - 'then', - 'currentTarget', - 'data', - 'delegateTarget', - 'isDefaultPrevented', - 'isImmediatePropagationStopped', - 'isPropagationStopped', - 'metaKey', - 'namespace', - 'pageX', - 'pageY', - 'preventDefault', - 'relatedTarget', - 'result', - 'stopImmediatePropagation', - 'stopPropagation', - 'target', - 'timeStamp', - 'type', - 'which' -]; - -var $Proto = {}; -methods.forEach(method => $Proto[method] = returnThis); -statics.forEach(staticMeth => $[staticMeth] = return$); -$.prototype = Object.create($); diff --git a/client/iFrameScripts.js b/client/iFrameScripts.js index 7bbaa3eb10..b2d21bf77c 100644 --- a/client/iFrameScripts.js +++ b/client/iFrameScripts.js @@ -1,21 +1,50 @@ /* eslint-disable no-undef, no-unused-vars, no-native-reassign */ -window.__$ = parent.$; -window.__$(function() { +// the $ on the iframe window object is the same +// as the one used on the main site, but +// uses the iframe document as the context +window.$(document).ready(function() { var _ = parent._; var Rx = parent.Rx; var chai = parent.chai; var assert = chai.assert; var tests = parent.tests; var common = parent.common; - var editor = common.editor.getValue(); - // change the context of $ so it uses the iFrame for testing - var $ = __$.proxy(__$.fn.find, __$(document)); + + window.loopProtect.hit = function(line) { + window.__err = new Error( + 'Potential infinite loop at line ' + line + ); + }; + + common.getJsOutput = function evalJs(code = '') { + let output; + try { + /* eslint-disable no-eval */ + output = eval(code); + /* eslint-enable no-eval */ + } catch (e) { + window.__err = e; + } + return output; + }; common.runPreviewTests$ = - function runPreviewTests$({ tests = [], ...rest }) { - return Rx.Observable.from(tests) + function runPreviewTests$({ + tests = [], + originalCode, + ...rest + }) { + const code = originalCode; + const editor = { getValue() { return originalCode; } }; + if (window.__err) { + return Rx.Observable.throw(window.__err); + } + + return Rx.Observable.from(tests, null, null, Rx.Scheduler.default) + .delay(100) .map(test => { const userTest = {}; + common.appendToOutputDisplay(''); try { /* eslint-disable no-eval */ eval(test); @@ -23,16 +52,21 @@ window.__$(function() { } catch (e) { userTest.err = e.message.split(':').shift(); } finally { - userTest.text = test - .split(',') - .pop() - .replace(/\'/g, '') - .replace(/\)/, ''); + if (!test.match(/message: /g)) { + // assumes test does not contain arrays + // This is a patch until all test fall into this pattern + userTest.text = test + .split(',') + .pop(); + userTest.text = 'message: ' + userTest.text + '\');'; + } else { + userTest.text = test; + } } return userTest; }) .toArray() - .map(tests => ({ ...rest, tests })); + .map(tests => ({ ...rest, tests, originalCode })); }; // now that the runPreviewTest$ is defined diff --git a/gulpfile.js b/gulpfile.js index 3720c9ad72..b8311461b3 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -18,6 +18,7 @@ var Rx = require('rx'), uglify = require('gulp-uglify'), merge = require('merge-stream'), babel = require('gulp-babel'), + sourcemaps = require('gulp-sourcemaps'), // react app webpack = require('webpack-stream'), @@ -85,7 +86,8 @@ var paths = { 'public/bower_components/CodeMirror/mode/xml/xml.js', 'public/bower_components/CodeMirror/mode/css/css.js', 'public/bower_components/CodeMirror/mode/htmlmixed/htmlmixed.js', - 'node_modules/emmet-codemirror/dist/emmet.js' + 'node_modules/emmet-codemirror/dist/emmet.js', + 'public/js/lib/loop-protect/loop-protect.js' ], vendorMain: [ @@ -103,20 +105,20 @@ var paths = { js: [ 'client/main.js', 'client/iFrameScripts.js', - 'client/plugin.js', - 'client/faux.js' + 'client/plugin.js' ], commonFramework: [ 'init', 'bindings', 'add-test-to-string', - 'add-faux-stream', 'code-storage', 'code-uri', + 'add-loop-protect', + 'get-iframe', + 'update-preview', 'create-editor', 'detect-unsafe-code-stream', - 'detect-loops-stream', 'display-test-results', 'execute-challenge-stream', 'output-display', @@ -125,7 +127,6 @@ var paths = { 'run-tests-stream', 'show-completion', 'step-challenge', - 'update-preview', 'end' ], @@ -434,7 +435,9 @@ gulp.task('dependents', ['js'], function() { return gulp.src(formatCommonFrameworkPaths.call(paths.commonFramework)) .pipe(plumber({ errorHandler: errorHandler })) .pipe(babel()) + .pipe(__DEV__ ? sourcemaps.init() : gutil.noop()) .pipe(concat('commonFramework.js')) + .pipe(__DEV__ ? sourcemaps.write() : gutil.noop()) .pipe(__DEV__ ? gutil.noop() : uglify()) .pipe(revReplace({ manifest: manifest })) .pipe(gulp.dest(dest)) diff --git a/package.json b/package.json index 6a9b3990dc..01be061ba0 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "browser-sync": "^2.9.12", "chai": "^3.4.0", "envify": "^3.4.0", + "gulp-sourcemaps": "^1.6.0", "istanbul": "~0.4.0", "jsonlint": "^1.6.2", "loopback-component-explorer": "^2.1.1", diff --git a/public/js/lib/loop-protect/LICENSE.md b/public/js/lib/loop-protect/LICENSE.md new file mode 100644 index 0000000000..9c4f96a8ec --- /dev/null +++ b/public/js/lib/loop-protect/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 JS Bin Ltd + +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. diff --git a/public/js/lib/loop-protect/loop-protect.js b/public/js/lib/loop-protect/loop-protect.js new file mode 100644 index 0000000000..5050605b9e --- /dev/null +++ b/public/js/lib/loop-protect/loop-protect.js @@ -0,0 +1,432 @@ +if (typeof DEBUG === 'undefined') { DEBUG = true; } + +(function (root, factory) { + 'use strict'; + /*global define*/ + if (typeof define === 'function' && define.amd) { + define(factory(root)); + } else if (typeof exports === 'object') { + module.exports = factory(root); + } else { + root.loopProtect = factory(root); + } +})(this, function loopProtectModule(root) { + /*global DEBUG*/ + 'use strict'; + var debug = null; + + // the standard loops - note that recursive is not supported + var re = /\b(for|while|do)\b/g; + var reSingle = /\b(for|while|do)\b/; + var labelRe = /\b([a-z_]{1}\w+:)/i; + var comments = /(?:\/\*(?:[\s\S]*?)\*\/)|(?:([\s;])+\/\/(?:.*)$)/gm; + + var loopProtect = rewriteLoops; + + // used in the loop detection + loopProtect.counters = {}; + + // expose debug info + loopProtect.debug = function debugSwitch(state) { + debug = state ? function () { + console.log.apply(console, [].slice.apply(arguments)); + } : function () {}; + }; + + loopProtect.debug(false); // off by default + + // the method - as this could be aliased to something else + loopProtect.alias = 'loopProtect'; + + function inMultilineComment(lineNum, lines) { + if (lineNum === 0) { + return false; + } + + var j = lineNum; + var closeCommentTags = 1; // let's assume we're inside a comment + var closePos = -1; + var openPos = -1; + + do { + j -= 1; + DEBUG && debug('looking backwards ' + lines[j]); // jshint ignore:line + closePos = lines[j].indexOf('*/'); + openPos = lines[j].indexOf('/*'); + + if (closePos !== -1) { + closeCommentTags++; + } + + if (openPos !== -1) { + closeCommentTags--; + + if (closeCommentTags === 0) { + DEBUG && debug('- exit: part of a multiline comment'); // jshint ignore:line + return true; + } + } + } while (j !== 0); + + return false; + } + + function inCommentOrString(index, line) { + var character; + while (--index > -1) { + character = line.substr(index, 1); + if (character === '"' || character === '\'' || character === '.') { + // our loop keyword was actually either in a string or a property, so let's exit and ignore this line + DEBUG && debug('- exit: matched inside a string or property key'); // jshint ignore:line + return true; + } + if (character === '/' || character === '*') { + // looks like a comment, go back one to confirm or not + --index; + if (character === '/') { + // we've found a comment, so let's exit and ignore this line + DEBUG && debug('- exit: part of a comment'); // jshint ignore:line + return true; + } + } + } + return false; + } + + function directlyBeforeLoop(index, lineNum, lines) { + reSingle.lastIndex = 0; + labelRe.lastIndex = 0; + var beforeLoop = false; + + var theRest = lines.slice(lineNum).join('\n').substr(index).replace(labelRe, ''); + theRest.replace(reSingle, function commentStripper(match, capture, i) { + var target = theRest.substr(0, i).replace(comments, '').trim(); + DEBUG && debug('- directlyBeforeLoop: ' + target); // jshint ignore:line + if (target.length === 0) { + beforeLoop = true; + } + // strip comments out of the target, and if there's nothing else + // it's a valid label...I hope! + }); + + return beforeLoop; + } + + /** + * Look for for, while and do loops, and inserts *just* at the start of the + * loop, a check function. + */ + function rewriteLoops(code, offset) { + var recompiled = []; + var lines = code.split('\n'); + var disableLoopProtection = false; + var method = loopProtect.alias + '.protect'; + var ignore = {}; + var pushonly = {}; + var labelPostion = null; + + function insertReset(lineNum, line, matchPosition) { + // recompile the line with the reset **just** before the actual loop + // so that we insert in to the correct location (instead of possibly + // outside the logic + return line.slice(0, matchPosition) + ';' + method + '({ line: ' + lineNum + ', reset: true }); ' + line.slice(matchPosition); + }; + + if (!offset) { + offset = 0; + } + + lines.forEach(function eachLine(line, lineNum) { + // reset our regexp each time. + re.lastIndex = 0; + labelRe.lastIndex = 0; + + if (disableLoopProtection) { + return; + } + + if (line.toLowerCase().indexOf('noprotect') !== -1) { + disableLoopProtection = true; + } + + var index = -1; + var matchPosition = -1; + var originalLineNum = lineNum; + // +1 since we're humans and don't read lines numbers from zero + var printLineNumber = lineNum - offset + 1; + var character = ''; + // special case for `do` loops, as they're end with `while` + var dofound = false; + var findwhile = false; + var terminator = false; + var matches = line.match(re) || []; + var match = matches.length ? matches[0] : ''; + var labelMatch = line.match(labelRe) || []; + var openBrackets = 0; + var openBraces = 0; + + if (labelMatch.length) { + DEBUG && debug('- label match'); // jshint ignore:line + index = line.indexOf(labelMatch[1]); + if (!inCommentOrString(index, line)) { + if (!inMultilineComment(lineNum, lines)) { + if (directlyBeforeLoop(index, lineNum, lines)) { + DEBUG && debug('- found a label: "' + labelMatch[0] + '"'); // jshint ignore:line + labelPostion = lineNum; + } else { + DEBUG && debug('- ignored "label", false positive'); // jshint ignore:line + } + } else { + DEBUG && debug('- ignored label in multline comment'); // jshint ignore:line + } + } else { + DEBUG && debug('- ignored label in string or comment'); // jshint ignore:line + } + } + + if (ignore[lineNum]) { + DEBUG && debug(' -exit: ignoring line ' + lineNum +': ' + line); // jshint ignore:line + return; + } + + if (pushonly[lineNum]) { + DEBUG && debug('- exit: ignoring, but adding line ' + lineNum + ': ' + line); // jshint ignore:line + recompiled.push(line); + return; + } + + // if there's more than one match, we just ignore this kind of loop + // otherwise I'm going to be writing a full JavaScript lexer...and god + // knows I've got better things to be doing. + if (match && matches.length === 1 && line.indexOf('jsbin') === -1) { + DEBUG && debug('match on ' + match + '\n'); // jshint ignore:line + + // there's a special case for protecting `do` loops, we need to first + // prtect the `do`, but then ignore the closing `while` statement, so + // we reset the search state for this special case. + dofound = match === 'do'; + + // make sure this is an actual loop command by searching backwards + // to ensure it's not a string, comment or object property + matchPosition = index = line.indexOf(match); + + // first we need to walk backwards to ensure that our match isn't part + // of a string or part of a comment + if (inCommentOrString(index, line)) { + recompiled.push(line); + return; + } + + // it's quite possible we're in the middle of a multiline + // comment, so we'll cycle up looking for an opening comment, + // and if there's one (and not a closing `*/`), then we'll + // ignore this line as a comment + if (inMultilineComment(lineNum, lines)) { + recompiled.push(line); + return; + } + + // now work our way forward to look for '{' + index = line.indexOf(match) + match.length; + + if (index === line.length) { + if (index === line.length && lineNum < (lines.length-1)) { + // move to the next line + DEBUG && debug('- moving to next line'); // jshint ignore:line + recompiled.push(line); + lineNum++; + line = lines[lineNum]; + ignore[lineNum] = true; + index = 0; + } + + } + + while (index < line.length) { + character = line.substr(index, 1); + // DEBUG && debug(character, index); // jshint ignore:line + + if (character === '(') { + openBrackets++; + } + + if (character === ')') { + openBrackets--; + + if (openBrackets === 0 && terminator === false) { + terminator = index; + } + } + + if (character === '{') { + openBraces++; + } + + if (character === '}') { + openBraces--; + } + + if (openBrackets === 0 && (character === ';' || character === '{')) { + // if we're a non-curlies loop, then convert to curlies to get our code inserted + if (character === ';') { + if (lineNum !== originalLineNum) { + DEBUG && debug('- multiline inline loop'); // jshint ignore:line + // affect the compiled line + recompiled[originalLineNum] = recompiled[originalLineNum].substring(0, terminator + 1) + '{\nif (' + method + '({ line: ' + printLineNumber + ' })) break;\n' + recompiled[originalLineNum].substring(terminator + 1); + line += '\n}\n'; + } else { + // simpler + DEBUG && debug('- single line inline loop'); // jshint ignore:line + line = line.substring(0, terminator + 1) + '{\nif (' + method + '({ line: ' + printLineNumber + ' })) break;\n' + line.substring(terminator + 1) + '\n}\n'; + } + + } else if (character === '{') { + DEBUG && debug('- multiline with braces'); // jshint ignore:line + var insert = ';\nif (' + method + '({ line: ' + printLineNumber + ' })) break;\n'; + line = line.substring(0, index + 1) + insert + line.substring(index + 1); + + index += insert.length; + } + + // work out where to put the reset + if (lineNum === originalLineNum && labelPostion === null) { + DEBUG && debug('- simple reset insert'); // jshint ignore:line + line = insertReset(printLineNumber, line, matchPosition); + index += (';' + method + '({ line: ' + lineNum + ', reset: true }); ').length; + } else { + // insert the reset above the originalLineNum OR if this loop used + // a label, we have to insert the reset *above* the label + if (labelPostion === null) { + DEBUG && debug('- reset inserted above original line'); // jshint ignore:line + recompiled[originalLineNum] = insertReset(printLineNumber, recompiled[originalLineNum], matchPosition); + } else { + DEBUG && debug('- reset inserted above matched label on line ' + labelPostion); // jshint ignore:line + if (recompiled[labelPostion] === undefined) { + labelPostion--; + matchPosition = 0; + } + recompiled[labelPostion] = insertReset(printLineNumber, recompiled[labelPostion], matchPosition); + labelPostion = null; + } + } + + recompiled.push(line); + + if (!dofound) { + return; + } else { + DEBUG && debug('searching for closing `while` statement for: ' + line); // jshint ignore:line + // cycle forward until we find the close brace, after which should + // be our while statement to ignore + findwhile = false; + while (index < line.length) { + character = line.substr(index, 1); + + if (character === '{') { + openBraces++; + } + + if (character === '}') { + openBraces--; + } + + if (openBraces === 0) { + findwhile = true; + } else { + findwhile = false; + } + + if (openBraces === 0) { + DEBUG && debug('outside of closure, looking for `while` statement: ' + line); // jshint ignore:line + } + + if (findwhile && line.indexOf('while') !== -1) { + DEBUG && debug('- exit as we found `while`: ' + line); // jshint ignore:line + pushonly[lineNum] = true; + return; + } + + index++; + + if (index === line.length && lineNum < (lines.length-1)) { + lineNum++; + line = lines[lineNum]; + DEBUG && debug(line); // jshint ignore:line + index = 0; + } + } + return; + } + } + + index++; + + if (index === line.length && lineNum < (lines.length-1)) { + // move to the next line + DEBUG && debug('- moving to next line'); // jshint ignore:line + recompiled.push(line); + lineNum++; + line = lines[lineNum]; + ignore[lineNum] = true; + index = 0; + } + } + } else { + // else we're a regular line, and we shouldn't be touched + DEBUG && debug('regular line ' + line); // jshint ignore:line + recompiled.push(line); + } + }); + + DEBUG && debug('---- source ----'); // jshint ignore:line + DEBUG && debug(code); // jshint ignore:line + DEBUG && debug('---- rewrite ---'); // jshint ignore:line + DEBUG && debug(recompiled.join('\n')); // jshint ignore:line + DEBUG && debug(''); // jshint ignore:line + + return disableLoopProtection ? code : recompiled.join('\n'); + }; + + /** + * Injected code in to user's code to **try** to protect against infinite + * loops cropping up in the code, and killing the browser. Returns true + * when the loops has been running for more than 100ms. + */ + loopProtect.protect = function protect(state) { + loopProtect.counters[state.line] = loopProtect.counters[state.line] || {}; + var line = loopProtect.counters[state.line]; + var now = (new Date()).getTime(); + + if (state.reset) { + line.time = now; + line.hit = 0; + line.last = 0; + } + + line.hit++; + if ((now - line.time) > 100) {//} && line.hit !== line.last+1) { + // We've spent over 100ms on this loop... smells infinite. + loopProtect.hit(state.line); + // Returning true prevents the loop running again + return true; + } + line.last++; + return false; + }; + + loopProtect.hit = function hit(line) { + var msg = 'Exiting potential infinite loop at line ' + line + '. To disable loop protection: add "// noprotect" to your code'; + if (root.proxyConsole) { + root.proxyConsole.error(msg); + } else { + console.error(msg); + } + }; + + loopProtect.reset = function reset() { + // reset the counters + loopProtect.counters = {}; + }; + + return loopProtect; +}); diff --git a/seed/challenges/basic-javascript.json b/seed/challenges/basic-javascript.json index 9d395bfccd..79d1ff0943 100644 --- a/seed/challenges/basic-javascript.json +++ b/seed/challenges/basic-javascript.json @@ -1658,10 +1658,10 @@ "Math.floor(Math.random() * (3 - 1 + 1)) + 1;" ], "tests": [ - "assert(typeof runSlots($(\".slot\"))[0] === \"number\" && runSlots($(\".slot\"))[0] > 0 && runSlots($(\".slot\"))[0] < 4, 'message: slotOne should be a random number.')", - "assert(typeof runSlots($(\".slot\"))[1] === \"number\" && runSlots($(\".slot\"))[1] > 0 && runSlots($(\".slot\"))[1] < 4, 'message: slotTwo should be a random number.')", - "assert(typeof runSlots($(\".slot\"))[2] === \"number\" && runSlots($(\".slot\"))[2] > 0 && runSlots($(\".slot\"))[2] < 4, 'message: slotThree should be a random number.')", - "assert((function(){if(editor.match(/Math\\.floor\\(\\s?Math\\.random\\(\\)\\s?\\*\\s?\\(\\s?3\\s?\\-\\s?1\\s?\\+\\s?1\\s?\\)\\s?\\)\\s?\\+\\s?1/gi) !== null){return editor.match(/slot.*?=.*?\\(.*?\\).*?/gi).length >= 3;}else{return false;}})(), 'message: You should have used Math.floor(Math.random() * (3 - 1 + 1)) + 1; three times to generate your random numbers.')" + "assert(typeof runSlots($(\".slot\"))[0] === \"number\" && runSlots($(\".slot\"))[0] > 0 && runSlots($(\".slot\"))[0] < 4, 'message: slotOne should be a random number.');", + "assert(typeof runSlots($(\".slot\"))[1] === \"number\" && runSlots($(\".slot\"))[1] > 0 && runSlots($(\".slot\"))[1] < 4, 'message: slotTwo should be a random number.');", + "assert(typeof runSlots($(\".slot\"))[2] === \"number\" && runSlots($(\".slot\"))[2] > 0 && runSlots($(\".slot\"))[2] < 4, 'message: slotThree should be a random number.');", + "assert((function(){if(code.match(/Math\\.floor\\(\\s?Math\\.random\\(\\)\\s?\\*\\s?\\(\\s?3\\s?\\-\\s?1\\s?\\+\\s?1\\s?\\)\\s?\\)\\s?\\+\\s?1/gi) !== null){return code.match(/slot.*?=.*?\\(.*?\\).*?/gi).length >= 3;}else{return false;}})(), 'message: You should have used Math.floor(Math.random() * (3 - 1 + 1)) + 1; three times to generate your random numbers.');" ], "challengeSeed": [ "fccss", @@ -1825,7 +1825,7 @@ "If all three numbers match, we should also set the text \"It's A Win\" to the element with class logger." ], "tests": [ - "assert((function(){var data = runSlots();return data === null || data.toString().length === 1;})(), 'message: If all three of our random numbers are the same we should return that number. Otherwise we should return null.')" + "assert((function(){var data = runSlots();return data === null || data.toString().length === 1;})(), 'message: If all three of our random numbers are the same we should return that number. Otherwise we should return null.');" ], "challengeSeed": [ "fccss", @@ -1994,8 +1994,8 @@ "Use the above selector to display each number in its corresponding slot." ], "tests": [ - "assert((function(){runSlots();if($($(\".slot\")[0]).html().replace(/\\s/gi, \"\") !== \"\" && $($(\".slot\")[1]).html().replace(/\\s/gi, \"\") !== \"\" && $($(\".slot\")[2]).html().replace(/\\s/gi, \"\") !== \"\"){return true;}else{return false;}})(), 'message: You should be displaying the result of the slot numbers in the corresponding slots.')", - "assert((editor.match( /\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[\\d\\]\\s*?\\)/gi) && editor.match( /\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[\\d\\]\\s*?\\)/gi ).length >= 3 && editor.match( /\\.html\\(slotOne\\)/gi ) && editor.match( /\\.html\\(slotTwo\\)/gi ) && editor.match( /\\.html\\(slotThree\\)/gi )), 'message: You should have used the the selector given in the description to select each slot and assign it the value of slotOne, slotTwo and slotThree respectively.')" + "assert((function(){runSlots();if($($(\".slot\")[0]).html().replace(/\\s/gi, \"\") !== \"\" && $($(\".slot\")[1]).html().replace(/\\s/gi, \"\") !== \"\" && $($(\".slot\")[2]).html().replace(/\\s/gi, \"\") !== \"\"){return true;}else{return false;}})(), 'message: You should be displaying the result of the slot numbers in the corresponding slots.');", + "assert((code.match( /\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[\\d\\]\\s*?\\)/gi) && code.match( /\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[\\d\\]\\s*?\\)/gi ).length >= 3 && code.match( /\\.html\\(slotOne\\)/gi ) && code.match( /\\.html\\(slotTwo\\)/gi ) && code.match( /\\.html\\(slotThree\\)/gi )), 'message: You should have used the the selector given in the description to select each slot and assign it the value of slotOne, slotTwo and slotThree respectively.');" ], "challengeSeed": [ "fccss", @@ -2168,13 +2168,13 @@ "Set up all three slots like this, then click the \"Go\" button to play the slot machine." ], "tests": [ - "assert((editor.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[\\d\\]\\s*?\\)\\.html\\(\\s*?\\'\\\\'\\s*?\\);/gi) && editor.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[\\d\\]\\s*?\\)\\.html\\(\\s*?\\'\\\\'\\s*?\\);/gi).length >= 3), 'message: Use the provided code three times. One for each slot.')", - "assert(editor.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[0\\]\\s*?\\)/gi), 'message: You should have used $('.slot')[0] at least once.')", - "assert(editor.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[1\\]\\s*?\\)/gi), 'message: You should have used $('.slot')[1] at least once.')", - "assert(editor.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[2\\]\\s*?\\)/gi), 'message: You should have used $('.slot')[2] at least once.')", - "assert(editor.match(/slotOne/gi) && editor.match(/slotOne/gi).length >= 7, 'message: You should have used the slotOne value at least once.')", - "assert(editor.match(/slotTwo/gi) && editor.match(/slotTwo/gi).length >= 8, 'message: You should have used the slotTwo value at least once.')", - "assert(editor.match(/slotThree/gi) && editor.match(/slotThree/gi).length >= 7, 'message: You should have used the slotThree value at least once.')" + "assert((code.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[\\d\\]\\s*?\\)\\.html\\(\\s*?\\'\\\\'\\s*?\\);/gi) && code.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[\\d\\]\\s*?\\)\\.html\\(\\s*?\\'\\\\'\\s*?\\);/gi).length >= 3), 'message: Use the provided code three times. One for each slot.');", + "assert(code.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[0\\]\\s*?\\)/gi), 'message: You should have used $('.slot')[0] at least once.');", + "assert(code.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[1\\]\\s*?\\)/gi), 'message: You should have used $('.slot')[1] at least once.');", + "assert(code.match(/\\$\\s*?\\(\\s*?\\$\\s*?\\(\\s*?(?:'|\")\\s*?\\.slot\\s*?(?:'|\")\\s*?\\)\\[2\\]\\s*?\\)/gi), 'message: You should have used $('.slot')[2] at least once.');", + "assert(code.match(/slotOne/gi) && code.match(/slotOne/gi).length >= 7, 'message: You should have used the slotOne value at least once.');", + "assert(code.match(/slotTwo/gi) && code.match(/slotTwo/gi).length >= 8, 'message: You should have used the slotTwo value at least once.');", + "assert(code.match(/slotThree/gi) && code.match(/slotThree/gi).length >= 7, 'message: You should have used the slotThree value at least once.');" ], "challengeSeed": [ "fccss", diff --git a/seed/challenges/bootstrap.json b/seed/challenges/bootstrap.json index 80ed2d43d4..76e6d180cf 100644 --- a/seed/challenges/bootstrap.json +++ b/seed/challenges/bootstrap.json @@ -16,9 +16,9 @@ "To get started, we should nest all of our HTML in a div element with the class container-fluid." ], "tests": [ - "assert($(\"div\").hasClass(\"container-fluid\"), 'message: Your div element should have the class container-fluid.')", - "assert(editor.match(/<\\/div>/g) && editor.match(/
/g).length === editor.match(/
div elements has a closing tag.')", - "assert($(\".container-fluid\").children().length >= 8, 'message: Make sure you have nested all HTML elements in .container-fluid.')" + "assert($(\"div\").hasClass(\"container-fluid\"), 'message: Your div element should have the class container-fluid.');", + "assert(code.match(/<\\/div>/g) && code.match(/
/g).length === code.match(/
div elements has a closing tag.');", + "assert($(\".container-fluid\").children().length >= 8, 'message: Make sure you have nested all HTML elements in .container-fluid.');" ], "challengeSeed": [ "", @@ -106,11 +106,11 @@ "Fortunately, with Bootstrap, all we need to do is add the img-responsive class to your image. Do this, and the image should perfectly fit the width of your page." ], "tests": [ - "assert($(\"img\").length === 2, 'message: You should have a total of two images.')", - "assert($(\"img:eq(1)\").hasClass(\"img-responsive\"), 'message: Your new image should be below your old one and have the class img-responsive.')", - "assert(!$(\"img:eq(1)\").hasClass(\"smaller-image\"), 'message: Your new image should not have the class smaller-image.')", - "assert($(\"img:eq(1)\").attr(\"src\") === \"http://bit.ly/fcc-running-cats\", 'message: Your new image should have a src of http://bit.ly/fcc-running-cats.')", - "assert(editor.match(//g).length === 2 && editor.match(/img element has a closing angle bracket.')" + "assert($(\"img\").length === 2, 'message: You should have a total of two images.');", + "assert($(\"img:eq(1)\").hasClass(\"img-responsive\"), 'message: Your new image should be below your old one and have the class img-responsive.');", + "assert(!$(\"img:eq(1)\").hasClass(\"smaller-image\"), 'message: Your new image should not have the class smaller-image.');", + "assert($(\"img:eq(1)\").attr(\"src\") === \"http://bit.ly/fcc-running-cats\", 'message: Your new image should have a src of http://bit.ly/fcc-running-cats.');", + "assert(code.match(//g).length === 2 && code.match(/img element has a closing angle bracket.');" ], "challengeSeed": [ "", @@ -196,8 +196,8 @@ "<h2 class=\"red-text text-center\">your text</h2>" ], "tests": [ - "assert($(\"h2\").hasClass(\"text-center\"), 'message: Your h2 element should be centered by applying the class text-center')", - "assert($(\"h2\").hasClass(\"red-text\"), 'message: Your h2 element should still have the class red-text')" + "assert($(\"h2\").hasClass(\"text-center\"), 'message: Your h2 element should be centered by applying the class text-center');", + "assert($(\"h2\").hasClass(\"red-text\"), 'message: Your h2 element should still have the class red-text');" ], "challengeSeed": [ "", @@ -283,9 +283,9 @@ "Create a new button element below your large kitten photo. Give it the class btn and the text of \"Like\"." ], "tests": [ - "assert(new RegExp(\"like\",\"gi\").test($(\"button\").text()), 'message: Create a new button element with the text \"Like\".')", - "assert($(\"button\").hasClass(\"btn\"), 'message: Your new button should have the class btn.')", - "assert(editor.match(/<\\/button>/g) && editor.match(/