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

@@ -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>(.*)<\/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 }
}));
}