feat: execute js challenges saga
This commit is contained in:
35
client/src/client/workers/test-evaluator.js
Normal file
35
client/src/client/workers/test-evaluator.js
Normal file
@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@ -21,24 +21,31 @@ import WorkerExecutor from '../utils/worker-executor';
|
|||||||
const protectTimeout = 100;
|
const protectTimeout = 100;
|
||||||
Babel.registerPlugin('loopProtection', protect(protectTimeout));
|
Babel.registerPlugin('loopProtection', protect(protectTimeout));
|
||||||
|
|
||||||
const babelOptions = {
|
const babelOptionsJSX = {
|
||||||
plugins: ['loopProtection'],
|
plugins: ['loopProtection'],
|
||||||
presets: [presetEnv, presetReact]
|
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 =
|
// const sourceReg =
|
||||||
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
|
// /(<!-- fcc-start-source -->)([\s\S]*?)(?=<!-- fcc-end-source -->)/g;
|
||||||
const NBSPReg = new RegExp(String.fromCharCode(160), '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 testHTML = matchesProperty('ext', 'html');
|
||||||
const testHTMLJS = overSome(isJS, testHTML);
|
const testHTML$JS$JSX = overSome(testHTML, testJS, testJSX);
|
||||||
export const testJS$JSX = overSome(isJS, matchesProperty('ext', 'jsx'));
|
export const testJS$JSX = overSome(testJS, testJSX);
|
||||||
|
|
||||||
export const replaceNBSP = cond([
|
export const replaceNBSP = cond([
|
||||||
[
|
[
|
||||||
testHTMLJS,
|
testHTML$JS$JSX,
|
||||||
partial(vinyl.transformContents, contents =>
|
partial(vinyl.transformContents, contents =>
|
||||||
contents.replace(NBSPReg, ' ')
|
contents.replace(NBSPReg, ' ')
|
||||||
)
|
)
|
||||||
@ -63,11 +70,20 @@ function tryTransform(wrap = identity) {
|
|||||||
|
|
||||||
export const babelTransformer = cond([
|
export const babelTransformer = cond([
|
||||||
[
|
[
|
||||||
testJS$JSX,
|
testJS,
|
||||||
flow(
|
flow(
|
||||||
partial(
|
partial(
|
||||||
vinyl.transformHeadTailAndContents,
|
vinyl.transformHeadTailAndContents,
|
||||||
tryTransform(babelTransformCode)
|
tryTransform(babelTransformCode(babelOptionsJS))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
testJSX,
|
||||||
|
flow(
|
||||||
|
partial(
|
||||||
|
vinyl.transformHeadTailAndContents,
|
||||||
|
tryTransform(babelTransformCode(babelOptionsJSX))
|
||||||
),
|
),
|
||||||
partial(vinyl.setExt, 'js')
|
partial(vinyl.setExt, 'js')
|
||||||
)
|
)
|
||||||
@ -82,12 +98,12 @@ const htmlSassTransformCode = file => {
|
|||||||
div.innerHTML = file.contents;
|
div.innerHTML = file.contents;
|
||||||
const styleTags = div.querySelectorAll('style[type="text/sass"]');
|
const styleTags = div.querySelectorAll('style[type="text/sass"]');
|
||||||
if (styleTags.length > 0) {
|
if (styleTags.length > 0) {
|
||||||
return Promise.all([].map.call(styleTags, async style => {
|
return Promise.all(
|
||||||
style.type = 'text/css';
|
[].map.call(styleTags, async style => {
|
||||||
style.innerHTML = await sassWorker.execute(style.innerHTML, 2000);
|
style.type = 'text/css';
|
||||||
})).then(() => (
|
style.innerHTML = await sassWorker.execute(style.innerHTML, 2000);
|
||||||
vinyl.transformContents(() => div.innerHTML, file)
|
})
|
||||||
));
|
).then(() => vinyl.transformContents(() => div.innerHTML, file));
|
||||||
}
|
}
|
||||||
return vinyl.transformContents(() => div.innerHTML, file);
|
return vinyl.transformContents(() => div.innerHTML, file);
|
||||||
};
|
};
|
||||||
|
@ -44,6 +44,13 @@ const executeDebounceTimeout = 750;
|
|||||||
function updateMainEpic(action$, state$, { document }) {
|
function updateMainEpic(action$, state$, { document }) {
|
||||||
return action$.pipe(
|
return action$.pipe(
|
||||||
ofType(types.updateFile, types.challengeMounted),
|
ofType(types.updateFile, types.challengeMounted),
|
||||||
|
filter(() => {
|
||||||
|
const { challengeType } = challengeMetaSelector(state$.value);
|
||||||
|
return (
|
||||||
|
challengeType !== challengeTypes.js &&
|
||||||
|
challengeType !== challengeTypes.bonfire
|
||||||
|
);
|
||||||
|
}),
|
||||||
debounceTime(executeDebounceTimeout),
|
debounceTime(executeDebounceTimeout),
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
const frameMain = createMainFramer(document, state$);
|
const frameMain = createMainFramer(document, state$);
|
||||||
@ -77,6 +84,8 @@ function executeChallengeEpic(action$, state$, { document }) {
|
|||||||
consoleProxy
|
consoleProxy
|
||||||
);
|
);
|
||||||
const challengeResults = frameReady.pipe(
|
const challengeResults = frameReady.pipe(
|
||||||
|
// Delay for jQuery ready code, in jQuery challenges
|
||||||
|
delay(250),
|
||||||
pluck('checkChallengePayload'),
|
pluck('checkChallengePayload'),
|
||||||
map(checkChallengePayload => ({
|
map(checkChallengePayload => ({
|
||||||
checkChallengePayload,
|
checkChallengePayload,
|
||||||
@ -103,6 +112,13 @@ function executeChallengeEpic(action$, state$, { document }) {
|
|||||||
);
|
);
|
||||||
const buildAndFrameChallenge = action$.pipe(
|
const buildAndFrameChallenge = action$.pipe(
|
||||||
ofType(types.executeChallenge),
|
ofType(types.executeChallenge),
|
||||||
|
filter(() => {
|
||||||
|
const { challengeType } = challengeMetaSelector(state$.value);
|
||||||
|
return (
|
||||||
|
challengeType !== challengeTypes.js &&
|
||||||
|
challengeType !== challengeTypes.bonfire
|
||||||
|
);
|
||||||
|
}),
|
||||||
debounceTime(executeDebounceTimeout),
|
debounceTime(executeDebounceTimeout),
|
||||||
filter(() => isJSEnabledSelector(state$.value)),
|
filter(() => isJSEnabledSelector(state$.value)),
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
|
@ -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>(.*?)<\/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)];
|
||||||
|
}
|
@ -14,6 +14,7 @@ import codeStorageEpic from './code-storage-epic';
|
|||||||
import currentChallengeEpic from './current-challenge-epic';
|
import currentChallengeEpic from './current-challenge-epic';
|
||||||
|
|
||||||
import { createIdToNameMapSaga } from './id-to-name-map-saga';
|
import { createIdToNameMapSaga } from './id-to-name-map-saga';
|
||||||
|
import { createExecuteChallengeSaga } from './execute-challenge-saga';
|
||||||
|
|
||||||
export const ns = 'challenge';
|
export const ns = 'challenge';
|
||||||
export const backendNS = 'backendChallenge';
|
export const backendNS = 'backendChallenge';
|
||||||
@ -88,7 +89,10 @@ export const epics = [
|
|||||||
currentChallengeEpic
|
currentChallengeEpic
|
||||||
];
|
];
|
||||||
|
|
||||||
export const sagas = [...createIdToNameMapSaga(types)];
|
export const sagas = [
|
||||||
|
...createIdToNameMapSaga(types),
|
||||||
|
...createExecuteChallengeSaga(types)
|
||||||
|
];
|
||||||
|
|
||||||
export const createFiles = createAction(types.createFiles, challengeFiles =>
|
export const createFiles = createAction(types.createFiles, challengeFiles =>
|
||||||
Object.keys(challengeFiles)
|
Object.keys(challengeFiles)
|
||||||
|
@ -83,6 +83,37 @@ export function buildFromFiles(state) {
|
|||||||
return concatHtml(finalRequires, template, finalFiles);
|
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) {
|
export function buildBackendChallenge(state) {
|
||||||
const {
|
const {
|
||||||
solution: { value: url }
|
solution: { value: url }
|
||||||
|
@ -2,6 +2,10 @@ export default class WorkerExecutor {
|
|||||||
constructor(workerName) {
|
constructor(workerName) {
|
||||||
this.workerName = workerName;
|
this.workerName = workerName;
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
|
|
||||||
|
this.execute = this.execute.bind(this);
|
||||||
|
this.killWorker = this.killWorker.bind(this);
|
||||||
|
this.getWorker = this.getWorker.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
getWorker() {
|
getWorker() {
|
||||||
|
@ -6,7 +6,8 @@ module.exports = (env = {}) => {
|
|||||||
mode: __DEV__ ? 'development' : 'production',
|
mode: __DEV__ ? 'development' : 'production',
|
||||||
entry: {
|
entry: {
|
||||||
'frame-runner': './src/client/frame-runner.js',
|
'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',
|
devtool: __DEV__ ? 'inline-source-map' : 'source-map',
|
||||||
output: {
|
output: {
|
||||||
|
Reference in New Issue
Block a user