diff --git a/.eslintrc b/.eslintrc index 216a7b76a9..8d75edc0d8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,6 +6,7 @@ } }, "env": { + "es6": true, "browser": true, "mocha": true, "node": true diff --git a/.gitignore b/.gitignore index 420ddb5c2e..f28fd1ea0d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ public/js/main* public/js/commonFramework* public/js/sandbox* public/js/iFrameScripts* +public/js/frame-runner* public/js/plugin* public/js/vendor* public/js/faux* diff --git a/client/frame-runner.js b/client/frame-runner.js new file mode 100644 index 0000000000..7d399590cd --- /dev/null +++ b/client/frame-runner.js @@ -0,0 +1,70 @@ +document.addEventListener('DOMContentLoaded', function() { + var common = parent.__common; + var Rx = parent.Rx; + + common.getJsOutput = function evalJs(source = '') { + if (window.__err || !common.shouldRun()) { + return window.__err || 'source disabled'; + } + let output; + try { + /* eslint-disable no-eval */ + output = eval(source); + /* eslint-enable no-eval */ + } catch (e) { + window.__err = e; + } + return output; + }; + + common.runTests$ = function runTests$({ tests = [], source }) { + const editor = { getValue() { return source; } }; + if (window.__err) { + return Rx.Observable.throw(window.__err); + } + + // Iterate through the test one at a time + // on new stacks + return Rx.Observable.from(tests, null, null, Rx.Scheduler.default) + // add delay here for firefox to catch up + .delay(100) + .map(({ text, testString }) => { + const newTest = { text, testString }; + let test; + try { + /* eslint-disable no-eval */ + test = eval(testString); + /* eslint-enable no-eval */ + if (typeof test === 'function') { + // maybe sync/promise/observable + if (test.length === 0) { + test(); + } + // callback test + if (test.length === 1) { + console.log('callback test'); + } + } + } catch (e) { + newTest.err = e.message.split(':').shift(); + } + return newTest; + }) + // gather tests back into an array + .toArray(); + }; + + // used when updating preview without running tests + common.checkPreview$ = function checkPreview$(args) { + if (window.__err) { + return Rx.Observable.throw(window.__err); + } + return Rx.Observable.just(args); + }; + + // now that the runPreviewTest$ is defined + // we set the subject to true + // this will let the updatePreview + // script now that we are ready. + common.testFrameReady$.onNext(true); +}); diff --git a/client/iFrameScripts.js b/client/iFrameScripts.js index e9d1456f88..7d399590cd 100644 --- a/client/iFrameScripts.js +++ b/client/iFrameScripts.js @@ -1,23 +1,15 @@ -/* eslint-disable no-undef, no-unused-vars, no-native-reassign */ -// 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._; +document.addEventListener('DOMContentLoaded', function() { + var common = parent.__common; var Rx = parent.Rx; - var chai = parent.chai; - var assert = chai.assert; - var tests = parent.tests; - var common = parent.common; - common.getJsOutput = function evalJs(code = '') { + common.getJsOutput = function evalJs(source = '') { if (window.__err || !common.shouldRun()) { - return window.__err || 'code disabled'; + return window.__err || 'source disabled'; } let output; try { /* eslint-disable no-eval */ - output = eval(code); + output = eval(source); /* eslint-enable no-eval */ } catch (e) { window.__err = e; @@ -25,49 +17,42 @@ window.$(document).ready(function() { return output; }; - common.runPreviewTests$ = - function runPreviewTests$({ - tests = [], - originalCode, - ...rest - }) { - const code = originalCode; - const editor = { getValue() { return originalCode; } }; - if (window.__err) { - return Rx.Observable.throw(window.__err); - } + common.runTests$ = function runTests$({ tests = [], source }) { + const editor = { getValue() { return source; } }; + if (window.__err) { + return Rx.Observable.throw(window.__err); + } - // Iterate throught the test one at a time - // on new stacks - return Rx.Observable.from(tests, null, null, Rx.Scheduler.default) - // add delay here for firefox to catch up - .delay(100) - .map(test => { - const userTest = {}; - try { - /* eslint-disable no-eval */ - eval(test); - /* eslint-enable no-eval */ - } catch (e) { - userTest.err = e.message.split(':').shift(); - } finally { - 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; + // Iterate through the test one at a time + // on new stacks + return Rx.Observable.from(tests, null, null, Rx.Scheduler.default) + // add delay here for firefox to catch up + .delay(100) + .map(({ text, testString }) => { + const newTest = { text, testString }; + let test; + try { + /* eslint-disable no-eval */ + test = eval(testString); + /* eslint-enable no-eval */ + if (typeof test === 'function') { + // maybe sync/promise/observable + if (test.length === 0) { + test(); + } + // callback test + if (test.length === 1) { + console.log('callback test'); } } - return userTest; - }) - // gather tests back into an array - .toArray() - .map(tests => ({ ...rest, tests, originalCode })); - }; + } catch (e) { + newTest.err = e.message.split(':').shift(); + } + return newTest; + }) + // gather tests back into an array + .toArray(); + }; // used when updating preview without running tests common.checkPreview$ = function checkPreview$(args) { @@ -81,5 +66,5 @@ window.$(document).ready(function() { // we set the subject to true // this will let the updatePreview // script now that we are ready. - common.previewReady$.onNext(true); + common.testFrameReady$.onNext(true); }); diff --git a/client/index.js b/client/index.js index 887380d1ee..77d72f8177 100644 --- a/client/index.js +++ b/client/index.js @@ -51,7 +51,7 @@ createApp({ serviceOptions, initialState, middlewares: [ routingMiddleware ], - sagas, + sagas: [...sagas ], sagaOptions, reducers: { routing }, enhancers: [ devTools ] diff --git a/client/new-framework/add-loop-protect.js b/client/new-framework/add-loop-protect.js deleted file mode 100644 index 05d986e1a6..0000000000 --- a/client/new-framework/add-loop-protect.js +++ /dev/null @@ -1,21 +0,0 @@ -import loopProtect from 'loopProtect'; - -loopProtect.hit = function hit(line) { - var err = 'Error: Exiting potential infinite loop at line ' + - line + - '. To disable loop protection, write: \n\\/\\/ noprotect\nas the first' + - 'line. Beware that if you do have an infinite loop in your code' + - 'this will crash your browser.'; - console.error(err); -}; - -// Observable[Observable[File]]::addLoopProtect() => Observable[String] -export default function addLoopProtect() { - const source = this; - return source.map(files$ => files$.map(file => { - if (file.extname === 'js') { - file.contents = loopProtect(file.contents); - } - return file; - })); -} diff --git a/client/new-framework/execute-challenge-saga.js b/client/new-framework/execute-challenge-saga.js deleted file mode 100644 index 7044642e0e..0000000000 --- a/client/new-framework/execute-challenge-saga.js +++ /dev/null @@ -1,13 +0,0 @@ -import createTypes from '../../common/app/utils/create-types'; -const filterTypes = [ - execute -]; -export default function executeChallengeSaga(action$, getState) { - return action$ - .filter(({ type }) => filterTypes.some(_type => _type === type)) - .map(action => { - if (action.type === execute) { - const editors = getState().editors; - } - }) -} diff --git a/client/new-framework/throw-unsafe-code.js b/client/new-framework/throwers.js similarity index 74% rename from client/new-framework/throw-unsafe-code.js rename to client/new-framework/throwers.js index 567fa62152..ad25cf54a6 100644 --- a/client/new-framework/throw-unsafe-code.js +++ b/client/new-framework/throwers.js @@ -1,14 +1,14 @@ import { helpers, Observable } from 'rx'; const throwForJsHtml = { - extname: /js|html/, + ext: /js|html/, throwers: [ { name: 'multiline-comment', description: 'Detect if a JS multi-line comment is left open', - thrower: function checkForComments({ content }) { - const openingComments = content.match(/\/\*/gi); - const closingComments = content.match(/\*\//gi); + thrower: function checkForComments({ contents }) { + const openingComments = contents.match(/\/\*/gi); + const closingComments = contents.match(/\*\//gi); if ( openingComments && (!closingComments || openingComments.length > closingComments.length) @@ -20,8 +20,8 @@ const throwForJsHtml = { name: 'nested-jQuery', description: 'Nested dollar sign calls breaks browsers', detectUnsafeJQ: /\$\s*?\(\s*?\$\s*?\)/gi, - thrower: function checkForNestedJquery({ content }) { - if (content.match(this.detectUnsafeJQ)) { + thrower: function checkForNestedJquery({ contents }) { + if (contents.match(this.detectUnsafeJQ)) { throw new Error('Unsafe $($)'); } } @@ -29,10 +29,10 @@ const throwForJsHtml = { name: 'unfinished-function', description: 'lonely function keywords breaks browsers', detectFunctionCall: /function\s*?\(|function\s+\w+\s*?\(/gi, - thower: function checkForUnfinishedFunction({ content: code }) { + thrower: function checkForUnfinishedFunction({ contents }) { if ( - code.match(/function/g) && - !code.match(this.detectFunctionCall) + contents.match(/function/g) && + !contents.match(this.detectFunctionCall) ) { throw new Error( 'SyntaxError: Unsafe or unfinished function declaration' @@ -43,8 +43,8 @@ const throwForJsHtml = { name: 'unsafe console call', description: 'console call stops tests scripts from running', detectUnsafeConsoleCall: /if\s\(null\)\sconsole\.log\(1\);/gi, - thrower: function checkForUnsafeConsole({ content }) { - if (content.match(this.detectUnsafeConsoleCall)) { + thrower: function checkForUnsafeConsole({ contents }) { + if (contents.match(this.detectUnsafeConsoleCall)) { throw new Error('Invalid if (null) console.log(1); detected'); } } @@ -52,17 +52,17 @@ const throwForJsHtml = { ] }; -export default function pretester() { +export default function throwers() { const source = this; return source.map(file$ => file$.flatMap(file => { - if (!throwForJsHtml.extname.test(file.extname)) { + if (!throwForJsHtml.ext.test(file.ext)) { return Observable.just(file); } return Observable.from(throwForJsHtml.throwers) - .flatMap(({ thrower }) => { + .flatMap(context => { try { let finalObs; - const maybeObservableOrPromise = thrower(file); + const maybeObservableOrPromise = context.thrower(file); if (helpers.isPromise(maybeObservableOrPromise)) { finalObs = Observable.fromPromise(maybeObservableOrPromise); } else if (Observable.isObservable(maybeObservableOrPromise)) { diff --git a/client/new-framework/transformers.js b/client/new-framework/transformers.js new file mode 100644 index 0000000000..537729d899 --- /dev/null +++ b/client/new-framework/transformers.js @@ -0,0 +1,37 @@ +import { Observable } from 'rx'; +import loopProtect from 'loop-protect'; + +loopProtect.hit = function hit(line) { + var err = 'Error: Exiting potential infinite loop at line ' + + line + + '. To disable loop protection, write: \n\\/\\/ noprotect\nas the first' + + 'line. Beware that if you do have an infinite loop in your code' + + 'this will crash your browser.'; + console.error(err); +}; + +const transformersForHtmlJS = { + ext: /html|js/, + transformers: [ + { + name: 'add-loop-protect', + transformer: function addLoopProtect(file) { + file.contents = loopProtect(file.contents); + return file; + } + } + ] +}; + + +// Observable[Observable[File]]::addLoopProtect() => Observable[String] +export default function transformers() { + const source = this; + return source.map(files$ => files$.flatMap(file => { + if (!transformersForHtmlJS.ext.test(file.ext)) { + return Observable.just(file); + } + return Observable.from(transformersForHtmlJS.transformers) + .reduce((file, context) => context.transformer(file), file); + })); +} diff --git a/client/sagas/execute-challenge-saga.js b/client/sagas/execute-challenge-saga.js new file mode 100644 index 0000000000..93d49baf62 --- /dev/null +++ b/client/sagas/execute-challenge-saga.js @@ -0,0 +1,98 @@ +import { Observable } from 'rx'; + +import { ajax$ } from '../../common/utils/ajax-stream'; +import throwers from '../new-framework/throwers'; +import transformers from '../new-framework/transformers'; +import types from '../../common/app/routes/challenges/redux/types'; +import { + frameMain +} from '../../common/app/routes/challenges/redux/actions'; +import { setExt, updateContents } from '../../common/utils/polyvinyl'; + +// createFileStream(files: Dictionary[Path, PolyVinyl]) => +// Observable[...Observable[...PolyVinyl]] +function createFileStream(files = {}) { + return Observable.just( + Observable.from(Object.keys(files)).map(key => files[key]) + ); +} + +const jQuery = { + src: '/bower_components/jquery/dist/jquery.js', + script: true, + type: 'global' +}; + +const scriptCache = new Map(); + +function cacheScript({ src } = {}) { + if (!src) { + return Observable.throw(new Error('No source provided for script')); + } + if (scriptCache.has(src)) { + return scriptCache.get(src); + } + const script$ = ajax$(src) + .doOnNext(res => { + console.log('status', res.status); + if (res.status !== 200) { + throw new Error('Request errror: ' + res.status); + } + }) + .map(({ response }) => response) + .map(script => ``) + .catch(e => (console.error(e), Observable.just(''))) + .shareReplay(); + + scriptCache.set(src, script$); + return script$; +} + +const frameRunner$ = cacheScript({ src: '/js/frame-runner.js' }); + +const htmlCatch = '\n'; +const jsCatch = '\n;/* */'; + +export default function executeChallengeSaga(action$, getState) { + return action$ + .filter(({ type }) => type === types.executeChallenge) + .debounce(750) + .flatMapLatest(() => { + const { files, required = [ jQuery ] } = getState().challengesApp; + return createFileStream(files) + ::throwers() + ::transformers() + // createbuild + .flatMap(file$ => file$.reduce((build, file) => { + let finalFile; + if (file.ext === 'js') { + finalFile = setExt('html', updateContents( + ``, + file + )); + } else if (file.ext === 'css') { + finalFile = setExt('html', updateContents( + ``, + file + )); + } else { + finalFile = file; + } + return build + finalFile.contents + htmlCatch; + }, '')) + // add required scripts and links here + .flatMap(build => { + const header$ = Observable.from(required) + .flatMap(required => { + if (required.script) { + return cacheScript(required); + } + return Observable.just(''); + }) + .reduce((header, required) => header + required, ''); + return Observable.combineLatest(header$, frameRunner$) + .map(([ header, frameRunner ]) => header + build + frameRunner); + }) + .map(build => frameMain(build)); + }); +} diff --git a/client/sagas/frame-saga.js b/client/sagas/frame-saga.js new file mode 100644 index 0000000000..3402d4f6a0 --- /dev/null +++ b/client/sagas/frame-saga.js @@ -0,0 +1,51 @@ +import { BehaviorSubject } from 'rx'; +import types from '../../common/app/routes/challenges/redux/types'; + +// we use three different frames to make them all essentially pure functions +const mainId = 'fcc-main-frame'; +/* +const outputId = 'fcc-output-frame'; +const testId = 'fcc-test-frame'; +*/ + +function createFrame(document, id = mainId) { + const frame = document.createElement('iframe'); + frame.id = id; + frame.setAttribute('style', 'display: none'); + document.body.appendChild(frame); + return frame; +} + +function refreshFrame(frame) { + frame.src = 'about:blank'; + return frame; +} + +function getFrameDocument(document, id = mainId) { + let frame = document.getElementById(id); + if (!frame) { + frame = createFrame(document, id); + } else { + refreshFrame(frame); + } + return frame.contentDocument || frame.contentWindow.document; +} + +function frameMain(build, document) { + const main = getFrameDocument(document); + main.open(); + main.write(build); + main.close(); +} + +export default function frameSaga(actions$, getState, { window, document }) { + window.__common = {}; + window.__common.outputFrameReady$ = new BehaviorSubject(false); + window.__common.testFrameReady$ = new BehaviorSubject(false); + return actions$ + .filter(({ type }) => type === types.frameMain) + .map(action => { + frameMain(action.payload, document); + return null; + }); +} diff --git a/client/sagas/index.js b/client/sagas/index.js index 2c7e2781b7..5a8011cf1b 100644 --- a/client/sagas/index.js +++ b/client/sagas/index.js @@ -3,11 +3,15 @@ import titleSaga from './title-saga'; import localStorageSaga from './local-storage-saga'; import hardGoToSaga from './hard-go-to-saga'; import windowSaga from './window-saga'; +import executeChallengeSaga from './execute-challenge-saga'; +import frameSaga from './frame-saga'; export default [ errSaga, titleSaga, localStorageSaga, hardGoToSaga, - windowSaga + windowSaga, + executeChallengeSaga, + frameSaga ]; diff --git a/common/app/routes/challenges/components/classic/Classic.jsx b/common/app/routes/challenges/components/classic/Classic.jsx index 80f6dceeb0..fe89974e37 100644 --- a/common/app/routes/challenges/components/classic/Classic.jsx +++ b/common/app/routes/challenges/components/classic/Classic.jsx @@ -8,23 +8,23 @@ import Editor from './Editor.jsx'; import SidePanel from './Side-Panel.jsx'; import Preview from './Preview.jsx'; import { challengeSelector } from '../../redux/selectors'; -import { updateFile } from '../../redux/actions'; +import { executeChallenge, updateFile } from '../../redux/actions'; const mapStateToProps = createSelector( challengeSelector, state => state.challengesApp.tests, state => state.challengesApp.files, - state => state.challengesApp.path, - ({ challenge, showPreview, mode }, tests, files = {}, path = '') => ({ - content: files[path] && files[path].contents || '', - file: files[path], + state => state.challengesApp.key, + ({ challenge, showPreview, mode }, tests, files = {}, key = '') => ({ + content: files[key] && files[key].contents || '', + file: files[key], showPreview, mode, tests }) ); -const bindableActions = { updateFile }; +const bindableActions = { executeChallenge, updateFile }; export class Challenge extends PureComponent { static displayName = 'Challenge'; @@ -32,7 +32,9 @@ export class Challenge extends PureComponent { static propTypes = { showPreview: PropTypes.bool, content: PropTypes.string, - mode: PropTypes.string + mode: PropTypes.string, + updateFile: PropTypes.func, + executeChallenge: PropTypes.func }; renderPreview(showPreview) { @@ -54,7 +56,8 @@ export class Challenge extends PureComponent { updateFile, file, mode, - showPreview + showPreview, + executeChallenge } = this.props; return (