Files
freeCodeCamp/client/src/templates/Challenges/redux/execute-challenge-saga.js

271 lines
7.5 KiB
JavaScript
Raw Normal View History

import i18next from 'i18next';
import { escape } from 'lodash-es';
import { channel } from 'redux-saga';
2018-12-10 08:22:32 +03:00
import {
2019-06-10 15:22:45 +03:00
delay,
2018-12-10 08:22:32 +03:00
put,
select,
call,
takeLatest,
takeEvery,
2019-01-09 03:36:45 +03:00
fork,
getContext,
take,
cancel
2018-12-10 08:22:32 +03:00
} from 'redux-saga/effects';
2018-11-26 02:17:38 +03:00
import { playTone } from '../../../utils/tone';
import {
buildChallenge,
canBuildChallenge,
getTestRunner,
challengeHasPreview,
updatePreview,
updateProjectPreview,
isJavaScriptChallenge,
isLoopProtected
} from '../utils/build';
import { actionTypes } from './action-types';
2018-11-26 02:17:38 +03:00
import {
2019-02-08 17:33:05 +03:00
challengeDataSelector,
challengeMetaSelector,
2018-11-26 02:17:38 +03:00
challengeTestsSelector,
initConsole,
updateConsole,
initLogs,
updateLogs,
logsToConsole,
2019-02-08 17:33:05 +03:00
updateTests,
openModal,
2019-02-08 17:33:05 +03:00
isBuildEnabledSelector,
disableBuildOnError
2018-11-26 02:17:38 +03:00
} 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 }) {
2019-02-08 17:33:05 +03:00
const isBuildEnabled = yield select(isBuildEnabledSelector);
if (!isBuildEnabled) {
return;
}
2018-12-27 13:34:55 +03:00
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
});
2019-02-08 17:33:05 +03:00
const document = yield getContext('document');
const testRunner = yield call(
getTestRunner,
buildData,
{ proxyLogger, removeComments: challengeMeta.removeComments },
2019-02-08 17:33:05 +03:00
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));
2018-12-27 13:34:55 +03:00
} finally {
consoleProxy.close();
2018-11-26 02:17:38 +03:00
}
}
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) {
2018-12-27 13:34:55 +03:00
try {
return yield call(buildChallenge, challengeData, options);
2019-02-08 17:33:05 +03:00
} catch (e) {
yield put(disableBuildOnError());
throw e;
2018-12-27 13:34:55 +03:00
}
2018-11-26 02:17:38 +03:00
}
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) {
2021-11-25 16:10:01 +01:00
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);
2019-02-08 17:33:05 +03:00
const isBuildEnabled = yield select(isBuildEnabledSelector);
if (!isBuildEnabled) {
return;
}
const logProxy = yield channel();
const proxyLogger = args => logProxy.put(args);
2019-02-08 17:33:05 +03:00
2018-12-10 17:29:58 +03:00
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);
}
}
2018-12-10 17:29:58 +03:00
} 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)));
2018-12-10 17:29:58 +03:00
}
}
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);
}
}
2018-11-26 02:17:38 +03:00
export function createExecuteChallengeSaga(types) {
return [
takeLatest(types.executeChallenge, executeCancellableChallengeSaga),
takeLatest(
[
types.updateFile,
types.previewMounted,
types.challengeMounted,
types.resetChallenge
],
executeCancellablePreviewSaga
),
takeLatest(types.projectPreviewMounted, previewProjectSolutionSaga)
];
2018-11-26 02:17:38 +03:00
}