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
This commit is contained in:
Berkeley Martinez
2017-01-26 21:07:22 -08:00
committed by Quincy Larson
parent 1b2b54e2b0
commit f1d936198e
25 changed files with 772 additions and 361 deletions

View File

@ -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,

View File

@ -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,6 +53,7 @@ document.addEventListener('DOMContentLoaded', function() {
/* eslint-disable no-unused-vars */
.flatMap(({ text, testString }) => {
const assert = chai.assert;
const getUserInput = __getUserInput;
/* eslint-enable no-unused-vars */
const newTest = { text, testString };
let test;
@ -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>(.*)<\/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 });
});

View File

@ -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);
});
}

View File

@ -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 => `<script>${script}</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 => `<style>${script}</style>`)
.catch(() => Observable.just(''))
.shareReplay();
linkCache.set(link, link$);
return link$;
}
const htmlCatch = '\n<!--fcc-->';
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(
`<script>${finalContents.join(jsCatch)}${jsCatch}</script>`,
file
));
} else if (file.ext === 'css') {
finalFile = setExt('html', updateContents(
`<style>${finalContents.join(htmlCatch)}</style>`,
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(
`<link href=${required.link} rel='stylesheet' />`
);
}
if (required.link) {
return cacheLink(required, required.crossDomain);
}
return Observable.just('');
})
.reduce((head, required) => head + required, '')
.map(head => `<head>${head}</head>`);
return Observable.combineLatest(head$, frameRunner$)
.map(([ head, frameRunner ]) => {
const body = `
<body style='margin:8px;'>
<!-- fcc-start-source -->
${source}
<!-- fcc-end-source -->
</body>`;
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);
});
}

View File

@ -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) => `
<script>
window.__frameId = '${id}';
window.onerror = function(msg, url, ln, col, err) {
window.__err = err;
return true;
};
</script>
`;
@ -46,78 +53,79 @@ function getFrameDocument(document, id = mainId) {
};
}
const consoleReg = /(?:\b)console(\.log\S+)/g;
const sourceReg =
/(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/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
);
}

View File

@ -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
];

173
client/utils/build.js Normal file
View File

@ -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 =
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/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 => `<script>${script}</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 => `<style>${script}</style>`)
.catch(() => Observable.just(''))
.shareReplay();
linkCache.set(link, link$);
return link$;
}
const htmlCatch = '\n<!--fcc-->';
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(
`<script>${finalContents.join(jsCatch)}${jsCatch}</script>`,
file
));
} else if (file.ext === 'css') {
finalFile = setExt('html', updateContents(
`<style>${finalContents.join(htmlCatch)}</style>`,
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(
`<link href=${required.link} rel='stylesheet' />`
);
}
if (required.link) {
return cacheLink(required, required.crossDomain);
}
return Observable.just('');
})
.reduce((head, required) => head + required, '')
.map(head => `<head>${head}</head>`);
return Observable.combineLatest(head$, frameRunner)
.map(([ head, frameRunner ]) => {
const body = `
<body style='margin:8px;'>
<!-- fcc-start-source -->
${source}
<!-- fcc-end-source -->
</body>`;
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 }
}));
}

View File

@ -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 (
<div className='ReactCodeMirror'>
<div className='CodeMirror cm-s-monokai CodeMirror-wrap'>

View File

@ -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 (
<div className='challenge-log'>
<NoSSR onSSR={ <CodeMirrorSkeleton content={ output } /> }>
<Codemirror
options={ defaultOptions }
value={ output }
value={ output || defaultOutput }
/>
</NoSSR>
</div>
);
}
}
Output.displayName = 'Output';
Output.propTypes = {
output: PropTypes.string,
defaultOutput: PropTypes.string
};

View File

@ -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 = {

View File

@ -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 (
<FormGroup
controlId='solution'
validationState={ validationState }
>
<FormControl
name='solution'
placeholder={ placeholder }
type='url'
{ ...DOMOnlyProps(solution) }
/>
{
validationState === 'error' ?
<HelpBlock>Make sure you provide a proper URL.</HelpBlock> :
null
}
</FormGroup>
);
}
SolutionInput.propTypes = {
solution: PropTypes.object,
placeholder: PropTypes.string
};

View File

