* Fixed Error message update not displaying when timeout happens when there was an error in the code. Working on fixing the timeout it self. * refactor: remove comment + log changes Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
271 lines
7.5 KiB
JavaScript
271 lines
7.5 KiB
JavaScript
import i18next from 'i18next';
|
|
import { escape } from 'lodash-es';
|
|
import { channel } from 'redux-saga';
|
|
import {
|
|
delay,
|
|
put,
|
|
select,
|
|
call,
|
|
takeLatest,
|
|
takeEvery,
|
|
fork,
|
|
getContext,
|
|
take,
|
|
cancel
|
|
} from 'redux-saga/effects';
|
|
|
|
import { playTone } from '../../../utils/tone';
|
|
import {
|
|
buildChallenge,
|
|
canBuildChallenge,
|
|
getTestRunner,
|
|
challengeHasPreview,
|
|
updatePreview,
|
|
updateProjectPreview,
|
|
isJavaScriptChallenge,
|
|
isLoopProtected
|
|
} from '../utils/build';
|
|
import { actionTypes } from './action-types';
|
|
import {
|
|
challengeDataSelector,
|
|
challengeMetaSelector,
|
|
challengeTestsSelector,
|
|
initConsole,
|
|
updateConsole,
|
|
initLogs,
|
|
updateLogs,
|
|
logsToConsole,
|
|
updateTests,
|
|
openModal,
|
|
isBuildEnabledSelector,
|
|
disableBuildOnError
|
|
} from './';
|
|
|
|
// How long before bailing out of a preview.
|
|
const previewTimeout = 2500;
|
|
let previewTask;
|
|
|
|
export function* executeCancellableChallengeSaga(payload) {
|
|
if (previewTask) {
|
|
yield cancel(previewTask);
|
|
}
|
|
// executeChallenge with payload containing {showCompletionModal}
|
|
const task = yield fork(executeChallengeSaga, payload);
|
|
previewTask = yield fork(previewChallengeSaga, { flushLogs: false });
|
|
|
|
yield take(actionTypes.cancelTests);
|
|
yield cancel(task);
|
|
}
|
|
|
|
export function* executeCancellablePreviewSaga() {
|
|
previewTask = yield fork(previewChallengeSaga);
|
|
}
|
|
|
|
export function* executeChallengeSaga({ payload }) {
|
|
const isBuildEnabled = yield select(isBuildEnabledSelector);
|
|
if (!isBuildEnabled) {
|
|
return;
|
|
}
|
|
|
|
const consoleProxy = yield channel();
|
|
|
|
try {
|
|
yield put(initLogs());
|
|
yield put(initConsole(i18next.t('learn.running-tests')));
|
|
// reset tests to initial state
|
|
const tests = (yield select(challengeTestsSelector)).map(
|
|
({ text, testString }) => ({ text, testString })
|
|
);
|
|
yield put(updateTests(tests));
|
|
|
|
yield fork(takeEveryLog, consoleProxy);
|
|
const proxyLogger = args => consoleProxy.put(args);
|
|
|
|
const challengeData = yield select(challengeDataSelector);
|
|
const challengeMeta = yield select(challengeMetaSelector);
|
|
const protect = isLoopProtected(challengeMeta);
|
|
const buildData = yield buildChallengeData(challengeData, {
|
|
preview: false,
|
|
protect,
|
|
usesTestRunner: true
|
|
});
|
|
const document = yield getContext('document');
|
|
const testRunner = yield call(
|
|
getTestRunner,
|
|
buildData,
|
|
{ proxyLogger, removeComments: challengeMeta.removeComments },
|
|
document
|
|
);
|
|
const testResults = yield executeTests(testRunner, tests);
|
|
yield put(updateTests(testResults));
|
|
|
|
const challengeComplete = testResults.every(test => test.pass && !test.err);
|
|
if (challengeComplete) {
|
|
playTone('tests-completed');
|
|
} else {
|
|
playTone('tests-failed');
|
|
}
|
|
if (challengeComplete && payload?.showCompletionModal) {
|
|
yield put(openModal('completion'));
|
|
}
|
|
yield put(updateConsole(i18next.t('learn.tests-completed')));
|
|
yield put(logsToConsole(i18next.t('learn.console-output')));
|
|
} catch (e) {
|
|
yield put(updateConsole(e));
|
|
} finally {
|
|
consoleProxy.close();
|
|
}
|
|
}
|
|
|
|
function* takeEveryLog(channel) {
|
|
// TODO: move all stringifying and escaping into the reducer so there is a
|
|
// single place responsible for formatting the logs.
|
|
yield takeEvery(channel, function* (args) {
|
|
yield put(updateLogs(escape(args)));
|
|
});
|
|
}
|
|
|
|
function* takeEveryConsole(channel) {
|
|
// TODO: move all stringifying and escaping into the reducer so there is a
|
|
// single place responsible for formatting the console output.
|
|
yield takeEvery(channel, function* (args) {
|
|
yield put(updateConsole(escape(args)));
|
|
});
|
|
}
|
|
|
|
function* buildChallengeData(challengeData, options) {
|
|
try {
|
|
return yield call(buildChallenge, challengeData, options);
|
|
} catch (e) {
|
|
yield put(disableBuildOnError());
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function* executeTests(testRunner, tests, testTimeout = 5000) {
|
|
const testResults = [];
|
|
for (let i = 0; i < tests.length; i++) {
|
|
const { text, testString } = tests[i];
|
|
const newTest = { text, testString };
|
|
// only the last test outputs console.logs to avoid log duplication.
|
|
const firstTest = i === 1;
|
|
try {
|
|
const { pass, err } = yield call(
|
|
testRunner,
|
|
testString,
|
|
testTimeout,
|
|
firstTest
|
|
);
|
|
if (pass) {
|
|
newTest.pass = true;
|
|
} else {
|
|
throw err;
|
|
}
|
|
} catch (err) {
|
|
const { actual, expected } = err;
|
|
|
|
newTest.message = text
|
|
.replace('--fcc-expected--', expected)
|
|
.replace('--fcc-actual--', actual);
|
|
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;
|
|
}
|
|
yield put(updateConsole(newTest.message));
|
|
} finally {
|
|
testResults.push(newTest);
|
|
}
|
|
}
|
|
return testResults;
|
|
}
|
|
|
|
// updates preview frame and the fcc console.
|
|
function* previewChallengeSaga({ flushLogs = true } = {}) {
|
|
yield delay(700);
|
|
|
|
const isBuildEnabled = yield select(isBuildEnabledSelector);
|
|
if (!isBuildEnabled) {
|
|
return;
|
|
}
|
|
|
|
const logProxy = yield channel();
|
|
const proxyLogger = args => logProxy.put(args);
|
|
|
|
try {
|
|
if (flushLogs) {
|
|
yield put(initLogs());
|
|
yield put(initConsole(''));
|
|
}
|
|
yield fork(takeEveryConsole, logProxy);
|
|
|
|
const challengeData = yield select(challengeDataSelector);
|
|
|
|
if (canBuildChallenge(challengeData)) {
|
|
const challengeMeta = yield select(challengeMetaSelector);
|
|
const protect = isLoopProtected(challengeMeta);
|
|
const buildData = yield buildChallengeData(challengeData, {
|
|
preview: true,
|
|
protect
|
|
});
|
|
// evaluate the user code in the preview frame or in the worker
|
|
if (challengeHasPreview(challengeData)) {
|
|
const document = yield getContext('document');
|
|
yield call(updatePreview, buildData, document, proxyLogger);
|
|
} else if (isJavaScriptChallenge(challengeData)) {
|
|
const runUserCode = getTestRunner(buildData, {
|
|
proxyLogger,
|
|
removeComments: challengeMeta.removeComments
|
|
});
|
|
// without a testString the testRunner just evaluates the user's code
|
|
yield call(runUserCode, null, previewTimeout);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err[0] === 'timeout') {
|
|
// TODO: translate the error
|
|
// eslint-disable-next-line no-ex-assign
|
|
err[0] = `The code you have written is taking longer than the ${previewTimeout}ms our challenges allow. You may have created an infinite loop or need to write a more efficient algorithm`;
|
|
}
|
|
console.log(err);
|
|
yield put(updateConsole(escape(err)));
|
|
}
|
|
}
|
|
|
|
function* previewProjectSolutionSaga({ payload }) {
|
|
if (!payload) return;
|
|
const { showProjectPreview, challengeData } = payload;
|
|
if (!showProjectPreview) return;
|
|
|
|
try {
|
|
if (canBuildChallenge(challengeData)) {
|
|
const buildData = yield buildChallengeData(challengeData);
|
|
if (challengeHasPreview(challengeData)) {
|
|
const document = yield getContext('document');
|
|
yield call(updateProjectPreview, buildData, document);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.log(err);
|
|
}
|
|
}
|
|
|
|
export function createExecuteChallengeSaga(types) {
|
|
return [
|
|
takeLatest(types.executeChallenge, executeCancellableChallengeSaga),
|
|
takeLatest(
|
|
[
|
|
types.updateFile,
|
|
types.previewMounted,
|
|
types.challengeMounted,
|
|
types.resetChallenge
|
|
],
|
|
executeCancellablePreviewSaga
|
|
),
|
|
takeLatest(types.projectPreviewMounted, previewProjectSolutionSaga)
|
|
];
|
|
}
|