From a50a048ee77691f3349c29be80a5f539e30d37d0 Mon Sep 17 00:00:00 2001 From: Valeriy Date: Mon, 10 Dec 2018 01:46:26 +0300 Subject: [PATCH] feat(client): execute DOM challenge saga --- client/src/client/frame-runner.js | 135 +++++++----------- .../Challenges/rechallenge/builders.js | 3 +- .../redux/execute-challenge-epic.js | 53 +------ .../redux/execute-challenge-saga.js | 108 +++++++++++++- .../src/templates/Challenges/utils/frame.js | 75 +++------- 5 files changed, 171 insertions(+), 203 deletions(-) diff --git a/client/src/client/frame-runner.js b/client/src/client/frame-runner.js index 031ec48270..05e10184f0 100644 --- a/client/src/client/frame-runner.js +++ b/client/src/client/frame-runner.js @@ -4,31 +4,23 @@ import jQuery from 'jquery'; window.$ = jQuery; -document.addEventListener('DOMContentLoaded', function() { - const { - timeout, - catchError, - map, - toArray, - mergeMap, - of, - from, - throwError - } = document.__deps__.rx; +const testId = 'fcc-test-frame'; +if (window.frameElement && window.frameElement.id === testId) { + document.addEventListener('DOMContentLoaded', initTestFrame); +} + +function initTestFrame() { + // window.__logs = []; + // const oldLog = window.console.log.bind(window.console); + // window.console.log = function proxyConsole(...args) { + // window.__logs = [...window.__logs, ...args]; + // return oldLog(...args); + // }; + const frameReady = document.__frameReady; const source = document.__source; const __getUserInput = document.__getUserInput || (x => x); - const checkChallengePayload = document.__checkChallengePayload; - const fiveSeconds = 5000; - - function isPromise(value) { - return ( - value && - typeof value.subscribe !== 'function' && - typeof value.then === 'function' - ); - } // Fake Deep Equal dependency /* eslint-disable no-unused-vars */ const DeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b); @@ -49,11 +41,15 @@ document.addEventListener('DOMContentLoaded', function() { return o; }; + const assert = chai.assert; + const getUserInput = __getUserInput; + if (document.Enzyme) { window.Enzyme = document.Enzyme; } - document.__runTests = function runTests(tests = []) { + document.__runTest = async function runTests(testString) { + window.__logs = []; /* eslint-disable no-unused-vars */ const code = source.slice(0); const editor = { @@ -61,71 +57,36 @@ document.addEventListener('DOMContentLoaded', function() { return source; } }; - const assert = chai.assert; - const getUserInput = __getUserInput; - // Iterate through the test one at a time - // on new stacks - const results = from(tests).pipe( - mergeMap(function runOneTest({ text, testString }) { - const newTest = { text, testString }; - let test; - let __result; - // uncomment the following line to inspect - // the frame-runner as it runs tests - // make sure the dev tools console is open - // debugger; - try { - /* eslint-disable no-eval */ - // eval test string to actual JavaScript - // This return can be a function - // i.e. function() { assert(true, 'happy coding'); } - test = eval(testString); - /* eslint-enable no-eval */ - if (typeof test === 'function') { - // all async tests must return a promise or observable - // sync tests can return Any type - __result = test(getUserInput); - if (isPromise(__result)) { - // resolve the promise before continuing - __result = from(__result); - } - } - - if (!__result || typeof __result.subscribe !== 'function') { - // make sure result is an observable - __result = of(null); - } - } catch (e) { - __result = throwError(e); - } - return __result.pipe( - timeout(fiveSeconds), - map(() => { - // if we are here, then the assert passed - // mark test as passing - newTest.pass = true; - return newTest; - }), - catchError(err => { - const { message, stack } = err; - // we catch the error here to prevent the error from bubbling up - // and collapsing the pipe - newTest.err = message + '\n' + stack; - newTest.stack = stack; - newTest.message = text.replace(/(.*?)<\/code>/g, '$1'); - if (!(err instanceof chai.AssertionError)) { - console.error(err); - } - // RxJS catch expects an observable as a return - return of(newTest); - }) - ); - }), - toArray() - ); - return results; + /* eslint-disable no-unused-vars */ + // uncomment the following line to inspect + // the frame-runner as it runs tests + // make sure the dev tools console is open + // debugger; + try { + /* eslint-disable no-eval */ + // eval test string to actual JavaScript + // This return can be a function + // i.e. function() { assert(true, 'happy coding'); } + const test = eval(testString); + /* eslint-enable no-eval */ + if (typeof test === 'function') { + await test(getUserInput); + } + return { pass: true, logs: window.__logs.map(String) }; + } catch (err) { + if (!(err instanceof chai.AssertionError)) { + console.error(err); + } + return { + err: { + message: err.message, + stack: err.stack + }, + logs: window.__logs.map(String) + }; + } }; // notify that the window methods are ready to run - frameReady.next({ checkChallengePayload }); -}); + frameReady(); +} diff --git a/client/src/templates/Challenges/rechallenge/builders.js b/client/src/templates/Challenges/rechallenge/builders.js index c1354a23d1..bd20ad4a8f 100644 --- a/client/src/templates/Challenges/rechallenge/builders.js +++ b/client/src/templates/Challenges/rechallenge/builders.js @@ -1,4 +1,3 @@ -import { from } from 'rxjs'; import { cond, flow, @@ -102,7 +101,7 @@ A required file can not have both a src and a link: src = ${src}, link = ${link} const frameRunner = ''; - return from( + return ( Promise.all([head, body, frameRunner, sourceMap]).then( ([head, body, frameRunner, sourceMap]) => ({ build: head + frameRunner + body, diff --git a/client/src/templates/Challenges/redux/execute-challenge-epic.js b/client/src/templates/Challenges/redux/execute-challenge-epic.js index dd45bf8c2d..3ded3c86c2 100644 --- a/client/src/templates/Challenges/redux/execute-challenge-epic.js +++ b/client/src/templates/Challenges/redux/execute-challenge-epic.js @@ -13,7 +13,7 @@ import { startWith, delay } from 'rxjs/operators'; -import { ofType, combineEpics } from 'redux-observable'; +import { ofType } from 'redux-observable'; import { overEvery, isString } from 'lodash'; import { @@ -31,51 +31,12 @@ import { isJSEnabledSelector } from './'; import { buildFromFiles, buildBackendChallenge } from '../utils/build'; -import { - runTestsInTestFrame, - createTestFramer, - createMainFramer -} from '../utils/frame.js'; +import { runTestsInTestFrame, createTestFramer } from '../utils/frame.js'; import { challengeTypes } from '../../../../utils/challengeTypes'; const executeDebounceTimeout = 750; -function updateMainEpic(action$, state$, { document }) { - return action$.pipe( - ofType( - types.updateFile, - types.previewMounted, - types.challengeMounted, - types.resetChallenge - ), - filter(() => { - const { challengeType } = challengeMetaSelector(state$.value); - return ( - challengeType !== challengeTypes.js && - challengeType !== challengeTypes.bonfire - ); - }), - debounceTime(executeDebounceTimeout), - switchMap(() => { - const frameMain = createMainFramer(document, state$); - return buildFromFiles(state$.value).pipe( - map(frameMain), - ignoreElements(), - startWith(initConsole('')), - catchError((...err) => { - console.error(err); - return of(disableJSOnError(err.message)); - }) - ); - }), - catchError(err => { - console.error(err); - return of(disableJSOnError(err.message)); - }) - ); -} - function executeChallengeEpic(action$, state$, { document }) { return of(document).pipe( filter(Boolean), @@ -117,13 +78,7 @@ 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 - ); - }), + filter(() => false), debounceTime(executeDebounceTimeout), filter(() => isJSEnabledSelector(state$.value)), switchMap(() => { @@ -151,4 +106,4 @@ function executeChallengeEpic(action$, state$, { document }) { ); } -export default combineEpics(updateMainEpic, executeChallengeEpic); +export default executeChallengeEpic; diff --git a/client/src/templates/Challenges/redux/execute-challenge-saga.js b/client/src/templates/Challenges/redux/execute-challenge-saga.js index 014538525c..b3322d20d0 100644 --- a/client/src/templates/Challenges/redux/execute-challenge-saga.js +++ b/client/src/templates/Challenges/redux/execute-challenge-saga.js @@ -1,4 +1,5 @@ -import { put, select, call, takeLatest } from 'redux-saga/effects'; +import { put, select, call, takeLatest, race } from 'redux-saga/effects'; +import { delay } from 'redux-saga'; import { challengeMetaSelector, @@ -12,11 +13,16 @@ import { challengeFilesSelector } from './'; -import { buildJSFromFiles } from '../utils/build'; +import { buildJSFromFiles, buildFromFiles } from '../utils/build'; import { challengeTypes } from '../../../../utils/challengeTypes'; import WorkerExecutor from '../utils/worker-executor'; +import { + createMainFramer, + createTestFramer, + runTestInTestFrame +} from '../utils/frame.js'; const testWorker = new WorkerExecutor('test-evaluator'); const testTimeout = 5000; @@ -26,8 +32,8 @@ function* ExecuteChallengeSaga() { const { js, bonfire, backend } = challengeTypes; const { challengeType } = yield select(challengeMetaSelector); - // TODO: ExecuteBackendChallengeSaga and ExecuteDOMChallengeSaga - if (challengeType !== js && challengeType !== bonfire) { + // TODO: ExecuteBackendChallengeSaga + if (challengeType === backend) { return; } @@ -45,7 +51,7 @@ function* ExecuteChallengeSaga() { // yield ExecuteBackendChallengeSaga(); break; default: - // yield ExecuteDOMChallengeSaga(); + testResults = yield ExecuteDOMChallengeSaga(tests); } yield put(updateTests(testResults)); @@ -96,6 +102,94 @@ function* ExecuteJSChallengeSaga(tests) { return testResults; } -export function createExecuteChallengeSaga(types) { - return [takeLatest(types.executeChallenge, ExecuteChallengeSaga)]; +function createTestFrame(state, ctx, proxyLogger) { + return new Promise(resolve => { + const frameTest = createTestFramer(document, state, resolve, proxyLogger); + frameTest(ctx); + }).then(() => console.log('Frame ready')); +} + +function* proxyLogger() { + let args = yield; + while (true) { + args = yield put(updateLogs(args)); + } +} + +function* ExecuteDOMChallengeSaga(tests) { + const testResults = []; + const state = yield select(); + const ctx = yield call(buildFromFiles, state); + const proxy = proxyLogger(); + proxy.next('1'); + proxy.next('2'); + proxy.next('3'); + yield call(createTestFrame, state, ctx, proxy); + + for (const { text, testString } of tests) { + const newTest = { text, testString }; + try { + const [{ pass, err, logs }, timeout] = yield race([ + call(runTestInTestFrame, document, testString), + delay(testTimeout, 'timeout') + ]); + if (timeout) { + throw timeout; + } + for (const log of logs) { + yield put(updateLogs(log)); + } + if (pass) { + newTest.pass = true; + } else { + throw err; + } + } catch (err) { + newTest.message = text.replace(/(.*?)<\/code>/g, '$1'); + if (err === 'timeout') { + newTest.err = 'Test timed out'; + newTest.message = `${newTest.message} (${newTest.err})`; + } else { + const { message, stack } = err; + newTest.err = message + '\n' + stack; + newTest.stack = stack; + } + console.error(err); + yield put(updateConsole(newTest.message)); + } finally { + testResults.push(newTest); + } + } + return testResults; +} + +function* updateMainSaga() { + try { + const { html, modern } = challengeTypes; + const { challengeType } = yield select(challengeMetaSelector); + if (challengeType !== html && challengeType !== modern) { + return; + } + const state = yield select(); + const frameMain = yield call(createMainFramer, document, state); + const ctx = yield call(buildFromFiles, state); + yield call(frameMain, ctx); + } catch (err) { + console.error(err); + } +} + +export function createExecuteChallengeSaga(types) { + return [ + takeLatest(types.executeChallenge, ExecuteChallengeSaga), + takeLatest( + [ + types.updateFile, + types.previewMounted, + types.challengeMounted, + types.resetChallenge + ], + updateMainSaga + ) + ]; } diff --git a/client/src/templates/Challenges/utils/frame.js b/client/src/templates/Challenges/utils/frame.js index cdbd6f848a..862c4e4bcf 100644 --- a/client/src/templates/Challenges/utils/frame.js +++ b/client/src/templates/Challenges/utils/frame.js @@ -1,14 +1,4 @@ import { toString, flow } from 'lodash'; -import { defer, of, from, Observable, throwError, queueScheduler } from 'rxjs'; -import { - tap, - map, - toArray, - delay, - mergeMap, - timeout, - catchError -} from 'rxjs/operators'; import { configure, shallow, mount } from 'enzyme'; import Adapter16 from 'enzyme-adapter-react-16'; import { setConfig } from 'react-hot-loader'; @@ -43,16 +33,15 @@ const createHeader = (id = mainId) => ` `; -export const runTestsInTestFrame = (document, tests) => - defer(() => { - const { contentDocument: frame } = document.getElementById(testId); - // Enable Stateless Functional Component. Otherwise, enzyme-adapter-react-16 - // does not work correctly. - setConfig({ pureSFC: true }); - return frame - .__runTests(tests) - .pipe(tap(() => setConfig({ pureSFC: false }))); - }); +export const runTestInTestFrame = async function(document, tests) { + const { contentDocument: frame } = document.getElementById(testId); + // Enable Stateless Functional Component. Otherwise, enzyme-adapter-react-16 + // does not work correctly. + setConfig({ pureSFC: true }); + const result = await frame.__runTest(tests); + setConfig({ pureSFC: false }); + return result; +}; const createFrame = (document, state, id) => ctx => { const isJSEnabled = isJSEnabledSelector(state); @@ -67,14 +56,14 @@ const createFrame = (document, state, id) => ctx => { }; }; -const hiddenFrameClassname = 'hide-test-frame'; +const hiddenFrameClassName = 'hide-test-frame'; const mountFrame = document => ({ element, ...rest }) => { const oldFrame = document.getElementById(element.id); if (oldFrame) { - element.className = oldFrame.className || hiddenFrameClassname; + element.className = oldFrame.className || hiddenFrameClassName; oldFrame.parentNode.replaceChild(element, oldFrame); } else { - element.className = hiddenFrameClassname; + element.className = hiddenFrameClassName; document.body.appendChild(element); } return { @@ -85,33 +74,6 @@ const mountFrame = document => ({ element, ...rest }) => { }; }; -const addDepsToDocument = ctx => { - ctx.document.__deps__ = { - rx: { - of, - from, - Observable, - throwError, - queueScheduler, - tap, - map, - toArray, - delay, - mergeMap, - timeout, - catchError - }, - log: (...things) => console.log('from test frame', ...things) - }; - // using require here prevents nodejs issues as loop-protect - // is added to the window object by webpack and not available to - // us server side. - /* eslint-disable import/no-unresolved */ - ctx.document.loopProtect = require('loop-protect'); - /* eslint-enable import/no-unresolved */ - return ctx; -}; - const buildProxyConsole = proxyLogger => ctx => { const oldLog = ctx.window.console.log.bind(ctx.window.console); ctx.window.console.log = function proxyConsole(...args) { @@ -122,7 +84,7 @@ const buildProxyConsole = proxyLogger => ctx => { }; const writeTestDepsToDocument = frameReady => ctx => { - const { sources, checkChallengePayload } = ctx; + const { sources } = ctx; // add enzyme // TODO: do programatically // TODO: webpack lazyload this @@ -133,7 +95,6 @@ const writeTestDepsToDocument = frameReady => ctx => { ctx.document.__source = sources && 'index' in sources ? sources['index'] : ''; // provide the file name and get the original source ctx.document.__getUserInput = fileName => toString(sources[fileName]); - ctx.document.__checkChallengePayload = checkChallengePayload; ctx.document.__frameReady = frameReady; return ctx; }; @@ -150,19 +111,17 @@ const writeContentToFrame = ctx => { return ctx; }; -export const createMainFramer = (document, state$) => +export const createMainFramer = (document, state) => flow( - createFrame(document, state$.value, mainId), + createFrame(document, state, mainId), mountFrame(document), - addDepsToDocument, writeContentToFrame ); -export const createTestFramer = (document, state$, frameReady, proxyConsole) => +export const createTestFramer = (document, state, frameReady, proxyConsole) => flow( - createFrame(document, state$.value, testId), + createFrame(document, state, testId), mountFrame(document), - addDepsToDocument, writeTestDepsToDocument(frameReady), buildProxyConsole(proxyConsole), writeContentToFrame