From a4fd935b8bd55f653ac260684139e4f8ea069aca Mon Sep 17 00:00:00 2001 From: Valeriy Date: Mon, 26 Nov 2018 02:17:38 +0300 Subject: [PATCH] feat: execute js challenges saga --- client/src/client/workers/test-evaluator.js | 35 ++++++++ .../Challenges/rechallenge/transformers.js | 44 ++++++---- .../redux/execute-challenge-epic.js | 16 ++++ .../redux/execute-challenge-saga.js | 81 +++++++++++++++++++ .../src/templates/Challenges/redux/index.js | 6 +- .../src/templates/Challenges/utils/build.js | 31 +++++++ .../Challenges/utils/worker-executor.js | 4 + client/webpack-frame-runner.js | 3 +- 8 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 client/src/client/workers/test-evaluator.js create mode 100644 client/src/templates/Challenges/redux/execute-challenge-saga.js diff --git a/client/src/client/workers/test-evaluator.js b/client/src/client/workers/test-evaluator.js new file mode 100644 index 00000000000..1791c254e76 --- /dev/null +++ b/client/src/client/workers/test-evaluator.js @@ -0,0 +1,35 @@ +/* global chai, importScripts */ +importScripts('https://cdnjs.cloudflare.com/ajax/libs/chai/4.2.0/chai.min.js'); + +const oldLog = self.console.log.bind(self.console); +self.console.log = function proxyConsole(...args) { + self.__logs = [...self.__logs, ...args]; + return oldLog(...args); +}; + +onmessage = async e => { + self.__logs = []; + const { script: __test, code } = e.data; + /* eslint-disable no-unused-vars */ + const assert = chai.assert; + // Fake Deep Equal dependency + const DeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b); + /* eslint-enable no-unused-vars */ + try { + // eslint-disable-next-line no-eval + const testResult = eval(__test); + if (typeof testResult === 'function') { + await testResult(() => code); + } + self.postMessage({ pass: true, logs: self.__logs.map(String) }); + } catch (err) { + self.postMessage({ + err: { + message: err.message, + stack: err.stack, + isAssertionError: err instanceof chai.AssertionError + }, + logs: self.__logs.map(String) + }); + } +}; diff --git a/client/src/templates/Challenges/rechallenge/transformers.js b/client/src/templates/Challenges/rechallenge/transformers.js index c49ea885f62..69e0d7ad6ad 100644 --- a/client/src/templates/Challenges/rechallenge/transformers.js +++ b/client/src/templates/Challenges/rechallenge/transformers.js @@ -21,24 +21,31 @@ import WorkerExecutor from '../utils/worker-executor'; const protectTimeout = 100; Babel.registerPlugin('loopProtection', protect(protectTimeout)); -const babelOptions = { +const babelOptionsJSX = { plugins: ['loopProtection'], presets: [presetEnv, presetReact] }; -const babelTransformCode = code => Babel.transform(code, babelOptions).code; + +const babelOptionsJS = { + presets: [presetEnv] +}; + +const babelTransformCode = options => code => + Babel.transform(code, options).code; // const sourceReg = // /()([\s\S]*?)(?=)/g; const NBSPReg = new RegExp(String.fromCharCode(160), 'g'); -const isJS = matchesProperty('ext', 'js'); +const testJS = matchesProperty('ext', 'js'); +const testJSX = matchesProperty('ext', 'jsx'); const testHTML = matchesProperty('ext', 'html'); -const testHTMLJS = overSome(isJS, testHTML); -export const testJS$JSX = overSome(isJS, matchesProperty('ext', 'jsx')); +const testHTML$JS$JSX = overSome(testHTML, testJS, testJSX); +export const testJS$JSX = overSome(testJS, testJSX); export const replaceNBSP = cond([ [ - testHTMLJS, + testHTML$JS$JSX, partial(vinyl.transformContents, contents => contents.replace(NBSPReg, ' ') ) @@ -63,11 +70,20 @@ function tryTransform(wrap = identity) { export const babelTransformer = cond([ [ - testJS$JSX, + testJS, flow( partial( vinyl.transformHeadTailAndContents, - tryTransform(babelTransformCode) + tryTransform(babelTransformCode(babelOptionsJS)) + ) + ) + ], + [ + testJSX, + flow( + partial( + vinyl.transformHeadTailAndContents, + tryTransform(babelTransformCode(babelOptionsJSX)) ), partial(vinyl.setExt, 'js') ) @@ -82,12 +98,12 @@ const htmlSassTransformCode = file => { div.innerHTML = file.contents; const styleTags = div.querySelectorAll('style[type="text/sass"]'); if (styleTags.length > 0) { - return Promise.all([].map.call(styleTags, async style => { - style.type = 'text/css'; - style.innerHTML = await sassWorker.execute(style.innerHTML, 2000); - })).then(() => ( - vinyl.transformContents(() => div.innerHTML, file) - )); + return Promise.all( + [].map.call(styleTags, async style => { + style.type = 'text/css'; + style.innerHTML = await sassWorker.execute(style.innerHTML, 2000); + }) + ).then(() => vinyl.transformContents(() => div.innerHTML, file)); } return vinyl.transformContents(() => div.innerHTML, file); }; diff --git a/client/src/templates/Challenges/redux/execute-challenge-epic.js b/client/src/templates/Challenges/redux/execute-challenge-epic.js index 52b6dfc8d56..57f657da18b 100644 --- a/client/src/templates/Challenges/redux/execute-challenge-epic.js +++ b/client/src/templates/Challenges/redux/execute-challenge-epic.js @@ -44,6 +44,13 @@ const executeDebounceTimeout = 750; function updateMainEpic(action$, state$, { document }) { return action$.pipe( ofType(types.updateFile, types.challengeMounted), + filter(() => { + const { challengeType } = challengeMetaSelector(state$.value); + return ( + challengeType !== challengeTypes.js && + challengeType !== challengeTypes.bonfire + ); + }), debounceTime(executeDebounceTimeout), switchMap(() => { const frameMain = createMainFramer(document, state$); @@ -77,6 +84,8 @@ function executeChallengeEpic(action$, state$, { document }) { consoleProxy ); const challengeResults = frameReady.pipe( + // Delay for jQuery ready code, in jQuery challenges + delay(250), pluck('checkChallengePayload'), map(checkChallengePayload => ({ checkChallengePayload, @@ -103,6 +112,13 @@ function executeChallengeEpic(action$, state$, { document }) { ); const buildAndFrameChallenge = action$.pipe( ofType(types.executeChallenge), + filter(() => { + const { challengeType } = challengeMetaSelector(state$.value); + return ( + challengeType !== challengeTypes.js && + challengeType !== challengeTypes.bonfire + ); + }), debounceTime(executeDebounceTimeout), filter(() => isJSEnabledSelector(state$.value)), switchMap(() => { diff --git a/client/src/templates/Challenges/redux/execute-challenge-saga.js b/client/src/templates/Challenges/redux/execute-challenge-saga.js new file mode 100644 index 00000000000..d7fa56c36be --- /dev/null +++ b/client/src/templates/Challenges/redux/execute-challenge-saga.js @@ -0,0 +1,81 @@ +import { takeEvery, put, select, call } from 'redux-saga/effects'; + +import { + challengeMetaSelector, + challengeTestsSelector, + initConsole, + updateConsole, + initLogs, + updateLogs, + logsToConsole, + checkChallenge, + updateTests, + challengeFilesSelector +} from './'; + +import { buildJSFromFiles } from '../utils/build'; + +import { challengeTypes } from '../../../../utils/challengeTypes'; + +import WorkerExecutor from '../utils/worker-executor'; + +const testWorker = new WorkerExecutor('test-evaluator'); +const testTimeout = 5000; + +function* ExecuteJSChallengeSaga() { + const { challengeType } = yield select(challengeMetaSelector); + const { js, bonfire } = challengeTypes; + if (challengeType !== js && challengeType !== bonfire) { + return; + } + yield put(initLogs()); + yield put(initConsole('// running tests')); + try { + const files = yield select(challengeFilesSelector); + const { code, solution } = yield call(buildJSFromFiles, files); + const tests = yield select(challengeTestsSelector); + const testResults = []; + for (const { text, testString } of tests) { + const newTest = { text, testString }; + const { pass, err, logs } = yield call( + testWorker.execute, + { script: solution + '\n' + testString, code }, + testTimeout + ); + if (pass) { + newTest.pass = true; + } else { + const { message, stack, isAssertionError } = err; + newTest.err = message + '\n' + stack; + newTest.stack = stack; + newTest.message = text.replace(/(.*?)<\/code>/g, '$1'); + yield put(updateConsole(newTest.message)); + if (!isAssertionError) { + console.warn(message); + } + } + testResults.push(newTest); + for (const log of logs) { + yield put(updateLogs(log)); + } + // kill worker for independent tests + yield call(testWorker.killWorker); + } + yield put(updateTests(testResults)); + yield put(updateConsole('// tests completed')); + yield put(logsToConsole('// console output')); + yield put(checkChallenge()); + } catch (e) { + if (e === 'timeout') { + yield put(updateConsole('Test timed out')); + } else { + yield put(updateConsole(e)); + } + } finally { + yield call(testWorker.killWorker); + } +} + +export function createExecuteChallengeSaga(types) { + return [takeEvery(types.executeChallenge, ExecuteJSChallengeSaga)]; +} diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index cc00d5c53f1..4dd3f5eecea 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -14,6 +14,7 @@ import codeStorageEpic from './code-storage-epic'; import currentChallengeEpic from './current-challenge-epic'; import { createIdToNameMapSaga } from './id-to-name-map-saga'; +import { createExecuteChallengeSaga } from './execute-challenge-saga'; export const ns = 'challenge'; export const backendNS = 'backendChallenge'; @@ -88,7 +89,10 @@ export const epics = [ currentChallengeEpic ]; -export const sagas = [...createIdToNameMapSaga(types)]; +export const sagas = [ + ...createIdToNameMapSaga(types), + ...createExecuteChallengeSaga(types) +]; export const createFiles = createAction(types.createFiles, challengeFiles => Object.keys(challengeFiles) diff --git a/client/src/templates/Challenges/utils/build.js b/client/src/templates/Challenges/utils/build.js index f3540dd9fa0..5aca859fb34 100644 --- a/client/src/templates/Challenges/utils/build.js +++ b/client/src/templates/Challenges/utils/build.js @@ -83,6 +83,37 @@ export function buildFromFiles(state) { return concatHtml(finalRequires, template, finalFiles); } +export function buildJSFromFiles(files) { + const pipeLine = flow( + applyFunctions(throwers), + applyFunctions(transformers) + ); + const finalFiles = Object.keys(files) + .map(key => files[key]) + .map(pipeLine); + const sourceMap = Promise.all(finalFiles).then(files => + files.reduce((sources, file) => { + sources[file.name] = file.source || file.contents; + return sources; + }, {}) + ); + const body = Promise.all(finalFiles).then(files => + files + .reduce( + (body, file) => [ + ...body, + file.head + '\n' + file.contents + '\n' + file.tail + ], + [] + ) + .join('/n') + ); + return Promise.all([body, sourceMap]).then(([body, sources]) => ({ + solution: body, + code: sources && 'index' in sources ? sources['index'] : '' + })); +} + export function buildBackendChallenge(state) { const { solution: { value: url } diff --git a/client/src/templates/Challenges/utils/worker-executor.js b/client/src/templates/Challenges/utils/worker-executor.js index dcf119d5e23..838beda2d49 100644 --- a/client/src/templates/Challenges/utils/worker-executor.js +++ b/client/src/templates/Challenges/utils/worker-executor.js @@ -2,6 +2,10 @@ export default class WorkerExecutor { constructor(workerName) { this.workerName = workerName; this.worker = null; + + this.execute = this.execute.bind(this); + this.killWorker = this.killWorker.bind(this); + this.getWorker = this.getWorker.bind(this); } getWorker() { diff --git a/client/webpack-frame-runner.js b/client/webpack-frame-runner.js index 11bcb4ffc8f..16e5dc5b144 100644 --- a/client/webpack-frame-runner.js +++ b/client/webpack-frame-runner.js @@ -6,7 +6,8 @@ module.exports = (env = {}) => { mode: __DEV__ ? 'development' : 'production', entry: { 'frame-runner': './src/client/frame-runner.js', - 'sass-compile': './src/client/workers/sass-compile.js' + 'sass-compile': './src/client/workers/sass-compile.js', + 'test-evaluator': './src/client/workers/test-evaluator.js' }, devtool: __DEV__ ? 'inline-source-map' : 'source-map', output: {