@ -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 = {
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;

View File

@ -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 (
<div
dangerouslySetInnerHTML={{ __html: line }}
key={ line.slice(-6) + index }
/>
);
}
return (
<p
className='wrappable'
dangerouslySetInnerHTML= {{ __html: line }}
key={ line.slice(-6) + index }
/>
);
});
}
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 (
<div>
<Col
md={ 6 }
mdOffset={ 3 }
xs={ 12 }
>
<Row>
<h3>{ title }</h3>
{ this.renderDescription(description) }
</Row>
<Row>
<form
name='BackEndChallenge'
onSubmit={ handleSubmit(executeChallenge) }
>
<SolutionInput
placeholder='https://your-app.com'
solution={ solution }
/>
<Button
block={ true }
bsStyle='primary'
className='btn-big'
onClick={ submitting ? null : null }
type={ submitting ? null : 'submit' }
>
{ buttonCopy } (ctrl + enter)
</Button>
</form>
</Row>
<Row>
<Output
defaultOutput={
`/**
* Test output will go here
*/`
}
output={ output }
/>
</Row>
<Row>
<TestSuite tests={ tests } />
</Row>
</Col>
</div>
);
}
}
BackEnd.displayName = 'BackEnd';
BackEnd.propTypes = propTypes;
export default reduxForm(
{
form: 'BackEndChallenge',
fields,
validate: createFormValidator(fieldValidators)
},
mapStateToProps,
mapDispatchToActions
)(BackEnd);

View File

@ -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,

View File

@ -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 = /\<blockquote|\<ol|\<h4|\<table/;
}
static displayName = 'SidePanel';
static propTypes = {
@ -84,7 +81,7 @@ export class SidePanel extends PureComponent {
updateHint: PropTypes.func
};
renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) {
renderDescription(description = [ 'Happy Coding!' ]) {
return description.map((line, index) => {
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) }
</Col>
</Row>
</div>
@ -160,7 +157,16 @@ export class SidePanel extends PureComponent {
unlockUntrustedCode={ unlockUntrustedCode }
updateHint={ updateHint }
/>
<Output output={ output }/>
<Output
defaultOutput={
`/**
* Your output will go here.
* Any console.log() statements
* will appear in here as well.
*/`
}
output={ output }
/>
<br />
<TestSuite tests={ tests } />
</div>

View File

@ -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 (
<FormGroup
controlId='solution'
validationState={ getValidationState(solution) }
>
<FormControl
name='solution'
placeholder={ placeholder }
type='url'
{ ...solution}
/>
</FormGroup>
);
}
SolutionInput.propTypes = {
solution: PropTypes.object,
placeholder: PropTypes.string
};
export function _FrontEndForm({
fields,
handleSubmit,

View File

@ -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);
});

View File

@ -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,

View File

@ -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'
};

View File

@ -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 = /\<blockquote|\<ol|\<h4|\<table/;
export function arrayToString(seedData = ['']) {
seedData = Array.isArray(seedData) ? seedData : [seedData];
@ -16,9 +55,9 @@ export function buildSeed({ challengeSeed = [] } = {}) {
}
const pathsMap = {
[html]: 'html',
[js]: 'js',
[bonfire]: 'js'
[ challengeTypes.html ]: 'html',
[ challengeTypes.js ]: 'js',
[ challengeTypes.bonfire ]: 'js'
};
export function getPreFile({ challengeType }) {

View File

@ -1,3 +1,4 @@
import omit from 'lodash/omit';
import normalizeUrl from 'normalize-url';
import { isURL } from 'validator';
@ -68,3 +69,22 @@ export function getValidationState(field) {
'error' :
'success';
}
// this should filter out none-dom props to silence React warnings
export function DOMOnlyProps(field) {
return omit(field, [
'initialValue',
'autofill',
'autocompleted',
'onUpdate',
'valid',
'invalid',
'dirty',
'pristine',
'active',
'touched',
'visited',
'autofilled',
'error'
]);
}

View File

@ -422,15 +422,9 @@ var watchDependents = [
'dev-server'
];
gulp.task('reload', function() {
notify({ message: 'test changed' });
reload();
});
gulp.task('watch', watchDependents, function() {
gulp.watch(paths.lessFiles, ['less']);
gulp.watch(paths.js.concat(paths.vendorChallenges), ['js']);
gulp.watch(paths.challenges, ['test-challenges', 'reload']);
gulp.watch(paths.js, ['js']);
});

View File

@ -4,6 +4,29 @@
"time": "150 hours",
"helpRoom": "HelpBackend",
"challenges": [
{
"id": "57ed709d334ad35e8fe79acb",
"title": "New Backend Format",
"isBeta": "true",
"meta": "This is just a test",
"description": [
"This is just a test of the new backend challenge test framework"
],
"tests": [{
"text": "website should return 200",
"testString": "getUserInput => $.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",

View File

@ -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$

View File

@ -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'",