From f1d936198e03f1cade29cb8138f074a8e0696065 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 26 Jan 2017 21:07:22 -0800 Subject: [PATCH] feat(challenges): add backend challenge infrastructure (#11058) * Feat: Initial backend view * Feat: Refactor frame runner * Feat: backend challenge submit runs tests * Feat: Backend challenge request * Feat: Whitelist hyperdev in csp * Fix: Use app tests instead of challenge tests * Feat: Allow hyperdev subdomains * Fix(csp): allow hypderdev.space subdomains * feat(challenge): submit backend * feat: Add timeout to test runner (5 sec) * chore(seed): Add more to test backend * fix(csp): s/hyperdev/gomix/g * fix(app): fix code mirror skeleton filepath * fix(app): remove Gitter saga import * fix(app): codemirrorskeleton does not need it's own folder fix(app): cmk needs to work with Null types * fix: No longer restart the browser when challenges change * fix(app): Update jquery for challenges * fix(seed): Remove to promise jquery call * fix(lint): Undo merge error undefined is no allowed * fix(app): linting errors due to bad merge * fix(seed): Remove old seed file --- .eslintrc | 2 +- client/frame-runner.js | 39 +++- client/sagas/build-challenge-epic.js | 58 ++++++ client/sagas/execute-challenge-saga.js | 181 ------------------ client/sagas/{frame-saga.js => frame-epic.js} | 74 +++---- client/sagas/index.js | 38 ++-- client/utils/build.js | 173 +++++++++++++++++ .../{skeleton => }/CodeMirrorSkeleton.jsx | 2 +- .../components/{classic => }/Output.jsx | 21 +- .../app/routes/challenges/components/Show.jsx | 4 +- .../challenges/components/Solution-Input.jsx | 30 +++ .../components/{classic => }/Test-Suite.jsx | 15 +- .../components/backend/Back-End.jsx | 173 +++++++++++++++++ .../challenges/components/classic/Editor.jsx | 2 +- .../components/classic/Side-Panel.jsx | 24 ++- .../challenges/components/project/Forms.jsx | 22 +-- .../challenges/redux/completion-saga.js | 58 ++++-- common/app/routes/challenges/redux/reducer.js | 8 +- .../app/routes/challenges/redux/selectors.js | 48 ++--- common/app/routes/challenges/utils.js | 49 ++++- common/app/utils/form.js | 20 ++ gulpfile.js | 6 - .../api-and-microservice-projects.json | 25 ++- server/boot/challenge.js | 55 ++++++ server/middlewares/csp.js | 6 + 25 files changed, 772 insertions(+), 361 deletions(-) create mode 100644 client/sagas/build-challenge-epic.js delete mode 100644 client/sagas/execute-challenge-saga.js rename client/sagas/{frame-saga.js => frame-epic.js} (57%) create mode 100644 client/utils/build.js rename common/app/routes/challenges/components/{skeleton => }/CodeMirrorSkeleton.jsx (96%) rename common/app/routes/challenges/components/{classic => }/Output.jsx (54%) create mode 100644 common/app/routes/challenges/components/Solution-Input.jsx rename common/app/routes/challenges/components/{classic => }/Test-Suite.jsx (82%) create mode 100644 common/app/routes/challenges/components/backend/Back-End.jsx diff --git a/.eslintrc b/.eslintrc index 5e669cf274..b6eccc73a4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -146,7 +146,7 @@ "no-unused-expressions": 2, "no-unused-vars": 2, "no-use-before-define": 0, - "no-void": 2, + "no-void": 0, "no-warning-comments": [ 2, { "terms": [ "fixme" ], "location": "start" } ], "no-with": 2, "one-var": 0, diff --git a/client/frame-runner.js b/client/frame-runner.js index a9efb73f87..f00150bddc 100644 --- a/client/frame-runner.js +++ b/client/frame-runner.js @@ -1,11 +1,14 @@ document.addEventListener('DOMContentLoaded', function() { + var testTimeout = 5000; var common = parent.__common; var frameId = window.__frameId; - var frameReady = common[frameId + 'Ready$'] || { onNext() {} }; + var frameReady = common[frameId + 'Ready'] || { onNext() {} }; var Rx = document.Rx; var helpers = Rx.helpers; var chai = parent.chai; var source = document.__source; + var __getUserInput = document.__getUserInput || (x => x); + var checkChallengePayload = document.__checkChallengePayload; document.__getJsOutput = function getJsOutput() { if (window.__err || !common.shouldRun()) { @@ -23,13 +26,23 @@ document.addEventListener('DOMContentLoaded', function() { return output; }; - document.__runTests$ = function runTests$(tests = []) { + document.__runTests = function runTests(tests = []) { /* eslint-disable no-unused-vars */ const editor = { getValue() { return source; } }; const code = source; /* eslint-enable no-unused-vars */ if (window.__err) { - return Rx.Observable.throw(window.__err); + return Rx.Observable.from(tests) + .map(test => { + return { + ...test, + err: window.__err.message + '\n' + window.__err.stack, + message: window.__err.message, + stack: window.__err.stack + }; + }) + .toArray() + .do(() => { window.__err = null; }); } // Iterate through the test one at a time @@ -40,7 +53,8 @@ document.addEventListener('DOMContentLoaded', function() { /* eslint-disable no-unused-vars */ .flatMap(({ text, testString }) => { const assert = chai.assert; - /* eslint-enable no-unused-vars */ + const getUserInput = __getUserInput; + /* eslint-enable no-unused-vars */ const newTest = { text, testString }; let test; let __result; @@ -57,18 +71,18 @@ document.addEventListener('DOMContentLoaded', function() { // the function could expect a callback // or it could return a promise/observable // or it could still be sync - if (test.length === 0) { + if (test.length === 1) { // a function with length 0 means it expects 0 args // We call it and store the result // This result may be a promise or an observable or undefined - __result = test(); + __result = test(getUserInput); } else { // if function takes arguments // we expect it to be of the form // function(cb) { /* ... */ } // and callback has the following signature // function(err) { /* ... */ } - __result = Rx.Observable.fromNodeCallback(test)(); + __result = Rx.Observable.fromNodeCallback(test)(getUserInput); } if (helpers.isPromise(__result)) { @@ -86,6 +100,7 @@ document.addEventListener('DOMContentLoaded', function() { __result = Rx.Observable.throw(e); } return __result + .timeout(testTimeout) .map(() => { // we don't need the result of a promise/observable/cb here // all data asserts should happen further up the chain @@ -96,7 +111,15 @@ document.addEventListener('DOMContentLoaded', function() { .catch(err => { // we catch the error here to prevent the error from bubbling up // and collapsing the pipe + let message = (err.message || ''); + const assertIndex = message.indexOf(': expected'); + if (assertIndex !== -1) { + message = message.slice(0, assertIndex); + } + message = message.replace(/(.*)<\/code>/, '$1'); newTest.err = err.message + '\n' + err.stack; + newTest.stack = err.stack; + newTest.message = message; // RxJS catch expects an observable as a return return Rx.Observable.of(newTest); }); @@ -106,5 +129,5 @@ document.addEventListener('DOMContentLoaded', function() { }; // notify that the window methods are ready to run - frameReady.onNext(null); + frameReady.onNext({ checkChallengePayload }); }); diff --git a/client/sagas/build-challenge-epic.js b/client/sagas/build-challenge-epic.js new file mode 100644 index 0000000000..29d404ccdf --- /dev/null +++ b/client/sagas/build-challenge-epic.js @@ -0,0 +1,58 @@ +import { Scheduler, Observable } from 'rx'; + +import { + buildClassic, + buildBackendChallenge +} from '../utils/build.js'; +import { ofType } from '../../common/utils/get-actions-of-type.js'; +import { + challengeSelector +} from '../../common/app/routes/challenges/redux/selectors'; +import types from '../../common/app/routes/challenges/redux/types'; +import { createErrorObservable } from '../../common/app/redux/actions'; +import { + frameMain, + frameTests, + initOutput, + saveCode +} from '../../common/app/routes/challenges/redux/actions'; + +export default function buildChallengeEpic(actions, getState) { + return actions + ::ofType(types.executeChallenge, types.updateMain) + // if isCodeLocked do not run challenges + .filter(() => !getState().challengesApp.isCodeLocked) + .debounce(750) + .flatMapLatest(({ type }) => { + const shouldProxyConsole = type === types.updateMain; + const state = getState(); + const { files } = state.challengesApp; + const { + challenge: { + required = [], + type: challengeType + } + } = challengeSelector(state); + if (challengeType === 'backend') { + return buildBackendChallenge(state) + .map(frameTests) + .startWith(initOutput('// running test')); + } + return buildClassic(files, required, shouldProxyConsole) + .flatMap(payload => { + const actions = [ + frameMain(payload) + ]; + if (type === types.executeChallenge) { + actions.push(saveCode(), frameTests(payload)); + } + return Observable.from(actions, null, null, Scheduler.default); + }) + .startWith(( + type === types.executeChallenge ? + initOutput('// running test') : + null + )) + .catch(createErrorObservable); + }); +} diff --git a/client/sagas/execute-challenge-saga.js b/client/sagas/execute-challenge-saga.js deleted file mode 100644 index 3c28814faf..0000000000 --- a/client/sagas/execute-challenge-saga.js +++ /dev/null @@ -1,181 +0,0 @@ -import { Scheduler, Observable } from 'rx'; - -import { - challengeSelector -} from '../../common/app/routes/challenges/redux/selectors'; -import { ajax$ } from '../../common/utils/ajax-stream'; -import throwers from '../rechallenge/throwers'; -import transformers from '../rechallenge/transformers'; -import types from '../../common/app/routes/challenges/redux/types'; -import { createErrorObservable } from '../../common/app/redux/actions'; -import { - frameMain, - frameTests, - initOutput, - saveCode -} 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 globalRequires = [{ - link: 'https://cdnjs.cloudflare.com/' + - 'ajax/libs/normalize/4.2.0/normalize.min.css' -}, { - src: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/2.2.4/jquery.js' -}]; - -const scriptCache = new Map(); -const linkCache = new Map(); - -function cacheScript({ src } = {}, crossDomain = true) { - if (!src) { - throw new Error('No source provided for script'); - } - if (scriptCache.has(src)) { - return scriptCache.get(src); - } - const script$ = ajax$({ url: src, crossDomain }) - .doOnNext(res => { - if (res.status !== 200) { - throw new Error('Request errror: ' + res.status); - } - }) - .map(({ response }) => response) - .map(script => ``) - .shareReplay(); - - scriptCache.set(src, script$); - return script$; -} - -function cacheLink({ link } = {}, crossDomain = true) { - if (!link) { - return Observable.throw(new Error('No source provided for link')); - } - if (linkCache.has(link)) { - return linkCache.get(link); - } - const link$ = ajax$({ url: link, crossDomain }) - .doOnNext(res => { - if (res.status !== 200) { - throw new Error('Request errror: ' + res.status); - } - }) - .map(({ response }) => response) - .map(script => ``) - .catch(() => Observable.just('')) - .shareReplay(); - - linkCache.set(link, link$); - return link$; -} - - -const htmlCatch = '\n'; -const jsCatch = '\n;/*fcc*/\n'; - -export default function executeChallengeSaga(action$, getState) { - const frameRunner$ = cacheScript( - { src: '/js/frame-runner.js' }, - false - ); - return action$ - .filter(({ type }) => ( - type === types.executeChallenge || - type === types.updateMain - )) - // if isCodeLockedTrue do not run challenges - .filter(() => !getState().challengesApp.isCodeLocked) - .debounce(750) - .flatMapLatest(({ type }) => { - const state = getState(); - const { files } = state.challengesApp; - const { challenge: { required = [] } } = challengeSelector(state); - const finalRequires = [...globalRequires, ...required ]; - return createFileStream(files) - ::throwers() - ::transformers() - // createbuild - .flatMap(file$ => file$.reduce((build, file) => { - let finalFile; - const finalContents = [ - file.head, - file.contents, - file.tail - ]; - 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(source => { - const head$ = Observable.from(finalRequires) - .flatMap(required => { - if (required.src) { - return cacheScript(required, required.crossDomain); - } - // css files with `url(...` may not work in style tags - // so we put them in raw links - if (required.link && required.raw) { - return Observable.just( - `` - ); - } - if (required.link) { - return cacheLink(required, required.crossDomain); - } - return Observable.just(''); - }) - .reduce((head, required) => head + required, '') - .map(head => `${head}`); - - return Observable.combineLatest(head$, frameRunner$) - .map(([ head, frameRunner ]) => { - const body = ` - - - ${source} - - `; - return { - build: head + body + frameRunner, - source, - head - }; - }); - }) - .flatMap(payload => { - const actions = [ - frameMain(payload) - ]; - if (type === types.executeChallenge) { - actions.push(saveCode(), frameTests(payload)); - } - return Observable.from(actions, null, null, Scheduler.default); - }) - .startWith(( - type === types.executeChallenge ? - initOutput('// running test') : - null - )) - .catch(createErrorObservable); - }); -} diff --git a/client/sagas/frame-saga.js b/client/sagas/frame-epic.js similarity index 57% rename from client/sagas/frame-saga.js rename to client/sagas/frame-epic.js index 771908973e..31a3e6075a 100644 --- a/client/sagas/frame-saga.js +++ b/client/sagas/frame-epic.js @@ -10,13 +10,20 @@ import { updateTests } from '../../common/app/routes/challenges/redux/actions'; -// we use three different frames to make them all essentially pure functions +// we use two different frames to make them all essentially pure functions +// main iframe is responsible rendering the preview and is where we proxy the +// console.log const mainId = 'fcc-main-frame'; +// the test frame is responsible for running the assert tests const testId = 'fcc-test-frame'; const createHeader = (id = mainId) => ` `; @@ -46,78 +53,79 @@ function getFrameDocument(document, id = mainId) { }; } -const consoleReg = /(?:\b)console(\.log\S+)/g; -const sourceReg = - /()([\s\S]*?)(?=)/g; -function proxyConsole(build, source) { - const newSource = source.replace(consoleReg, (match, methodCall) => { - return 'window.__console' + methodCall; - }); - return build.replace(sourceReg, '\$1' + newSource); -} - -function buildProxyConsole(window, proxyLogger$) { +function buildProxyConsole(window, proxyLogger) { const oldLog = window.console.log.bind(console); window.__console = {}; window.__console.log = function proxyConsole(...args) { - proxyLogger$.onNext(args); + proxyLogger.onNext(args); return oldLog(...args); }; } -function frameMain({ build, source } = {}, document, proxyLogger$) { +function frameMain({ build } = {}, document, proxyLogger) { const { frame: main, frameWindow } = getFrameDocument(document); refreshFrame(main); - buildProxyConsole(frameWindow, proxyLogger$); + buildProxyConsole(frameWindow, proxyLogger); main.Rx = Rx; main.open(); - main.write(createHeader() + proxyConsole(build, source)); + main.write(createHeader() + build); main.close(); } -function frameTests({ build, source } = {}, document) { +function frameTests({ build, source, checkChallengePayload } = {}, document) { const { frame: tests } = getFrameDocument(document, testId); refreshFrame(tests); tests.Rx = Rx; tests.__source = source; + tests.__getUserInput = key => source[key]; + tests.__checkChallengePayload = checkChallengePayload; tests.open(); tests.write(createHeader(testId) + build); tests.close(); } -export default function frameSaga(actions$, getState, { window, document }) { +export default function frameEpic(actions, getState, { window, document }) { + // we attach a common place for the iframes to pull in functions from + // the main process window.__common = {}; window.__common.shouldRun = () => true; - const proxyLogger$ = new Subject(); - const runTests$ = window.__common[testId + 'Ready$'] = - new Subject(); - const result$ = actions$::ofType( - types.frameMain, - types.frameTests, - types.frameOutput - ) + // this will proxy console.log calls + const proxyLogger = new Subject(); + // frameReady will let us know when the test iframe is ready to run + const frameReady = window.__common[testId + 'Ready'] = new Subject(); + const result = actions + ::ofType(types.frameMain, types.frameTests) // if isCodeLocked is true do not frame user code .filter(() => !getState().challengesApp.isCodeLocked) .map(action => { if (action.type === types.frameMain) { - return frameMain(action.payload, document, proxyLogger$); + return frameMain(action.payload, document, proxyLogger); } return frameTests(action.payload, document); - }); + }) + .ignoreElements(); return Observable.merge( - proxyLogger$.map(updateOutput), - runTests$.flatMap(() => { + proxyLogger.map(updateOutput), + frameReady.flatMap(({ checkChallengePayload }) => { const { frame } = getFrameDocument(document, testId); const { tests } = getState().challengesApp; const postTests = Observable.of( updateOutput('// tests completed'), - checkChallenge() + checkChallenge(checkChallengePayload) ).delay(250); - return frame.__runTests$(tests) + // run the tests within the test iframe + return frame.__runTests(tests) + .do(tests => { + tests.forEach(test => { + if (typeof test.message === 'string') { + proxyLogger.onNext(test.message); + } + }); + }) .map(updateTests) .concat(postTests); }), - result$ + result ); } diff --git a/client/sagas/index.js b/client/sagas/index.js index b94b7cfb59..8fe5789425 100644 --- a/client/sagas/index.js +++ b/client/sagas/index.js @@ -1,23 +1,23 @@ -import errSaga from './err-saga'; -import titleSaga from './title-saga'; -import hardGoToSaga from './hard-go-to-saga'; -import windowSaga from './window-saga'; -import executeChallengeSaga from './execute-challenge-saga'; -import frameSaga from './frame-saga'; -import codeStorageSaga from './code-storage-saga'; -import mouseTrapSaga from './mouse-trap-saga'; -import analyticsSaga from './analytics-saga'; -import nightModeSaga from './night-mode-saga'; +import analyticsSaga from './analytics-saga.js'; +import codeStorageSaga from './code-storage-saga.js'; +import errSaga from './err-saga.js'; +import executeChallengeSaga from './build-challenge-epic.js'; +import frameEpic from './frame-epic.js'; +import hardGoToSaga from './hard-go-to-saga.js'; +import mouseTrapSaga from './mouse-trap-saga.js'; +import nightModeSaga from './night-mode-saga.js'; +import titleSaga from './title-saga.js'; +import windowSaga from './window-saga.js'; export default [ - errSaga, - titleSaga, - hardGoToSaga, - windowSaga, - executeChallengeSaga, - frameSaga, - codeStorageSaga, - mouseTrapSaga, analyticsSaga, - nightModeSaga + codeStorageSaga, + errSaga, + executeChallengeSaga, + frameEpic, + hardGoToSaga, + mouseTrapSaga, + nightModeSaga, + titleSaga, + windowSaga ]; diff --git a/client/utils/build.js b/client/utils/build.js new file mode 100644 index 0000000000..baeb33c31c --- /dev/null +++ b/client/utils/build.js @@ -0,0 +1,173 @@ +import { Observable } from 'rx'; +import { getValues } from 'redux-form'; + +import { ajax$ } from '../../common/utils/ajax-stream'; +import throwers from '../rechallenge/throwers'; +import transformers from '../rechallenge/transformers'; +import { setExt, updateContents } from '../../common/utils/polyvinyl'; + +const consoleReg = /(?:\b)console(\.log\S+)/g; +// const sourceReg = +// /()([\s\S]*?)(?=)/g; + +// useConsoleLogProxy(source: String) => String +export function useConsoleLogProxy(source) { + return source.replace(consoleReg, (match, methodCall) => { + return 'window.__console' + methodCall; + }); +} + +// createFileStream(files: Dictionary[Path, PolyVinyl]) => +// Observable[...Observable[...PolyVinyl]] +export function createFileStream(files = {}) { + return Observable.just( + Observable.from(Object.keys(files)).map(key => files[key]) + ); +} + +const jQuery = { + src: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js' +}; +const globalRequires = [ + { + link: 'https://cdnjs.cloudflare.com/' + + 'ajax/libs/normalize/4.2.0/normalize.min.css' + }, + jQuery +]; + +const scriptCache = new Map(); +export function cacheScript({ src } = {}, crossDomain = true) { + if (!src) { + throw new Error('No source provided for script'); + } + if (scriptCache.has(src)) { + return scriptCache.get(src); + } + const script$ = ajax$({ url: src, crossDomain }) + .doOnNext(res => { + if (res.status !== 200) { + throw new Error('Request errror: ' + res.status); + } + }) + .map(({ response }) => response) + .map(script => ``) + .shareReplay(); + + scriptCache.set(src, script$); + return script$; +} + +const linkCache = new Map(); +export function cacheLink({ link } = {}, crossDomain = true) { + if (!link) { + return Observable.throw(new Error('No source provided for link')); + } + if (linkCache.has(link)) { + return linkCache.get(link); + } + const link$ = ajax$({ url: link, crossDomain }) + .doOnNext(res => { + if (res.status !== 200) { + throw new Error('Request errror: ' + res.status); + } + }) + .map(({ response }) => response) + .map(script => ``) + .catch(() => Observable.just('')) + .shareReplay(); + + linkCache.set(link, link$); + return link$; +} + +const htmlCatch = '\n'; +const jsCatch = '\n;/*fcc*/\n'; +// we add a cache breaker to prevent browser from caching ajax request +const frameRunner = cacheScript({ + src: `/js/frame-runner.js?cacheBreaker=${Math.random()}` }, + false +); + +export function buildClassic(files, required, shouldProxyConsole) { + const finalRequires = [...globalRequires, ...required ]; + return createFileStream(files) + ::throwers() + ::transformers() + // createbuild + .flatMap(file$ => file$.reduce((build, file) => { + let finalFile; + const finalContents = [ + file.head, + file.contents, + file.tail + ].map( + // if shouldProxyConsole then we change instances of console log + // to `window.__console.log` + // this let's us tap into logging into the console. + // currently we only do this to the main window and not the test window + source => shouldProxyConsole ? useConsoleLogProxy(source) : source + ); + 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(source => { + const head$ = Observable.from(finalRequires) + .flatMap(required => { + if (required.src) { + return cacheScript(required, required.crossDomain); + } + // css files with `url(...` may not work in style tags + // so we put them in raw links + if (required.link && required.raw) { + return Observable.just( + `` + ); + } + if (required.link) { + return cacheLink(required, required.crossDomain); + } + return Observable.just(''); + }) + .reduce((head, required) => head + required, '') + .map(head => `${head}`); + + return Observable.combineLatest(head$, frameRunner) + .map(([ head, frameRunner ]) => { + const body = ` + + + ${source} + + `; + return { + build: head + body + frameRunner, + source, + head + }; + }); + }); +} + +export function buildBackendChallenge(state) { + const { solution: url } = getValues(state.form.BackEndChallenge); + return Observable.combineLatest(frameRunner, cacheScript(jQuery)) + .map(([ frameRunner, jQuery ]) => ({ + build: jQuery + frameRunner, + source: { url }, + checkChallengePayload: { solution: url } + })); +} diff --git a/common/app/routes/challenges/components/skeleton/CodeMirrorSkeleton.jsx b/common/app/routes/challenges/components/CodeMirrorSkeleton.jsx similarity index 96% rename from common/app/routes/challenges/components/skeleton/CodeMirrorSkeleton.jsx rename to common/app/routes/challenges/components/CodeMirrorSkeleton.jsx index 160087b442..a79d220b36 100644 --- a/common/app/routes/challenges/components/skeleton/CodeMirrorSkeleton.jsx +++ b/common/app/routes/challenges/components/CodeMirrorSkeleton.jsx @@ -25,7 +25,7 @@ export default class CodeMirrorSkeleton extends PureComponent { const { content } = this.props; - const editorLines = content.split('\n'); + const editorLines = (content || '').split('\n'); return (
diff --git a/common/app/routes/challenges/components/classic/Output.jsx b/common/app/routes/challenges/components/Output.jsx similarity index 54% rename from common/app/routes/challenges/components/classic/Output.jsx rename to common/app/routes/challenges/components/Output.jsx index f5846811a3..1969f6d3ee 100644 --- a/common/app/routes/challenges/components/classic/Output.jsx +++ b/common/app/routes/challenges/components/Output.jsx @@ -1,9 +1,8 @@ -import React, { PropTypes } from 'react'; -import PureComponent from 'react-pure-render/component'; +import React, { PureComponent, PropTypes } from 'react'; import NoSSR from 'react-no-ssr'; import Codemirror from 'react-codemirror'; -import CodeMirrorSkeleton from '../skeleton/CodeMirrorSkeleton.jsx'; +import CodeMirrorSkeleton from './CodeMirrorSkeleton.jsx'; const defaultOptions = { lineNumbers: false, @@ -13,22 +12,24 @@ const defaultOptions = { lineWrapping: true }; -export default class extends PureComponent { - static displayName = 'Output'; - static propTypes = { - output: PropTypes.string - }; +export default class Output extends PureComponent { render() { - const { output } = this.props; + const { output, defaultOutput } = this.props; return (
}>
); } } + +Output.displayName = 'Output'; +Output.propTypes = { + output: PropTypes.string, + defaultOutput: PropTypes.string +}; diff --git a/common/app/routes/challenges/components/Show.jsx b/common/app/routes/challenges/components/Show.jsx index 796d85f301..66925b068a 100644 --- a/common/app/routes/challenges/components/Show.jsx +++ b/common/app/routes/challenges/components/Show.jsx @@ -9,6 +9,7 @@ import Classic from './classic/Classic.jsx'; import Step from './step/Step.jsx'; import Project from './project/Project.jsx'; import Video from './video/Video.jsx'; +import BackEnd from './backend/Back-End.jsx'; import { fetchChallenge, @@ -25,7 +26,8 @@ const views = { classic: Classic, project: Project, simple: Project, - video: Video + video: Video, + backend: BackEnd }; const bindableActions = { diff --git a/common/app/routes/challenges/components/Solution-Input.jsx b/common/app/routes/challenges/components/Solution-Input.jsx new file mode 100644 index 0000000000..2fb518e37b --- /dev/null +++ b/common/app/routes/challenges/components/Solution-Input.jsx @@ -0,0 +1,30 @@ +import React, { PropTypes } from 'react'; +import { HelpBlock, FormGroup, FormControl } from 'react-bootstrap'; +import { getValidationState, DOMOnlyProps } from '../../../utils/form'; + +export default function SolutionInput({ solution, placeholder }) { + const validationState = getValidationState(solution); + return ( + + + { + validationState === 'error' ? + Make sure you provide a proper URL. : + null + } + + ); +} + +SolutionInput.propTypes = { + solution: PropTypes.object, + placeholder: PropTypes.string +}; diff --git a/common/app/routes/challenges/components/classic/Test-Suite.jsx b/common/app/routes/challenges/components/Test-Suite.jsx similarity index 82% rename from common/app/routes/challenges/components/classic/Test-Suite.jsx rename to common/app/routes/challenges/components/Test-Suite.jsx index 6a263d9755..4d8d7e3269 100644 --- a/common/app/routes/challenges/components/classic/Test-Suite.jsx +++ b/common/app/routes/challenges/components/Test-Suite.jsx @@ -1,14 +1,12 @@ -import React, { PropTypes } from 'react'; +import React, { PropTypes, PureComponent } from 'react'; import classnames from 'classnames'; -import PureComponent from 'react-pure-render/component'; import { Col, Row } from 'react-bootstrap'; -export default class extends PureComponent { - static displayName = 'TestSuite'; - static propTypes = { - tests: PropTypes.arrayOf(PropTypes.object) - }; +const propTypes = { + tests: PropTypes.arrayOf(PropTypes.object) +}; +export default class TestSuite extends PureComponent { renderTests(tests = []) { // err && pass > invalid state // err && !pass > failed tests @@ -52,3 +50,6 @@ export default class extends PureComponent { ); } } + +TestSuite.displayName = 'TestSuite'; +TestSuite.propTypes = propTypes; diff --git a/common/app/routes/challenges/components/backend/Back-End.jsx b/common/app/routes/challenges/components/backend/Back-End.jsx new file mode 100644 index 0000000000..327f022cf8 --- /dev/null +++ b/common/app/routes/challenges/components/backend/Back-End.jsx @@ -0,0 +1,173 @@ +import React, { PropTypes, PureComponent } from 'react'; +import { createSelector } from 'reselect'; +import { reduxForm } from 'redux-form'; +import { + Button, + Col, + Row +} from 'react-bootstrap'; + +import SolutionInput from '../Solution-Input.jsx'; +import TestSuite from '../Test-Suite.jsx'; +import Output from '../Output.jsx'; +import { submitChallenge, executeChallenge } from '../../redux/actions.js'; +import { challengeSelector } from '../../redux/selectors.js'; +import { descriptionRegex } from '../../utils.js'; +import { + isValidURL, + makeRequired, + createFormValidator +} from '../../../../utils/form.js'; + +const propTypes = { + id: PropTypes.string, + title: PropTypes.string, + description: PropTypes.arrayOf(PropTypes.string), + tests: PropTypes.array, + output: PropTypes.string, + executeChallenge: PropTypes.func.isRequired, + submitChallenge: PropTypes.func.isRequired, + // provided by redux form + submitting: PropTypes.bool, + fields: PropTypes.object, + resetForm: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired +}; + +const fields = [ 'solution' ]; + +const fieldValidators = { + solution: makeRequired(isValidURL) +}; + +const mapStateToProps = createSelector( + challengeSelector, + state => state.challengesApp.output, + state => state.challengesApp.tests, + ( + { + challenge: { + id, + title, + description + } = {} + }, + output, + tests + ) => ({ + id, + title, + tests, + description, + output + }) +); + +const mapDispatchToActions = { + executeChallenge, + submitChallenge +}; + +export class BackEnd extends PureComponent { + + renderDescription(description) { + if (!Array.isArray(description)) { + return null; + } + return description.map((line, index) => { + if (descriptionRegex.test(line)) { + return ( +
+ ); + } + return ( +

+ ); + }); + } + + render() { + const { + title, + description, + tests, + output, + // provided by redux-form + fields: { solution }, + submitting, + handleSubmit, + executeChallenge + } = this.props; + + const buttonCopy = submitting ? + 'Submit and go to my next challenge' : + "I've completed this challenge"; + return ( +

+ + +

{ title }

+ { this.renderDescription(description) } +
+ +
+ + + +
+ + + + + + + +
+ ); + } +} + +BackEnd.displayName = 'BackEnd'; +BackEnd.propTypes = propTypes; + +export default reduxForm( + { + form: 'BackEndChallenge', + fields, + validate: createFormValidator(fieldValidators) + }, + mapStateToProps, + mapDispatchToActions +)(BackEnd); diff --git a/common/app/routes/challenges/components/classic/Editor.jsx b/common/app/routes/challenges/components/classic/Editor.jsx index 12c034cd19..f2d67f2abe 100644 --- a/common/app/routes/challenges/components/classic/Editor.jsx +++ b/common/app/routes/challenges/components/classic/Editor.jsx @@ -7,7 +7,7 @@ import Codemirror from 'react-codemirror'; import NoSSR from 'react-no-ssr'; import PureComponent from 'react-pure-render/component'; -import CodeMirrorSkeleton from '../skeleton/CodeMirrorSkeleton.jsx'; +import CodeMirrorSkeleton from '../CodeMirrorSkeleton.jsx'; const mapStateToProps = createSelector( state => state.app.windowHeight, diff --git a/common/app/routes/challenges/components/classic/Side-Panel.jsx b/common/app/routes/challenges/components/classic/Side-Panel.jsx index 62eb4f9e41..2c027c4888 100644 --- a/common/app/routes/challenges/components/classic/Side-Panel.jsx +++ b/common/app/routes/challenges/components/classic/Side-Panel.jsx @@ -5,8 +5,8 @@ import { connect } from 'react-redux'; import PureComponent from 'react-pure-render/component'; import { Col, Row } from 'react-bootstrap'; -import TestSuite from './Test-Suite.jsx'; -import Output from './Output.jsx'; +import TestSuite from '../Test-Suite.jsx'; +import Output from '../Output.jsx'; import ToolPanel from './Tool-Panel.jsx'; import { challengeSelector } from '../../redux/selectors'; import { @@ -15,6 +15,7 @@ import { executeChallenge, unlockUntrustedCode } from '../../redux/actions'; +import { descriptionRegex } from '../../utils'; import { makeToast } from '../../../../toasts/redux/actions'; const mapDispatchToProps = { @@ -61,10 +62,6 @@ const mapStateToProps = createSelector( ); export class SidePanel extends PureComponent { - constructor(...args) { - super(...args); - this.descriptionRegex = /\ { if (descriptionRegex.test(line)) { return ( @@ -146,7 +143,7 @@ export class SidePanel extends PureComponent { className='challenge-instructions' xs={ 12 } > - { this.renderDescription(description, this.descriptionRegex) } + { this.renderDescription(description) }
@@ -160,7 +157,16 @@ export class SidePanel extends PureComponent { unlockUntrustedCode={ unlockUntrustedCode } updateHint={ updateHint } /> - +
diff --git a/common/app/routes/challenges/components/project/Forms.jsx b/common/app/routes/challenges/components/project/Forms.jsx index 84edbc6eea..bbaf3265ac 100644 --- a/common/app/routes/challenges/components/project/Forms.jsx +++ b/common/app/routes/challenges/components/project/Forms.jsx @@ -6,6 +6,7 @@ import { FormControl } from 'react-bootstrap'; +import SolutionInput from '../Solution-Input.jsx'; import { isValidURL, makeRequired, @@ -43,27 +44,6 @@ const backEndFieldValidators = { githubLink: makeRequired(isValidURL) }; -export function SolutionInput({ solution, placeholder }) { - return ( - - - - ); -} - -SolutionInput.propTypes = { - solution: PropTypes.object, - placeholder: PropTypes.string -}; - export function _FrontEndForm({ fields, handleSubmit, diff --git a/common/app/routes/challenges/redux/completion-saga.js b/common/app/routes/challenges/redux/completion-saga.js index 0a92703fec..9205cc1153 100644 --- a/common/app/routes/challenges/redux/completion-saga.js +++ b/common/app/routes/challenges/redux/completion-saga.js @@ -1,20 +1,21 @@ import { Observable } from 'rx'; -import types from './types'; +import types from './types.js'; import { moveToNextChallenge, clearSavedCode -} from './actions'; +} from './actions.js'; -import { challengeSelector } from './selectors'; +import { challengeSelector } from './selectors.js'; import { createErrorObservable, updateUserPoints, updateUserChallenge -} from '../../../redux/actions'; -import { backEndProject } from '../../../utils/challengeTypes'; -import { makeToast } from '../../../toasts/redux/actions'; -import { postJSON$ } from '../../../../utils/ajax-stream'; +} from '../../../redux/actions.js'; +import { backEndProject } from '../../../utils/challengeTypes.js'; +import { makeToast } from '../../../toasts/redux/actions.js'; +import { postJSON$ } from '../../../../utils/ajax-stream.js'; +import { ofType } from '../../../../utils/get-actions-of-type.js'; function postChallenge(url, username, _csrf, challengeInfo) { const body = { ...challengeInfo, _csrf }; @@ -97,8 +98,42 @@ function submitSimpleChallenge(type, state) { ); } -const submitTypes = { +function submitBackendChallenge(type, state, { solution }) { + const { tests } = state.challengesApp; + if ( + type === types.checkChallenge && + tests.length > 0 && + tests.every(test => test.pass && !test.err) + ) { + /* + return Observable.of( + makeToast({ + message: `${randomCompliment()} Go to your next challenge.`, + action: 'Submit', + actionCreator: 'submitChallenge', + timeout: 10000 + }) + ); + */ + + const { challenge: { id } } = challengeSelector(state); + const { app: { user, csrfToken } } = state; + const challengeInfo = { id, solution }; + return postChallenge( + '/backend-challenge-completed', + user, + csrfToken, + challengeInfo + ); + } + return Observable.just( + makeToast({ message: 'Keep trying.' }) + ); +} + +const submitters = { tests: submitModern, + backend: submitBackendChallenge, step: submitSimpleChallenge, video: submitSimpleChallenge, 'project.frontEnd': submitProject, @@ -108,14 +143,11 @@ const submitTypes = { export default function completionSaga(actions$, getState) { return actions$ - .filter(({ type }) => ( - type === types.checkChallenge || - type === types.submitChallenge - )) + ::ofType(types.checkChallenge, types.submitChallenge) .flatMap(({ type, payload }) => { const state = getState(); const { submitType } = challengeSelector(state); - const submitter = submitTypes[submitType] || + const submitter = submitters[submitType] || (() => Observable.just(null)); return submitter(type, state, payload); }); diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index ca08888c20..cda99f4fb2 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -24,13 +24,7 @@ const initialUiState = { isLightBoxOpen: false, // project is ready to submit isSubmitting: false, - output: `/** - * Your output will go here. - * Any console.log() - type - * statements will appear in - * your browser\'s DevTools - * JavaScript console as well. - */`, + output: null, // video // 1 indexed currentQuestion: 1, diff --git a/common/app/routes/challenges/redux/selectors.js b/common/app/routes/challenges/redux/selectors.js index a5f47cb2fe..7ab4063529 100644 --- a/common/app/routes/challenges/redux/selectors.js +++ b/common/app/routes/challenges/redux/selectors.js @@ -1,39 +1,8 @@ import { createSelector } from 'reselect'; -import * as challengeTypes from '../../../utils/challengeTypes'; -import { getNode } from '../utils'; +import { viewTypes, submitTypes, getNode } from '../utils'; import blockNameify from '../../../utils/blockNameify'; - -const viewTypes = { - [ challengeTypes.html]: 'classic', - [ challengeTypes.js ]: 'classic', - [ challengeTypes.bonfire ]: 'classic', - [ challengeTypes.frontEndProject]: 'project', - [ challengeTypes.backEndProject]: 'project', - // might not be used anymore - [ challengeTypes.simpleProject]: 'project', - // formally hikes - [ challengeTypes.video ]: 'video', - [ challengeTypes.step ]: 'step' -}; - -const submitTypes = { - [ challengeTypes.html ]: 'tests', - [ challengeTypes.js ]: 'tests', - [ challengeTypes.bonfire ]: 'tests', - // requires just a button press - [ challengeTypes.simpleProject ]: 'project.simple', - // requires just a single url - // like codepen.com/my-project - [ challengeTypes.frontEndProject ]: 'project.frontEnd', - // requires two urls - // a hosted URL where the app is running live - // project code url like GitHub - [ challengeTypes.backEndProject ]: 'project.backEnd', - // formally hikes - [ challengeTypes.video ]: 'video', - [ challengeTypes.step ]: 'step' -}; +import { html } from '../../../utils/challengeTypes'; export const challengeSelector = createSelector( state => state.challengesApp.challenge, @@ -44,6 +13,8 @@ export const challengeSelector = createSelector( } const challenge = challengeMap[challengeName]; const challengeType = challenge && challenge.challengeType; + const type = challenge && challenge.type; + const viewType = viewTypes[type] || viewTypes[challengeType] || 'classic'; const blockName = blockNameify(challenge.block); const title = blockName && challenge.title ? `${blockName}: ${challenge.title}` : @@ -51,10 +22,13 @@ export const challengeSelector = createSelector( return { challenge, title, - viewType: viewTypes[challengeType] || 'classic', - submitType: submitTypes[challengeType] || 'tests', - showPreview: challengeType === challengeTypes.html, - mode: challenge && challengeType === challengeTypes.html ? + viewType, + submitType: + submitTypes[challengeType] || + submitTypes[challenge && challenge.type] || + 'tests', + showPreview: challengeType === html, + mode: challenge && challengeType === html ? 'text/html' : 'javascript' }; diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js index 3d20cf9eda..ced3e074a3 100644 --- a/common/app/routes/challenges/utils.js +++ b/common/app/routes/challenges/utils.js @@ -1,7 +1,46 @@ import flow from 'lodash/flow'; -import { bonfire, html, js } from '../../utils/challengeTypes'; -import { decodeScriptTags } from '../../../utils/encode-decode'; +import * as challengeTypes from '../../utils/challengeTypes'; import protect from '../../utils/empty-protector'; +import { decodeScriptTags } from '../../../utils/encode-decode'; + +// determine the component to view for each challenge +export const viewTypes = { + [ challengeTypes.html ]: 'classic', + [ challengeTypes.js ]: 'classic', + [ challengeTypes.bonfire ]: 'classic', + [ challengeTypes.frontEndProject ]: 'project', + [ challengeTypes.backEndProject ]: 'project', + // might not be used anymore + [ challengeTypes.simpleProject ]: 'project', + // formally hikes + [ challengeTypes.video ]: 'video', + [ challengeTypes.step ]: 'step', + backend: 'backend' +}; + +// determine the type of submit function to use for the challenge on completion +export const submitTypes = { + [ challengeTypes.html ]: 'tests', + [ challengeTypes.js ]: 'tests', + [ challengeTypes.bonfire ]: 'tests', + // requires just a button press + [ challengeTypes.simpleProject ]: 'project.simple', + // requires just a single url + // like codepen.com/my-project + [ challengeTypes.frontEndProject ]: 'project.frontEnd', + // requires two urls + // a hosted URL where the app is running live + // project code url like GitHub + [ challengeTypes.backEndProject ]: 'project.backEnd', + // formally hikes + [ challengeTypes.video ]: 'video', + [ challengeTypes.step ]: 'step', + backend: 'backend' +}; + +// determines if a line in a challenge description +// has html that should be rendered +export const descriptionRegex = /\ $.ajax({ url: getUserInput('url'), method: 'HEAD' }).then(null, (err) => assert.fail(err));" + }, { + "text": "package.json should have a valid \"keywords\" key", + "testString": "getUserInput => ($.get(getUserInput('url') + '/_api/package.json').then(function(data){ var packJson = JSON.parse(data); assert(packJson.keywords); }, err => { throw new Error('Err: ' + err.statusText);}))" + }, { + "text": "\"keywords\" field should be an Array", + "testString": "getUserInput => ($.get(getUserInput('url') + '/_api/package.json').then(function(data){ var packJson = JSON.parse(data); assert.isArray(packJson.keywords); }, err => { throw new Error('Err: ' + err.statusText);}))" + }, { + "text": "\"keywords\" should include \"freecodecamp\"", + "testString": "getUserInput => ($.get(getUserInput('url') + '/_api/package.json').then(function(data){ var packJson = JSON.parse(data); assert.include(packJson.keywords, 'freecodecamp'); }, err => { throw new Error('Err: ' + err.statusText); }))" + }], + "type": "backend" + }, { "id": "bd7158d8c443edefaeb5bdef", "title": "Timestamp Microservice", @@ -261,4 +284,4 @@ } } ] -} \ No newline at end of file +} diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 820cac6e53..b24cb7b117 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -110,6 +110,12 @@ export default function(app) { projectCompleted ); + api.post( + '/backend-challenge-completed', + send200toNonUser, + backendChallengeCompleted + ); + router.get( '/challenges/current-challenge', redirectToCurrentChallenge @@ -287,6 +293,55 @@ export default function(app) { .subscribe(() => {}, next); } + function backendChallengeCompleted(req, res, next) { + const type = accepts(req).type('html', 'json', 'text'); + req.checkBody('id', 'id must be an ObjectId').isMongoId(); + req.checkBody('solution', 'solution must be a URL').isURL(); + + const errors = req.validationErrors(true); + + if (errors) { + if (type === 'json') { + return res.status(403).send({ errors }); + } + log('errors', errors); + return res.sendStatus(403); + } + + const { user, body = {} } = req; + + const completedChallenge = _.pick( + body, + [ 'id', 'solution' ] + ); + completedChallenge.completedDate = Date.now(); + + + return user.getChallengeMap$() + .flatMap(() => { + const { + alreadyCompleted, + updateData, + lastUpdated + } = buildUserUpdate(user, completedChallenge.id, completedChallenge); + + return user.update$(updateData) + .doOnNext(({ count }) => log('%s documents updated', count)) + .doOnNext(() => { + if (type === 'json') { + return res.send({ + alreadyCompleted, + points: alreadyCompleted ? user.points : user.points + 1, + completedDate: completedChallenge.completedDate, + lastUpdated + }); + } + return res.status(200).send(true); + }); + }) + .subscribe(() => {}, next); + } + function redirectToCurrentChallenge(req, res, next) { const { user } = req; return map$ diff --git a/server/middlewares/csp.js b/server/middlewares/csp.js index 27e997e07b..50548cd878 100644 --- a/server/middlewares/csp.js +++ b/server/middlewares/csp.js @@ -18,6 +18,12 @@ export default function csp() { '*.cloudflare.com', 'https://*.optimizely.com' ]), + connectSrc: trusted.concat([ + 'https://gomix.com', + 'https://*.gomix.com', + 'https://*.gomix.me', + 'https://*.cloudflare.com' + ]), scriptSrc: [ "'unsafe-eval'", "'unsafe-inline'",