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

View File

@ -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 =
'<script src="/js/frame-runner.js" type="text/javascript"></script>';
return from(
return (
Promise.all([head, body, frameRunner, sourceMap]).then(
([head, body, frameRunner, sourceMap]) => ({
build: head + frameRunner + body,

View File

@ -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;

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 {
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>(.*?)<\/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 { 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) => `
</script>
`;
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