feat(client): execute DOM challenge saga

This commit is contained in:
Valeriy
2018-12-10 01:46:26 +03:00
committed by Stuart Taylor
parent a5735e1a98
commit a50a048ee7
5 changed files with 171 additions and 203 deletions

View File

@ -4,31 +4,23 @@ import jQuery from 'jquery';
window.$ = jQuery; window.$ = jQuery;
document.addEventListener('DOMContentLoaded', function() { const testId = 'fcc-test-frame';
const { if (window.frameElement && window.frameElement.id === testId) {
timeout, document.addEventListener('DOMContentLoaded', initTestFrame);
catchError, }
map,
toArray, function initTestFrame() {
mergeMap, // window.__logs = [];
of, // const oldLog = window.console.log.bind(window.console);
from, // window.console.log = function proxyConsole(...args) {
throwError // window.__logs = [...window.__logs, ...args];
} = document.__deps__.rx; // return oldLog(...args);
// };
const frameReady = document.__frameReady; const frameReady = document.__frameReady;
const source = document.__source; const source = document.__source;
const __getUserInput = document.__getUserInput || (x => x); 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 // Fake Deep Equal dependency
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
const DeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b); const DeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@ -49,11 +41,15 @@ document.addEventListener('DOMContentLoaded', function() {
return o; return o;
}; };
const assert = chai.assert;
const getUserInput = __getUserInput;
if (document.Enzyme) { if (document.Enzyme) {
window.Enzyme = document.Enzyme; window.Enzyme = document.Enzyme;
} }
document.__runTests = function runTests(tests = []) { document.__runTest = async function runTests(testString) {
window.__logs = [];
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
const code = source.slice(0); const code = source.slice(0);
const editor = { const editor = {
@ -61,71 +57,36 @@ document.addEventListener('DOMContentLoaded', function() {
return source; return source;
} }
}; };
const assert = chai.assert; /* eslint-disable no-unused-vars */
const getUserInput = __getUserInput; // uncomment the following line to inspect
// Iterate through the test one at a time // the frame-runner as it runs tests
// on new stacks // make sure the dev tools console is open
const results = from(tests).pipe( // debugger;
mergeMap(function runOneTest({ text, testString }) { try {
const newTest = { text, testString }; /* eslint-disable no-eval */
let test; // eval test string to actual JavaScript
let __result; // This return can be a function
// uncomment the following line to inspect // i.e. function() { assert(true, 'happy coding'); }
// the frame-runner as it runs tests const test = eval(testString);
// make sure the dev tools console is open /* eslint-enable no-eval */
// debugger; if (typeof test === 'function') {
try { await test(getUserInput);
/* eslint-disable no-eval */ }
// eval test string to actual JavaScript return { pass: true, logs: window.__logs.map(String) };
// This return can be a function } catch (err) {
// i.e. function() { assert(true, 'happy coding'); } if (!(err instanceof chai.AssertionError)) {
test = eval(testString); console.error(err);
/* eslint-enable no-eval */ }
if (typeof test === 'function') { return {
// all async tests must return a promise or observable err: {
// sync tests can return Any type message: err.message,
__result = test(getUserInput); stack: err.stack
if (isPromise(__result)) { },
// resolve the promise before continuing logs: window.__logs.map(String)
__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>(.*?)<\/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;
}; };
// notify that the window methods are ready to run // notify that the window methods are ready to run
frameReady.next({ checkChallengePayload }); frameReady();
}); }

View File

@ -1,4 +1,3 @@
import { from } from 'rxjs';
import { import {
cond, cond,
flow, flow,
@ -102,7 +101,7 @@ A required file can not have both a src and a link: src = ${src}, link = ${link}
const frameRunner = const frameRunner =
'<script src="/js/frame-runner.js" type="text/javascript"></script>'; '<script src="/js/frame-runner.js" type="text/javascript"></script>';
return from( return (
Promise.all([head, body, frameRunner, sourceMap]).then( Promise.all([head, body, frameRunner, sourceMap]).then(
([head, body, frameRunner, sourceMap]) => ({ ([head, body, frameRunner, sourceMap]) => ({
build: head + frameRunner + body, build: head + frameRunner + body,

View File

@ -13,7 +13,7 @@ import {
startWith, startWith,
delay delay
} from 'rxjs/operators'; } from 'rxjs/operators';
import { ofType, combineEpics } from 'redux-observable'; import { ofType } from 'redux-observable';
import { overEvery, isString } from 'lodash'; import { overEvery, isString } from 'lodash';
import { import {
@ -31,51 +31,12 @@ import {
isJSEnabledSelector isJSEnabledSelector
} from './'; } from './';
import { buildFromFiles, buildBackendChallenge } from '../utils/build'; import { buildFromFiles, buildBackendChallenge } from '../utils/build';
import { import { runTestsInTestFrame, createTestFramer } from '../utils/frame.js';
runTestsInTestFrame,
createTestFramer,
createMainFramer
} from '../utils/frame.js';
import { challengeTypes } from '../../../../utils/challengeTypes'; import { challengeTypes } from '../../../../utils/challengeTypes';
const executeDebounceTimeout = 750; 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 }) { function executeChallengeEpic(action$, state$, { document }) {
return of(document).pipe( return of(document).pipe(
filter(Boolean), filter(Boolean),
@ -117,13 +78,7 @@ function executeChallengeEpic(action$, state$, { document }) {
); );
const buildAndFrameChallenge = action$.pipe( const buildAndFrameChallenge = action$.pipe(
ofType(types.executeChallenge), ofType(types.executeChallenge),
filter(() => { filter(() => false),
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(() => {
@ -151,4 +106,4 @@ function executeChallengeEpic(action$, state$, { document }) {
); );
} }
export default combineEpics(updateMainEpic, executeChallengeEpic); export default executeChallengeEpic;

View File

@ -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 { import {
challengeMetaSelector, challengeMetaSelector,
@ -12,11 +13,16 @@ import {
challengeFilesSelector challengeFilesSelector
} from './'; } from './';
import { buildJSFromFiles } from '../utils/build'; import { buildJSFromFiles, buildFromFiles } from '../utils/build';
import { challengeTypes } from '../../../../utils/challengeTypes'; import { challengeTypes } from '../../../../utils/challengeTypes';
import WorkerExecutor from '../utils/worker-executor'; import WorkerExecutor from '../utils/worker-executor';
import {
createMainFramer,
createTestFramer,
runTestInTestFrame
} from '../utils/frame.js';
const testWorker = new WorkerExecutor('test-evaluator'); const testWorker = new WorkerExecutor('test-evaluator');
const testTimeout = 5000; const testTimeout = 5000;
@ -26,8 +32,8 @@ function* ExecuteChallengeSaga() {
const { js, bonfire, backend } = challengeTypes; const { js, bonfire, backend } = challengeTypes;
const { challengeType } = yield select(challengeMetaSelector); const { challengeType } = yield select(challengeMetaSelector);
// TODO: ExecuteBackendChallengeSaga and ExecuteDOMChallengeSaga // TODO: ExecuteBackendChallengeSaga
if (challengeType !== js && challengeType !== bonfire) { if (challengeType === backend) {
return; return;
} }
@ -45,7 +51,7 @@ function* ExecuteChallengeSaga() {
// yield ExecuteBackendChallengeSaga(); // yield ExecuteBackendChallengeSaga();
break; break;
default: default:
// yield ExecuteDOMChallengeSaga(); testResults = yield ExecuteDOMChallengeSaga(tests);
} }
yield put(updateTests(testResults)); yield put(updateTests(testResults));
@ -96,6 +102,94 @@ function* ExecuteJSChallengeSaga(tests) {
return testResults; return testResults;
} }
export function createExecuteChallengeSaga(types) { function createTestFrame(state, ctx, proxyLogger) {
return [takeLatest(types.executeChallenge, ExecuteChallengeSaga)]; 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>(.*?)<\/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
)
];
} }

View File

@ -1,14 +1,4 @@
import { toString, flow } from 'lodash'; 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 { configure, shallow, mount } from 'enzyme';
import Adapter16 from 'enzyme-adapter-react-16'; import Adapter16 from 'enzyme-adapter-react-16';
import { setConfig } from 'react-hot-loader'; import { setConfig } from 'react-hot-loader';
@ -43,16 +33,15 @@ const createHeader = (id = mainId) => `
</script> </script>
`; `;
export const runTestsInTestFrame = (document, tests) => export const runTestInTestFrame = async function(document, tests) {
defer(() => { const { contentDocument: frame } = document.getElementById(testId);
const { contentDocument: frame } = document.getElementById(testId); // Enable Stateless Functional Component. Otherwise, enzyme-adapter-react-16
// Enable Stateless Functional Component. Otherwise, enzyme-adapter-react-16 // does not work correctly.
// does not work correctly. setConfig({ pureSFC: true });
setConfig({ pureSFC: true }); const result = await frame.__runTest(tests);
return frame setConfig({ pureSFC: false });
.__runTests(tests) return result;
.pipe(tap(() => setConfig({ pureSFC: false }))); };
});
const createFrame = (document, state, id) => ctx => { const createFrame = (document, state, id) => ctx => {
const isJSEnabled = isJSEnabledSelector(state); 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 mountFrame = document => ({ element, ...rest }) => {
const oldFrame = document.getElementById(element.id); const oldFrame = document.getElementById(element.id);
if (oldFrame) { if (oldFrame) {
element.className = oldFrame.className || hiddenFrameClassname; element.className = oldFrame.className || hiddenFrameClassName;
oldFrame.parentNode.replaceChild(element, oldFrame); oldFrame.parentNode.replaceChild(element, oldFrame);
} else { } else {
element.className = hiddenFrameClassname; element.className = hiddenFrameClassName;
document.body.appendChild(element); document.body.appendChild(element);
} }
return { 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 buildProxyConsole = proxyLogger => ctx => {
const oldLog = ctx.window.console.log.bind(ctx.window.console); const oldLog = ctx.window.console.log.bind(ctx.window.console);
ctx.window.console.log = function proxyConsole(...args) { ctx.window.console.log = function proxyConsole(...args) {
@ -122,7 +84,7 @@ const buildProxyConsole = proxyLogger => ctx => {
}; };
const writeTestDepsToDocument = frameReady => ctx => { const writeTestDepsToDocument = frameReady => ctx => {
const { sources, checkChallengePayload } = ctx; const { sources } = ctx;
// add enzyme // add enzyme
// TODO: do programatically // TODO: do programatically
// TODO: webpack lazyload this // TODO: webpack lazyload this
@ -133,7 +95,6 @@ const writeTestDepsToDocument = frameReady => ctx => {
ctx.document.__source = sources && 'index' in sources ? sources['index'] : ''; ctx.document.__source = sources && 'index' in sources ? sources['index'] : '';
// provide the file name and get the original source // provide the file name and get the original source
ctx.document.__getUserInput = fileName => toString(sources[fileName]); ctx.document.__getUserInput = fileName => toString(sources[fileName]);
ctx.document.__checkChallengePayload = checkChallengePayload;
ctx.document.__frameReady = frameReady; ctx.document.__frameReady = frameReady;
return ctx; return ctx;
}; };
@ -150,19 +111,17 @@ const writeContentToFrame = ctx => {
return ctx; return ctx;
}; };
export const createMainFramer = (document, state$) => export const createMainFramer = (document, state) =>
flow( flow(
createFrame(document, state$.value, mainId), createFrame(document, state, mainId),
mountFrame(document), mountFrame(document),
addDepsToDocument,
writeContentToFrame writeContentToFrame
); );
export const createTestFramer = (document, state$, frameReady, proxyConsole) => export const createTestFramer = (document, state, frameReady, proxyConsole) =>
flow( flow(
createFrame(document, state$.value, testId), createFrame(document, state, testId),
mountFrame(document), mountFrame(document),
addDepsToDocument,
writeTestDepsToDocument(frameReady), writeTestDepsToDocument(frameReady),
buildProxyConsole(proxyConsole), buildProxyConsole(proxyConsole),
writeContentToFrame writeContentToFrame