mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat(client): integrate new test runner (#60318)
This commit is contained in:
committed by
GitHub
parent
18e2f919c2
commit
49fbe88369
@@ -19,6 +19,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "js/test-runner/*/*.js",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=172800, immutable"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "{misc/*.js,sw.js,python-input-sw.js}",
|
||||
"headers": [
|
||||
|
||||
@@ -10,8 +10,7 @@ interface ConcatHTMLOptions {
|
||||
export function concatHtml({
|
||||
required = [],
|
||||
template,
|
||||
contents,
|
||||
testRunner
|
||||
contents
|
||||
}: ConcatHTMLOptions): string {
|
||||
const embedSource = template
|
||||
? _template(template)
|
||||
@@ -33,14 +32,7 @@ A required file can not have both a src and a link: src = ${src}, link = ${link}
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// The script has an id so that tests can look for it, if needed.
|
||||
const testRunnerScript = testRunner
|
||||
? `<script id="fcc-test-runner" src='${testRunner}' type='text/javascript'></script>`
|
||||
: '';
|
||||
|
||||
return `<head>${head}</head>${
|
||||
embedSource({ source: contents }) || ''
|
||||
}${testRunnerScript}`;
|
||||
return `<head>${head}</head>${embedSource({ source: contents }) || ''}`;
|
||||
}
|
||||
|
||||
export function createPythonTerminal(pythonRunnerSrc: string): string {
|
||||
|
||||
@@ -114,9 +114,6 @@ export function* executeChallengeSaga({ payload }) {
|
||||
const hooks = yield select(challengeHooksSelector);
|
||||
yield put(updateTests(tests));
|
||||
|
||||
yield fork(takeEveryLog, consoleProxy);
|
||||
const proxyLogger = args => consoleProxy.put(args);
|
||||
|
||||
const challengeData = yield select(challengeDataSelector);
|
||||
const challengeMeta = yield select(challengeMetaSelector);
|
||||
// The buildData is used even if there are build errors, so that lessons
|
||||
@@ -127,13 +124,7 @@ export function* executeChallengeSaga({ payload }) {
|
||||
disableLoopProtectPreview: challengeMeta.disableLoopProtectPreview,
|
||||
usesTestRunner: true
|
||||
});
|
||||
const document = yield getContext('document');
|
||||
const testRunner = yield call(
|
||||
getTestRunner,
|
||||
{ ...buildData, hooks },
|
||||
{ proxyLogger },
|
||||
document
|
||||
);
|
||||
const testRunner = yield call(getTestRunner, { ...buildData, hooks });
|
||||
const testResults = yield executeTests(testRunner, tests);
|
||||
yield put(updateTests(testResults));
|
||||
|
||||
@@ -169,14 +160,6 @@ export function* executeChallengeSaga({ payload }) {
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -199,22 +182,27 @@ function* executeTests(testRunner, tests, testTimeout = 5000) {
|
||||
for (let i = 0; i < tests.length; i++) {
|
||||
const { text, testString } = tests[i];
|
||||
const newTest = { text, testString, running: false };
|
||||
// only the last test outputs console.logs to avoid log duplication.
|
||||
const firstTest = i === 1;
|
||||
// only the first test outputs console.logs to avoid log duplication.
|
||||
const firstTest = i === 0;
|
||||
try {
|
||||
const { pass, err } = yield call(
|
||||
testRunner,
|
||||
testString,
|
||||
testTimeout,
|
||||
firstTest
|
||||
);
|
||||
const {
|
||||
pass,
|
||||
err,
|
||||
logs = []
|
||||
} = yield call(testRunner, testString, testTimeout);
|
||||
|
||||
const logString = logs.map(log => log.msg).join('\n');
|
||||
if (firstTest && logString) {
|
||||
yield put(updateLogs(logString));
|
||||
}
|
||||
|
||||
if (pass) {
|
||||
newTest.pass = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
const { actual, expected, errorType } = err;
|
||||
const { actual, expected, type } = err;
|
||||
|
||||
newTest.message = text
|
||||
.replace('--fcc-expected--', expected)
|
||||
@@ -222,9 +210,9 @@ function* executeTests(testRunner, tests, testTimeout = 5000) {
|
||||
if (err === 'timeout') {
|
||||
newTest.err = 'Test timed out';
|
||||
newTest.message = `${newTest.message} (${newTest.err})`;
|
||||
} else if (errorType) {
|
||||
} else if (type) {
|
||||
const msgKey =
|
||||
errorType === 'indentation'
|
||||
type === 'IndentationError'
|
||||
? 'learn.indentation-error'
|
||||
: 'learn.syntax-error';
|
||||
newTest.message = `<p>${i18next.t(msgKey)}</p>`;
|
||||
@@ -300,11 +288,12 @@ export function* previewChallengeSaga(action) {
|
||||
yield call(updatePreview, buildData, finalDocument, proxyLogger);
|
||||
}
|
||||
} else if (isJavaScriptChallenge(challengeData)) {
|
||||
const runUserCode = getTestRunner(buildData, {
|
||||
proxyLogger
|
||||
});
|
||||
const runUserCode = yield call(getTestRunner, buildData);
|
||||
// without a testString the testRunner just evaluates the user's code
|
||||
yield call(runUserCode, null, previewTimeout);
|
||||
const out = yield call(runUserCode, null, previewTimeout);
|
||||
|
||||
if (out)
|
||||
yield put(updateConsole(out.logs?.map(log => log.msg).join('\n')));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { challengeTypes } from '../../../../../shared/config/challenge-types';
|
||||
import frameRunnerData from '../../../../../client/config/browser-scripts/frame-runner.json';
|
||||
import jsTestEvaluatorData from '../../../../../client/config/browser-scripts/test-evaluator.json';
|
||||
import pyTestEvaluatorData from '../../../../../client/config/browser-scripts/python-test-evaluator.json';
|
||||
|
||||
import type { ChallengeFile } from '../../../redux/prop-types';
|
||||
import { concatHtml } from '../rechallenge/builders';
|
||||
@@ -12,16 +9,14 @@ import {
|
||||
getMultifileJSXTransformers
|
||||
} from '../rechallenge/transformers';
|
||||
import {
|
||||
createTestFramer,
|
||||
runTestInTestFrame,
|
||||
createMainPreviewFramer,
|
||||
createProjectPreviewFramer,
|
||||
ProxyLogger,
|
||||
TestRunnerConfig,
|
||||
Context,
|
||||
Source
|
||||
Source,
|
||||
prepTestRunner
|
||||
} from './frame';
|
||||
import { WorkerExecutor } from './worker-executor';
|
||||
|
||||
interface BuildChallengeData extends Context {
|
||||
challengeType: number;
|
||||
@@ -38,19 +33,6 @@ interface BuildOptions {
|
||||
usesTestRunner?: boolean;
|
||||
}
|
||||
|
||||
const { filename: jsTestEvaluator } = jsTestEvaluatorData;
|
||||
const { filename: pyTestEvaluator } = pyTestEvaluatorData;
|
||||
|
||||
const frameRunnerSrc = `/js/${frameRunnerData.filename}.js`;
|
||||
|
||||
const pythonWorkerExecutor = new WorkerExecutor(pyTestEvaluator, {
|
||||
terminateWorker: false,
|
||||
maxWorkers: 1
|
||||
});
|
||||
const jsWorkerExecutor = new WorkerExecutor(jsTestEvaluator, {
|
||||
terminateWorker: true
|
||||
});
|
||||
|
||||
type ApplyFunctionProps = (
|
||||
file: ChallengeFile
|
||||
) => Promise<ChallengeFile> | ChallengeFile;
|
||||
@@ -127,91 +109,35 @@ export async function buildChallenge(
|
||||
throw new Error(`Cannot build challenge of type ${challengeType}`);
|
||||
}
|
||||
|
||||
const testRunners = {
|
||||
[challengeTypes.js]: getJSTestRunner,
|
||||
[challengeTypes.html]: getDOMTestRunner,
|
||||
[challengeTypes.backend]: getDOMTestRunner,
|
||||
[challengeTypes.pythonProject]: getDOMTestRunner,
|
||||
[challengeTypes.python]: getPyTestRunner,
|
||||
[challengeTypes.multifileCertProject]: getDOMTestRunner,
|
||||
[challengeTypes.multifilePythonCertProject]: getPyTestRunner,
|
||||
[challengeTypes.lab]: getDOMTestRunner,
|
||||
[challengeTypes.pyLab]: getPyTestRunner,
|
||||
[challengeTypes.dailyChallengeJs]: getJSTestRunner,
|
||||
[challengeTypes.dailyChallengePy]: getPyTestRunner
|
||||
export const runnerTypes: Record<number, 'javascript' | 'dom' | 'python'> = {
|
||||
[challengeTypes.js]: 'javascript',
|
||||
[challengeTypes.html]: 'dom',
|
||||
[challengeTypes.backend]: 'dom',
|
||||
[challengeTypes.jsProject]: 'javascript',
|
||||
[challengeTypes.pythonProject]: 'python',
|
||||
[challengeTypes.python]: 'python',
|
||||
[challengeTypes.modern]: 'dom',
|
||||
[challengeTypes.multifileCertProject]: 'dom',
|
||||
[challengeTypes.multifilePythonCertProject]: 'python',
|
||||
[challengeTypes.lab]: 'dom',
|
||||
[challengeTypes.jsLab]: 'javascript',
|
||||
[challengeTypes.pyLab]: 'python',
|
||||
[challengeTypes.dailyChallengeJs]: 'javascript',
|
||||
[challengeTypes.dailyChallengePy]: 'python'
|
||||
};
|
||||
|
||||
export function getTestRunner(
|
||||
buildData: BuildChallengeData,
|
||||
runnerConfig: TestRunnerConfig,
|
||||
document: Document
|
||||
) {
|
||||
export async function getTestRunner(buildData: BuildChallengeData) {
|
||||
const { challengeType } = buildData;
|
||||
const testRunner = testRunners[challengeType];
|
||||
if (testRunner) {
|
||||
return testRunner(buildData, runnerConfig, document);
|
||||
const type = runnerTypes[challengeType];
|
||||
if (!type) {
|
||||
throw new Error(
|
||||
`Cannot get test runner for challenge type ${challengeType}`
|
||||
);
|
||||
}
|
||||
throw new Error(`Cannot get test runner for challenge type ${challengeType}`);
|
||||
}
|
||||
await prepTestRunner({ ...buildData, type });
|
||||
|
||||
function getJSTestRunner(
|
||||
{ build, sources }: BuildChallengeData,
|
||||
{ proxyLogger }: TestRunnerConfig
|
||||
) {
|
||||
return getWorkerTestRunner(
|
||||
{ build, sources },
|
||||
{ proxyLogger },
|
||||
jsWorkerExecutor
|
||||
);
|
||||
}
|
||||
|
||||
function getPyTestRunner(
|
||||
{ build, sources }: BuildChallengeData,
|
||||
{ proxyLogger }: TestRunnerConfig
|
||||
) {
|
||||
return getWorkerTestRunner(
|
||||
{ build, sources },
|
||||
{ proxyLogger },
|
||||
pythonWorkerExecutor
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkerTestRunner(
|
||||
{ build, sources }: Pick<BuildChallengeData, 'build' | 'sources'>,
|
||||
{ proxyLogger }: TestRunnerConfig,
|
||||
workerExecutor: WorkerExecutor
|
||||
) {
|
||||
const code = {
|
||||
contents: sources.index,
|
||||
editableContents: sources.editableContents
|
||||
};
|
||||
|
||||
interface TestWorkerExecutor extends WorkerExecutor {
|
||||
on: (event: string, listener: (...args: string[]) => void) => void;
|
||||
done: () => void;
|
||||
}
|
||||
|
||||
return (testString: string, testTimeout: number, firstTest = true) => {
|
||||
const result = workerExecutor.execute(
|
||||
{ build, testString, code, sources, firstTest },
|
||||
testTimeout
|
||||
) as TestWorkerExecutor;
|
||||
|
||||
result.on('LOG', proxyLogger);
|
||||
return result.done;
|
||||
};
|
||||
}
|
||||
|
||||
async function getDOMTestRunner(
|
||||
buildData: BuildChallengeData,
|
||||
{ proxyLogger }: TestRunnerConfig,
|
||||
document: Document
|
||||
) {
|
||||
await new Promise<void>(resolve =>
|
||||
createTestFramer(document, proxyLogger, resolve)(buildData)
|
||||
);
|
||||
return (testString: string, testTimeout: number) =>
|
||||
runTestInTestFrame(document, testString, testTimeout);
|
||||
runTestInTestFrame(testString, testTimeout, type);
|
||||
}
|
||||
|
||||
type BuildResult = {
|
||||
@@ -226,7 +152,12 @@ type BuildResult = {
|
||||
// abstraction (function, class, whatever) and then create the various functions
|
||||
// out of it.
|
||||
export async function buildDOMChallenge(
|
||||
{ challengeFiles, required = [], template = '' }: BuildChallengeData,
|
||||
{
|
||||
challengeFiles,
|
||||
required = [],
|
||||
template = '',
|
||||
challengeType
|
||||
}: BuildChallengeData,
|
||||
options?: BuildOptions
|
||||
): Promise<BuildResult> {
|
||||
// TODO: make this required in the schema.
|
||||
@@ -247,7 +178,6 @@ export async function buildDOMChallenge(
|
||||
: getTransformers(options)) as unknown as ApplyFunctionProps[];
|
||||
|
||||
const pipeLine = composeFunctions(...transformers);
|
||||
const usesTestRunner = options?.usesTestRunner ?? false;
|
||||
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
|
||||
const error = finalFiles.find(({ error }) => error)?.error;
|
||||
const contents = (await embedFilesInHtml(finalFiles)) as string;
|
||||
@@ -255,18 +185,15 @@ export async function buildDOMChallenge(
|
||||
// if there is an error, we just build the test runner so that it can be
|
||||
// used to run tests against the code without actually running the code.
|
||||
const toBuild = error
|
||||
? { ...(usesTestRunner && { testRunner: frameRunnerSrc }) }
|
||||
? {}
|
||||
: {
|
||||
required,
|
||||
template,
|
||||
contents,
|
||||
...(usesTestRunner && { testRunner: frameRunnerSrc })
|
||||
contents
|
||||
};
|
||||
|
||||
return {
|
||||
// TODO: Stop overwriting challengeType with 'html'. Figure out why it's
|
||||
// necessary at the moment.
|
||||
challengeType: challengeTypes.html,
|
||||
challengeType,
|
||||
build: concatHtml(toBuild),
|
||||
sources: buildSourceMap(finalFiles),
|
||||
loadEnzyme: requiresReact16,
|
||||
@@ -275,7 +202,10 @@ export async function buildDOMChallenge(
|
||||
}
|
||||
|
||||
export async function buildJSChallenge(
|
||||
{ challengeFiles }: { challengeFiles?: ChallengeFile[] },
|
||||
{
|
||||
challengeFiles,
|
||||
challengeType
|
||||
}: { challengeFiles?: ChallengeFile[]; challengeType: number },
|
||||
options: BuildOptions
|
||||
): Promise<BuildResult> {
|
||||
if (!challengeFiles) throw Error('No challenge files provided');
|
||||
@@ -289,7 +219,7 @@ export async function buildJSChallenge(
|
||||
const toBuild = error ? [] : finalFiles;
|
||||
|
||||
return {
|
||||
challengeType: challengeTypes.js,
|
||||
challengeType,
|
||||
build: toBuild
|
||||
.reduce(
|
||||
(body, challengeFile) => [
|
||||
@@ -306,16 +236,17 @@ export async function buildJSChallenge(
|
||||
};
|
||||
}
|
||||
|
||||
function buildBackendChallenge({ url }: BuildChallengeData) {
|
||||
function buildBackendChallenge({ url, challengeType }: BuildChallengeData) {
|
||||
return {
|
||||
challengeType: challengeTypes.backend,
|
||||
build: concatHtml({ testRunner: frameRunnerSrc }),
|
||||
challengeType,
|
||||
build: '',
|
||||
sources: { contents: url }
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPythonChallenge({
|
||||
challengeFiles
|
||||
challengeFiles,
|
||||
challengeType
|
||||
}: BuildChallengeData): Promise<BuildResult> {
|
||||
if (!challengeFiles) throw new Error('No challenge files provided');
|
||||
const pipeLine = composeFunctions(
|
||||
@@ -323,13 +254,12 @@ export async function buildPythonChallenge({
|
||||
);
|
||||
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
|
||||
const error = finalFiles.find(({ error }) => error)?.error;
|
||||
const sources = buildSourceMap(finalFiles);
|
||||
|
||||
return {
|
||||
challengeType:
|
||||
challengeFiles[0].editableRegionBoundaries?.length === 0
|
||||
? challengeTypes.multifilePythonCertProject
|
||||
: challengeTypes.python,
|
||||
sources: buildSourceMap(finalFiles),
|
||||
challengeType,
|
||||
sources,
|
||||
build: sources?.contents,
|
||||
error
|
||||
};
|
||||
}
|
||||
@@ -339,16 +269,7 @@ export function updatePreview(
|
||||
document: Document,
|
||||
proxyLogger: ProxyLogger
|
||||
): Promise<void> {
|
||||
// TODO: either create a 'buildType' or use the real challengeType here
|
||||
// (buildData.challengeType is set to 'html' for challenges that can be
|
||||
// previewed, hence this being true for python challenges, multifile steps and
|
||||
// so on).
|
||||
|
||||
if (
|
||||
buildData.challengeType === challengeTypes.html ||
|
||||
buildData.challengeType === challengeTypes.multifileCertProject ||
|
||||
buildData.challengeType === challengeTypes.lab
|
||||
) {
|
||||
if (challengeHasPreview(buildData)) {
|
||||
return new Promise<void>(resolve =>
|
||||
createMainPreviewFramer(
|
||||
document,
|
||||
@@ -379,11 +300,7 @@ export function updateProjectPreview(
|
||||
buildData: BuildChallengeData,
|
||||
document: Document
|
||||
): void {
|
||||
if (
|
||||
buildData.challengeType === challengeTypes.html ||
|
||||
buildData.challengeType === challengeTypes.multifileCertProject ||
|
||||
buildData.challengeType === challengeTypes.lab
|
||||
) {
|
||||
if (challengeHasPreview(buildData)) {
|
||||
createProjectPreviewFramer(
|
||||
document,
|
||||
getDocumentTitle(buildData)
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { flow } from 'lodash-es';
|
||||
import i18next, { type i18n } from 'i18next';
|
||||
|
||||
import {
|
||||
version as _helperVersion,
|
||||
type FCCTestRunner
|
||||
} from '../../../../../tools/client-plugins/browser-scripts/test-runner';
|
||||
|
||||
import { format } from '../../../utils/format';
|
||||
import type {
|
||||
FrameDocument,
|
||||
PythonDocument
|
||||
} from '../../../../../tools/client-plugins/browser-scripts';
|
||||
|
||||
export const helperVersion = _helperVersion;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
FCCTestRunner: FCCTestRunner;
|
||||
}
|
||||
}
|
||||
|
||||
const utilsFormat: <T>(x: T) => string = format;
|
||||
|
||||
export interface Source {
|
||||
@@ -30,11 +43,8 @@ export interface Context {
|
||||
build: string;
|
||||
sources: Source;
|
||||
hooks?: Hooks;
|
||||
loadEnzyme?: () => void;
|
||||
}
|
||||
|
||||
export interface TestRunnerConfig {
|
||||
proxyLogger: ProxyLogger;
|
||||
type: 'dom' | 'javascript' | 'python';
|
||||
loadEnzyme?: boolean;
|
||||
}
|
||||
|
||||
export type ProxyLogger = (msg: string) => void;
|
||||
@@ -76,10 +86,9 @@ export const scrollManager = new ScrollManager();
|
||||
// we use two different frames to make them all essentially pure functions
|
||||
// main iframe is responsible rendering the preview and is where we proxy the
|
||||
export const mainPreviewId = 'fcc-main-frame';
|
||||
// the test frame is responsible for running the assert tests
|
||||
export const testId = 'fcc-test-frame';
|
||||
// the project preview frame demos the finished project
|
||||
export const projectPreviewId = 'fcc-project-preview-frame';
|
||||
const ASSET_PATH = `/js/test-runner/${helperVersion}/`;
|
||||
|
||||
const DOCUMENT_NOT_FOUND_ERROR = 'misc.document-notfound';
|
||||
|
||||
@@ -149,14 +158,6 @@ const createHeader = (id = mainPreviewId) =>
|
||||
</script>
|
||||
`;
|
||||
|
||||
const createBeforeAllScript = (beforeAll?: string) => {
|
||||
if (!beforeAll) return '';
|
||||
|
||||
return `<script>
|
||||
${beforeAll};
|
||||
</script>`;
|
||||
};
|
||||
|
||||
type TestResult =
|
||||
| { pass: boolean }
|
||||
| { err: { message: string; stack?: string } };
|
||||
@@ -172,20 +173,43 @@ function getContentDocument<T extends Document = FrameDocument>(
|
||||
}
|
||||
|
||||
export const runTestInTestFrame = async function (
|
||||
document: Document,
|
||||
test: string,
|
||||
timeout: number
|
||||
timeout: number,
|
||||
type: 'dom' | 'javascript' | 'python'
|
||||
): Promise<TestResult | undefined> {
|
||||
const contentDocument = getContentDocument(document, testId);
|
||||
if (contentDocument) {
|
||||
return await Promise.race([
|
||||
new Promise<
|
||||
{ pass: boolean } | { err: { message: string; stack?: string } }
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
>((_, reject) => setTimeout(() => reject('timeout'), timeout)),
|
||||
contentDocument.__runTest(test)
|
||||
]);
|
||||
}
|
||||
const runner = window?.FCCTestRunner.getRunner(type);
|
||||
|
||||
return await Promise.race([
|
||||
new Promise<
|
||||
{ pass: boolean } | { err: { message: string; stack?: string } }
|
||||
>((_, reject) => setTimeout(() => reject(Error('timeout')), timeout)),
|
||||
runner?.runTest(test)
|
||||
]);
|
||||
};
|
||||
|
||||
export const prepTestRunner = async ({
|
||||
sources,
|
||||
loadEnzyme,
|
||||
build,
|
||||
hooks,
|
||||
type
|
||||
}: {
|
||||
sources: Source;
|
||||
loadEnzyme?: boolean;
|
||||
build: string;
|
||||
hooks?: Hooks;
|
||||
type: 'dom' | 'javascript' | 'python';
|
||||
}) => {
|
||||
const source = type === 'dom' ? prefixDoctype({ build, sources }) : build;
|
||||
await loadTestRunner(document);
|
||||
await window?.FCCTestRunner.createTestRunner({
|
||||
type,
|
||||
code: sources,
|
||||
source,
|
||||
assetPath: ASSET_PATH,
|
||||
hooks,
|
||||
loadEnzyme
|
||||
});
|
||||
};
|
||||
|
||||
export const runPythonInFrame = function (
|
||||
@@ -200,10 +224,49 @@ export const runPythonInFrame = function (
|
||||
void contentDocument?.__runPython(code);
|
||||
};
|
||||
|
||||
const TEST_RUNNER_ID = 'fcc-test-runner';
|
||||
const createRunnerScript = (document: Document) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = ASSET_PATH + 'index.js';
|
||||
script.id = TEST_RUNNER_ID;
|
||||
return script;
|
||||
};
|
||||
|
||||
const loadTestRunner = async (document: Document) => {
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const alreadyLoaded = !!window?.FCCTestRunner;
|
||||
|
||||
if (alreadyLoaded) return resolve();
|
||||
|
||||
const script =
|
||||
document.getElementById(TEST_RUNNER_ID) ?? createRunnerScript(document);
|
||||
|
||||
const errorListener = (err: ErrorEvent) => {
|
||||
console.error(err);
|
||||
reject(new Error('Test runner failed to load'));
|
||||
};
|
||||
|
||||
script.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
// Since it's loaded, we no longer need to listen for errors
|
||||
script.removeEventListener('error', errorListener);
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
script.addEventListener('error', errorListener, { once: true });
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return done;
|
||||
};
|
||||
|
||||
const createFrame =
|
||||
(document: Document, id: string, title?: string) =>
|
||||
(frameContext: Context) => {
|
||||
const frame = document.createElement('iframe');
|
||||
|
||||
frame.srcdoc = createContent(id, frameContext);
|
||||
frame.id = id;
|
||||
if (typeof title === 'string') {
|
||||
@@ -216,37 +279,20 @@ const createFrame =
|
||||
};
|
||||
};
|
||||
|
||||
const hiddenFrameClassName = 'hide-test-frame';
|
||||
const mountFrame =
|
||||
(document: Document, id: string) => (frameContext: Context) => {
|
||||
const { element }: { element: HTMLIFrameElement } = frameContext;
|
||||
const oldFrame = document.getElementById(element.id) as HTMLIFrameElement;
|
||||
if (oldFrame) {
|
||||
element.className = oldFrame.className || hiddenFrameClassName;
|
||||
oldFrame.parentNode!.replaceChild(element, oldFrame);
|
||||
// only test frames can be added (and hidden) here, other frames must be
|
||||
// added by react
|
||||
} else if (id === testId) {
|
||||
element.className = hiddenFrameClassName;
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
return {
|
||||
...frameContext,
|
||||
element,
|
||||
window: element.contentWindow
|
||||
};
|
||||
};
|
||||
|
||||
// Tests should not use functions that directly interact with the user, so
|
||||
// they're overridden. If tests need to spy on these functions, they can supply
|
||||
// the spy themselves.
|
||||
const overrideUserInteractions = (frameContext: Context) => {
|
||||
if (frameContext.window) {
|
||||
frameContext.window.prompt = () => null;
|
||||
frameContext.window.alert = () => {};
|
||||
frameContext.window.confirm = () => false;
|
||||
const mountFrame = (document: Document) => (frameContext: Context) => {
|
||||
const { element }: { element: HTMLIFrameElement } = frameContext;
|
||||
const oldFrame = document.getElementById(element.id) as HTMLIFrameElement;
|
||||
if (oldFrame) {
|
||||
element.className = oldFrame.className;
|
||||
oldFrame.parentNode!.replaceChild(element, oldFrame);
|
||||
// only test frames can be added (and hidden) here, other frames must be
|
||||
// added by react
|
||||
}
|
||||
return frameContext;
|
||||
return {
|
||||
...frameContext,
|
||||
element,
|
||||
window: element.contentWindow
|
||||
};
|
||||
};
|
||||
|
||||
const noop = <T>(x: T) => x;
|
||||
@@ -310,21 +356,6 @@ const updateWindowI18next = (frameContext: Context) => {
|
||||
return frameContext;
|
||||
};
|
||||
|
||||
const initTestFrame = (frameReady?: () => void) => (frameContext: Context) => {
|
||||
waitForFrame(frameContext)
|
||||
.then(async () => {
|
||||
const { sources, loadEnzyme } = frameContext;
|
||||
await frameContext.window?.document?.__initTestFrame({
|
||||
code: sources,
|
||||
loadEnzyme
|
||||
});
|
||||
|
||||
if (frameReady) frameReady();
|
||||
})
|
||||
.catch(handleDocumentNotFound);
|
||||
return frameContext;
|
||||
};
|
||||
|
||||
const initMainFrame =
|
||||
(frameReady?: () => void, proxyLogger?: ProxyLogger) =>
|
||||
(frameContext: Context) => {
|
||||
@@ -386,16 +417,24 @@ const waitForFrame = (frameContext: Context) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const createContent = (
|
||||
id: string,
|
||||
{ build, sources, hooks }: { build: string; sources: Source; hooks?: Hooks }
|
||||
) => {
|
||||
export const prefixDoctype = ({
|
||||
build,
|
||||
sources
|
||||
}: {
|
||||
build: string;
|
||||
sources: Source;
|
||||
}) => {
|
||||
// DOCTYPE should be the first thing written to the frame, so if the user code
|
||||
// includes a DOCTYPE declaration, we need to find it and write it first.
|
||||
const doctype = sources.contents?.match(/^<!DOCTYPE html>/i)?.[0] || '';
|
||||
return (
|
||||
doctype + createBeforeAllScript(hooks?.beforeAll) + createHeader(id) + build
|
||||
);
|
||||
return doctype + build;
|
||||
};
|
||||
|
||||
const createContent = (
|
||||
id: string,
|
||||
{ build, sources }: { build: string; sources: Source; hooks?: Hooks }
|
||||
) => {
|
||||
return prefixDoctype({ build: createHeader(id) + build, sources });
|
||||
};
|
||||
|
||||
const restoreScrollPosition = (frameContext: Context) => {
|
||||
@@ -433,20 +472,6 @@ export const createProjectPreviewFramer = (
|
||||
frameTitle
|
||||
});
|
||||
|
||||
export const createTestFramer = (
|
||||
document: Document,
|
||||
proxyLogger: ProxyLogger,
|
||||
frameReady: () => void
|
||||
): ((args: Context) => void) =>
|
||||
createFramer({
|
||||
document,
|
||||
id: testId,
|
||||
init: initTestFrame,
|
||||
proxyLogger,
|
||||
frameReady,
|
||||
updateWindowFunctions: overrideUserInteractions
|
||||
});
|
||||
|
||||
const createFramer = ({
|
||||
document,
|
||||
id,
|
||||
@@ -466,7 +491,7 @@ const createFramer = ({
|
||||
}) =>
|
||||
flow(
|
||||
createFrame(document, id, frameTitle),
|
||||
mountFrame(document, id),
|
||||
mountFrame(document),
|
||||
updateWindowFunctions ?? noop,
|
||||
updateProxyConsole(proxyLogger),
|
||||
updateWindowI18next,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"lib": ["WebWorker", "DOM", "DOM.Iterable", "es2023"],
|
||||
"target": "ES2023",
|
||||
"module": "es2020",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"allowJs": true,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
|
||||
@@ -46,16 +46,6 @@ Your `h1` element's text should be `CatPhotoApp`. You have either omitted the te
|
||||
assert(document.querySelector('h1').innerText.toLowerCase() === 'catphotoapp');
|
||||
```
|
||||
|
||||
You appear to be using a browser extension that is modifying the page. Be sure to turn off all browser extensions.
|
||||
|
||||
```js
|
||||
if(__checkForBrowserExtensions){
|
||||
assert.isAtMost(document.querySelectorAll('script').length, 2);
|
||||
assert.equal(document.querySelectorAll('style').length, 1);
|
||||
assert.equal(document.querySelectorAll('link').length, 0);
|
||||
}
|
||||
```
|
||||
|
||||
# --seed--
|
||||
|
||||
## --seed-contents--
|
||||
|
||||
@@ -20,7 +20,7 @@ Now you can start writing your JavaScript. Begin by creating a `script` element.
|
||||
You should have a `script` element.
|
||||
|
||||
```js
|
||||
assert.isAtLeast(document.querySelectorAll('script').length, 2);
|
||||
assert.isAtLeast(document.querySelectorAll('script').length, 1);
|
||||
```
|
||||
|
||||
Your `script` element should have an opening tag.
|
||||
|
||||
@@ -24,12 +24,8 @@ Your `script` element should come at the end of your `body` element.
|
||||
|
||||
```js
|
||||
const script = document.querySelector('script[data-src$="script.js"]');
|
||||
assert.equal(script.previousElementSibling.tagName, "DIV");
|
||||
// When building the test frame, the runner script is always inserted after user
|
||||
// code. This means the learner's script should be the penultimate element in
|
||||
// the body.
|
||||
assert.equal(script.nextElementSibling.id, "fcc-test-runner");
|
||||
assert.equal(script.parentElement.tagName, "BODY");
|
||||
const lastChild = document.querySelector('body').lastElementChild;
|
||||
assert.equal(script, lastChild);
|
||||
```
|
||||
|
||||
# --seed--
|
||||
|
||||
@@ -848,7 +848,7 @@ assert.equal(italics[0].innerText, "quote");
|
||||
You should have only one `script` element in your HTML.
|
||||
|
||||
```js
|
||||
assert.lengthOf(document.querySelectorAll("script"), 3);
|
||||
assert.lengthOf(document.querySelectorAll("script"), 1);
|
||||
```
|
||||
|
||||
# --seed--
|
||||
|
||||
@@ -32,7 +32,7 @@ assert.equal(document.querySelector('body :first-child').tagName, 'MAIN');
|
||||
Your `main` element should be the only child of the `body` element.
|
||||
|
||||
```js
|
||||
assert.equal(document.querySelector('body').children.length, 2);
|
||||
assert.equal(document.querySelector('body').children.length, 1);
|
||||
```
|
||||
|
||||
You should have an `h1` element with the topic of your page inside the `main` element.
|
||||
|
||||
@@ -38,15 +38,6 @@ Your `h1` element's text should be `CatPhotoApp`. You have either omitted the te
|
||||
assert.equal(document.querySelector('h1')?.innerText.toLowerCase(), 'catphotoapp');
|
||||
```
|
||||
|
||||
You appear to be using a browser extension that is modifying the page. Be sure to turn off all browser extensions.
|
||||
|
||||
```js
|
||||
if(__checkForBrowserExtensions){
|
||||
assert.isAtMost(document.querySelectorAll('script').length, 2);
|
||||
assert.equal(document.querySelectorAll('style').length, 1);
|
||||
assert.equal(document.querySelectorAll('link').length, 0);
|
||||
}
|
||||
```
|
||||
|
||||
# --seed--
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Next, you will start working on the `JavaScript`. For that, begin by linking the
|
||||
You should create a `script` element.
|
||||
|
||||
```js
|
||||
assert.lengthOf(document.querySelectorAll('script'), 3);
|
||||
assert.lengthOf(document.querySelectorAll('script'), 1);
|
||||
```
|
||||
|
||||
Your `script` element should have a `src` attribute set to `script.js`.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<body>
|
||||
<script type="module" src="dist/index.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -23,32 +23,20 @@ require('@babel/register')({
|
||||
only: [clientPath]
|
||||
});
|
||||
const {
|
||||
buildDOMChallenge,
|
||||
buildPythonChallenge,
|
||||
buildChallenge,
|
||||
buildFunctions
|
||||
runnerTypes
|
||||
} = require('../../client/src/templates/Challenges/utils/build');
|
||||
const {
|
||||
WorkerExecutor
|
||||
} = require('../../client/src/templates/Challenges/utils/worker-executor');
|
||||
const {
|
||||
challengeTypes,
|
||||
hasNoSolution
|
||||
} = require('../../shared/config/challenge-types');
|
||||
// the config files are created during the build, but not before linting
|
||||
const javaScriptTestEvaluator =
|
||||
require('../../client/config/browser-scripts/test-evaluator.json').filename;
|
||||
const pythonTestEvaluator =
|
||||
require('../../client/config/browser-scripts/python-test-evaluator.json').filename;
|
||||
|
||||
const { getLines } = require('../../shared/utils/get-lines');
|
||||
|
||||
const { getChallengesForLang, getMetaForBlock } = require('../get-challenges');
|
||||
const { challengeSchemaValidator } = require('../schema/challenge-schema');
|
||||
const { testedLang, getSuperOrder } = require('../utils');
|
||||
const {
|
||||
createContent,
|
||||
testId
|
||||
prefixDoctype,
|
||||
helperVersion
|
||||
} = require('../../client/src/templates/Challenges/utils/frame');
|
||||
const { chapterBasedSuperBlocks } = require('../../shared/config/curriculum');
|
||||
const ChallengeTitles = require('./utils/challenge-titles');
|
||||
@@ -135,8 +123,6 @@ spinner.text = 'Populate tests.';
|
||||
|
||||
let browser;
|
||||
let page;
|
||||
// This worker can be reused since it clears its environment between tests.
|
||||
let pythonWorker;
|
||||
|
||||
setup()
|
||||
.then(runTests)
|
||||
@@ -148,7 +134,13 @@ async function setup() {
|
||||
host: '127.0.0.1',
|
||||
port: '8080',
|
||||
root: path.resolve(__dirname, 'stubs'),
|
||||
mount: [['/js', path.join(clientPath, 'static/js')]],
|
||||
mount: [
|
||||
[
|
||||
'/dist',
|
||||
path.join(clientPath, `static/js/test-runner/${helperVersion}`)
|
||||
],
|
||||
['/js', path.join(clientPath, 'static/js')]
|
||||
],
|
||||
open: false,
|
||||
logLevel: 0
|
||||
});
|
||||
@@ -166,9 +158,6 @@ async function setup() {
|
||||
});
|
||||
global.Worker = createPseudoWorker(await newPageContext(browser));
|
||||
|
||||
pythonWorker = new WorkerExecutor(pythonTestEvaluator, {
|
||||
terminateWorker: false
|
||||
});
|
||||
page = await newPageContext(browser);
|
||||
await page.setViewport({ width: 300, height: 150 });
|
||||
|
||||
@@ -401,7 +390,11 @@ function populateTestsForLang({ lang, challenges, meta, superBlocks }) {
|
||||
challenge.challengeFiles,
|
||||
buildChallenge
|
||||
);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error creating test runner for initial contents`
|
||||
);
|
||||
console.error(e);
|
||||
fails = true;
|
||||
}
|
||||
if (!fails) {
|
||||
@@ -540,27 +533,15 @@ async function createTestRunner(
|
||||
{ usesTestRunner: true }
|
||||
);
|
||||
|
||||
const code = {
|
||||
contents: sources.index,
|
||||
editableContents: sources.editableContents
|
||||
};
|
||||
|
||||
const buildFunction = buildFunctions[challenge.challengeType];
|
||||
|
||||
const runsInBrowser = buildFunction === buildDOMChallenge;
|
||||
const runsInPythonWorker = buildFunction === buildPythonChallenge;
|
||||
|
||||
const evaluator = await (runsInBrowser
|
||||
? getContextEvaluator({
|
||||
// passing in challengeId so it's easier to debug timeouts
|
||||
challengeId: challenge.id,
|
||||
build,
|
||||
sources,
|
||||
code,
|
||||
loadEnzyme,
|
||||
hooks: challenge.hooks
|
||||
})
|
||||
: getWorkerEvaluator({ build, sources, code, runsInPythonWorker }));
|
||||
const evaluator = await getContextEvaluator({
|
||||
// passing in challengeId so it's easier to debug timeouts
|
||||
challengeId: challenge.id,
|
||||
build,
|
||||
sources,
|
||||
type: runnerTypes[challenge.challengeType],
|
||||
loadEnzyme,
|
||||
hooks: challenge.hooks
|
||||
});
|
||||
|
||||
return async ({ text, testString }) => {
|
||||
try {
|
||||
@@ -569,7 +550,6 @@ async function createTestRunner(
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
// add more info to the error so the failing test can be identified.
|
||||
text = 'Test text: ' + text;
|
||||
const newMessage = solutionFromNext
|
||||
? 'Check next step for solution!\n' + text
|
||||
@@ -625,43 +605,42 @@ ${testString}
|
||||
timeout
|
||||
)
|
||||
),
|
||||
await page.evaluate(async testString => {
|
||||
return await document.__runTest(testString);
|
||||
}, testString)
|
||||
await page.evaluate(
|
||||
async (testString, type) => {
|
||||
return await window.FCCTestRunner.getRunner(type).runTest(
|
||||
testString
|
||||
);
|
||||
},
|
||||
testString,
|
||||
config.type
|
||||
)
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkerEvaluator({
|
||||
async function initializeTestRunner({
|
||||
build,
|
||||
sources,
|
||||
code,
|
||||
runsInPythonWorker
|
||||
type,
|
||||
hooks,
|
||||
loadEnzyme
|
||||
}) {
|
||||
// The python worker clears the globals between tests, so it should be fine
|
||||
// to use the same evaluator for all tests. TODO: check if this is true for
|
||||
// sys, since sys.modules is not being reset.
|
||||
const testWorker = runsInPythonWorker
|
||||
? pythonWorker
|
||||
: new WorkerExecutor(javaScriptTestEvaluator, { terminateWorker: true });
|
||||
return {
|
||||
evaluate: async (testString, timeout) =>
|
||||
await testWorker.execute({ testString, build, code, sources }, timeout)
|
||||
.done
|
||||
};
|
||||
}
|
||||
const source = type === 'dom' ? prefixDoctype({ build, sources }) : build;
|
||||
|
||||
async function initializeTestRunner({ build, sources, loadEnzyme, hooks }) {
|
||||
await page.reload();
|
||||
await page.setContent(createContent(testId, { build, sources, hooks }));
|
||||
await page.evaluate(
|
||||
async (sources, loadEnzyme) => {
|
||||
await document.__initTestFrame({
|
||||
async (sources, source, type, hooks, loadEnzyme) => {
|
||||
await window.FCCTestRunner.createTestRunner({
|
||||
source,
|
||||
type,
|
||||
code: sources,
|
||||
hooks,
|
||||
loadEnzyme
|
||||
});
|
||||
},
|
||||
sources,
|
||||
source,
|
||||
type,
|
||||
hooks,
|
||||
loadEnzyme
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface InsertTextParameters {
|
||||
containerId?: string;
|
||||
isMobile: boolean;
|
||||
text: string;
|
||||
updatesConsole?: boolean;
|
||||
}
|
||||
|
||||
const replaceTextInCodeEditor = async ({
|
||||
@@ -32,12 +33,20 @@ const replaceTextInCodeEditor = async ({
|
||||
browserName,
|
||||
isMobile,
|
||||
text,
|
||||
containerId = 'editor-container-indexhtml'
|
||||
containerId = 'editor-container-indexhtml',
|
||||
updatesConsole = false
|
||||
}: InsertTextParameters) => {
|
||||
await expect(async () => {
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
await getEditors(page).fill(text);
|
||||
await expect(page.getByTestId(containerId)).toContainText(text);
|
||||
if (updatesConsole) {
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).not.toContainText('Your test output will go here');
|
||||
}
|
||||
}).toPass();
|
||||
};
|
||||
|
||||
@@ -151,7 +160,8 @@ test.describe('Challenge Output Component Tests', () => {
|
||||
page,
|
||||
isMobile,
|
||||
text: 'var',
|
||||
containerId: 'editor-container-scriptjs'
|
||||
containerId: 'editor-container-scriptjs',
|
||||
updatesConsole: true
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
@@ -165,20 +175,19 @@ test.describe('Challenge Output Component Tests', () => {
|
||||
).toHaveText(outputTexts.syntaxError);
|
||||
});
|
||||
|
||||
test('should contain reference error output when var is entered in editor', async ({
|
||||
test('should contain a reference error when an undefined var is entered in editor', async ({
|
||||
browserName,
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const referenceErrorRegex =
|
||||
/ReferenceError: (myName is not defined|Can't find variable: myName)/;
|
||||
await focusEditor({ page, isMobile });
|
||||
await replaceTextInCodeEditor({
|
||||
browserName,
|
||||
page,
|
||||
isMobile,
|
||||
text: 'myName',
|
||||
containerId: 'editor-container-scriptjs'
|
||||
containerId: 'editor-container-scriptjs',
|
||||
updatesConsole: true
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
@@ -189,7 +198,7 @@ test.describe('Challenge Output Component Tests', () => {
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toHaveText(referenceErrorRegex);
|
||||
).toContainText('ReferenceError: myName is not defined');
|
||||
});
|
||||
|
||||
test('should contain final output after test fail', async ({
|
||||
@@ -216,7 +225,8 @@ test.describe('Challenge Output Component Tests', () => {
|
||||
page,
|
||||
isMobile,
|
||||
text: 'var myName;',
|
||||
containerId: 'editor-container-scriptjs'
|
||||
containerId: 'editor-container-scriptjs',
|
||||
updatesConsole: true
|
||||
});
|
||||
await runChallengeTest(page, isMobile);
|
||||
await closeButton.click();
|
||||
@@ -284,7 +294,7 @@ test.describe('Custom output for Set and Map', () => {
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toContainText('Set(3) {1, set, 10}');
|
||||
).toContainText(`Set(3) {1, 'set', 10}`);
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
await replaceTextInCodeEditor({
|
||||
@@ -303,6 +313,6 @@ test.describe('Custom output for Set and Map', () => {
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toContainText('Map(2) {1 => one, two => 2}');
|
||||
).toContainText(`Map(2) {1 => 'one', 'two' => 2}`);
|
||||
});
|
||||
});
|
||||
|
||||
1170
pnpm-lock.yaml
generated
1170
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,110 +0,0 @@
|
||||
import jQuery from 'jquery';
|
||||
import * as helpers from '@freecodecamp/curriculum-helpers';
|
||||
|
||||
import type { FrameDocument, FrameWindow, InitTestFrameArg } from '.';
|
||||
|
||||
(window as FrameWindow).$ = jQuery;
|
||||
|
||||
const frameDocument = document as FrameDocument;
|
||||
|
||||
frameDocument.__initTestFrame = initTestFrame;
|
||||
|
||||
async function initTestFrame(e: InitTestFrameArg = { code: {} }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const code = (e.code.contents || '').slice();
|
||||
|
||||
const editableContents = (e.code.editableContents || '').slice();
|
||||
// __testEditable allows test authors to run tests against a transitory dom
|
||||
// element built using only the code in the editable region.
|
||||
const __testEditable = (cb: () => () => unknown) => {
|
||||
const div = frameDocument.createElement('div');
|
||||
div.id = 'editable-only';
|
||||
div.innerHTML = editableContents;
|
||||
frameDocument.body.appendChild(div);
|
||||
const out = cb();
|
||||
frameDocument.body.removeChild(div);
|
||||
return out;
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// Hardcode Deep Freeze dependency
|
||||
const DeepFreeze = (o: Record<string, unknown>) => {
|
||||
Object.freeze(o);
|
||||
Object.getOwnPropertyNames(o).forEach(function (prop) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(o, prop) &&
|
||||
o[prop] !== null &&
|
||||
(typeof o[prop] === 'object' || typeof o[prop] === 'function') &&
|
||||
!Object.isFrozen(o[prop])
|
||||
) {
|
||||
DeepFreeze(o[prop] as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
return o;
|
||||
};
|
||||
|
||||
const { default: chai } = await import(/* webpackChunkName: "chai" */ 'chai');
|
||||
const assert = chai.assert;
|
||||
const __helpers = helpers;
|
||||
const __checkForBrowserExtensions = true;
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
|
||||
let Enzyme;
|
||||
if (e.loadEnzyme) {
|
||||
/* eslint-disable prefer-const */
|
||||
let Adapter16;
|
||||
|
||||
[{ default: Enzyme }, { default: Adapter16 }] = await Promise.all([
|
||||
import(/* webpackChunkName: "enzyme" */ 'enzyme'),
|
||||
import(/* webpackChunkName: "enzyme-adapter" */ 'enzyme-adapter-react-16')
|
||||
]);
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter16() });
|
||||
/* eslint-enable prefer-const */
|
||||
}
|
||||
|
||||
frameDocument.__runTest = async function runTests(testString: string) {
|
||||
// uncomment the following line to inspect
|
||||
// the frame-runner as it runs tests
|
||||
// make sure the dev tools console is open
|
||||
// debugger;
|
||||
try {
|
||||
// eval test string to actual JavaScript
|
||||
// This return can be a function
|
||||
// i.e. function() { assert(true, 'happy coding'); }
|
||||
const testPromise = new Promise((resolve, reject) =>
|
||||
// To avoid race conditions, we have to run the test in a final
|
||||
// frameDocument ready:
|
||||
$(() => {
|
||||
try {
|
||||
const test: unknown = eval(testString);
|
||||
resolve(test);
|
||||
} catch (err) {
|
||||
reject(err as Error);
|
||||
}
|
||||
})
|
||||
);
|
||||
const test = await testPromise;
|
||||
if (typeof test === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await test();
|
||||
}
|
||||
return { pass: true };
|
||||
} catch (err) {
|
||||
if (!(err instanceof chai.AssertionError)) {
|
||||
console.error(err);
|
||||
}
|
||||
// to provide useful debugging information when debugging the tests, we
|
||||
// have to extract the message, stack and, if they exist, expected and
|
||||
// actual before returning
|
||||
return {
|
||||
err: {
|
||||
message: (err as Error).message,
|
||||
stack: (err as Error).stack,
|
||||
expected: (err as { expected?: string }).expected,
|
||||
actual: (err as { actual?: string }).actual
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -29,20 +29,11 @@
|
||||
"@babel/plugin-transform-runtime": "7.23.7",
|
||||
"@babel/preset-env": "7.23.7",
|
||||
"@babel/preset-typescript": "7.23.3",
|
||||
"@freecodecamp/curriculum-helpers": "4.1.0",
|
||||
"@types/chai": "4.3.12",
|
||||
"@types/copy-webpack-plugin": "^8.0.1",
|
||||
"@types/enzyme": "3.10.16",
|
||||
"@types/enzyme-adapter-react-16": "1.0.9",
|
||||
"@types/jquery": "3.5.29",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@typescript/vfs": "^1.6.0",
|
||||
"babel-loader": "8.3.0",
|
||||
"chai": "4.4.1",
|
||||
"copy-webpack-plugin": "9.1.0",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.8",
|
||||
"jquery": "3.7.1",
|
||||
"lodash-es": "4.17.21",
|
||||
"process": "0.11.10",
|
||||
"pyodide": "^0.23.3",
|
||||
@@ -52,6 +43,7 @@
|
||||
"webpack-cli": "4.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@freecodecamp/curriculum-helpers": "^4.4.0",
|
||||
"react": "16",
|
||||
"react-dom": "16",
|
||||
"xterm": "^5.2.1"
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// We have to specify pyodide.js because we need to import that file (not .mjs)
|
||||
// and 'import' defaults to .mjs
|
||||
import { loadPyodide, type PyodideInterface } from 'pyodide/pyodide.js';
|
||||
import type { PyProxy, PythonError } from 'pyodide/ffi';
|
||||
import pkg from 'pyodide/package.json';
|
||||
import * as helpers from '@freecodecamp/curriculum-helpers';
|
||||
import chai from 'chai';
|
||||
|
||||
const ctx: Worker & typeof globalThis = self as unknown as Worker &
|
||||
typeof globalThis;
|
||||
|
||||
let pyodide: PyodideInterface;
|
||||
|
||||
interface PythonRunEvent extends MessageEvent {
|
||||
data: {
|
||||
code: {
|
||||
contents: string;
|
||||
editableContents: string;
|
||||
};
|
||||
firstTest: unknown;
|
||||
testString: string;
|
||||
build: string;
|
||||
sources: {
|
||||
[fileName: string]: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type EvaluatedTeststring = {
|
||||
input: string[];
|
||||
test: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
async function setupPyodide() {
|
||||
if (pyodide) return pyodide;
|
||||
|
||||
pyodide = await loadPyodide({
|
||||
// TODO: host this ourselves
|
||||
indexURL: `https://cdn.jsdelivr.net/pyodide/v${pkg.version}/full/`
|
||||
});
|
||||
|
||||
// We freeze this to prevent learners from getting the worker into a
|
||||
// weird state. NOTE: this has to come after pyodide is loaded, because
|
||||
// pyodide modifies self while loading.
|
||||
Object.freeze(self);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
pyodide.FS.writeFile(
|
||||
'/home/pyodide/ast_helpers.py',
|
||||
helpers.python.astHelpers,
|
||||
{
|
||||
encoding: 'utf8'
|
||||
}
|
||||
);
|
||||
|
||||
ctx.postMessage({ type: 'contentLoaded' });
|
||||
|
||||
return pyodide;
|
||||
}
|
||||
|
||||
void setupPyodide();
|
||||
|
||||
ctx.onmessage = async (e: PythonRunEvent) => {
|
||||
const pyodide = await setupPyodide();
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
const code = (e.data.code.contents || '').slice();
|
||||
const editableContents = (e.data.code.editableContents || '').slice();
|
||||
const testString = e.data.testString;
|
||||
|
||||
const assert = chai.assert;
|
||||
const __helpers = helpers;
|
||||
|
||||
// Create fresh globals for each test
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
const __userGlobals = pyodide.globals.get('dict')() as PyProxy;
|
||||
|
||||
/* eslint-enable @typescript-eslint/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 {
|
||||
// eval test string to get the dummy input and actual test
|
||||
const evaluatedTestString = await new Promise<unknown>(
|
||||
(resolve, reject) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const test: { input: string[]; test: () => Promise<unknown> } =
|
||||
eval(testString);
|
||||
resolve(test);
|
||||
} catch (err) {
|
||||
reject(err as Error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// If the test string does not evaluate to an object, then we assume that
|
||||
// it's a standard JS test and any assertions have already passed.
|
||||
if (typeof evaluatedTestString !== 'object') {
|
||||
ctx.postMessage({ pass: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!evaluatedTestString || !('test' in evaluatedTestString)) {
|
||||
throw new Error(
|
||||
'Test string did not evaluate to an object with the test property'
|
||||
);
|
||||
}
|
||||
|
||||
const { input, test } = evaluatedTestString as EvaluatedTeststring;
|
||||
|
||||
// Some tests rely on __name__ being set to __main__ and we new dicts do not
|
||||
// have this set by default.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
__userGlobals.set('__name__', '__main__');
|
||||
|
||||
// The runPython helper is a shortcut for running python code with our
|
||||
// custom globals.
|
||||
const runPython = (pyCode: string) =>
|
||||
pyodide.runPython(pyCode, { globals: __userGlobals }) as unknown;
|
||||
|
||||
runPython(`
|
||||
def __inputGen(xs):
|
||||
def gen():
|
||||
for x in xs:
|
||||
yield x
|
||||
iter = gen()
|
||||
def input(arg=None):
|
||||
return next(iter)
|
||||
|
||||
return input
|
||||
|
||||
input = __inputGen(${JSON.stringify(input ?? [])})
|
||||
`);
|
||||
|
||||
runPython(`from ast_helpers import Node as _Node`);
|
||||
|
||||
// The tests need the user's code as a string, so we write it to the virtual
|
||||
// filesystem...
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
pyodide.FS.writeFile('/user_code.py', code, { encoding: 'utf8' });
|
||||
|
||||
// ...and then read it back into a variable so that they can evaluate it.
|
||||
runPython(`
|
||||
with open("/user_code.py", "r") as f:
|
||||
_code = f.read()
|
||||
`);
|
||||
|
||||
try {
|
||||
// Evaluates the learner's code so that any variables they define are
|
||||
// available to the test.
|
||||
runPython(code);
|
||||
} catch (e) {
|
||||
const err = e as PythonError;
|
||||
|
||||
// Quite a lot of lessons can easily lead users to write code that has
|
||||
// indentation errors. In these cases we want to provide a more helpful
|
||||
// error message. For other errors, we can just provide the standard
|
||||
// message.
|
||||
const errorType =
|
||||
err.type === 'IndentationError' ? 'indentation' : 'other';
|
||||
return ctx.postMessage({
|
||||
err: {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
errorType
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await test();
|
||||
|
||||
ctx.postMessage({ pass: true });
|
||||
} catch (err) {
|
||||
if (!(err instanceof chai.AssertionError)) {
|
||||
console.error(err);
|
||||
}
|
||||
// to provide useful debugging information when debugging the tests, we
|
||||
// have to extract the message, stack and, if they exist, expected and
|
||||
// actual before returning
|
||||
ctx.postMessage({
|
||||
err: {
|
||||
message: (err as Error).message,
|
||||
stack: (err as Error).stack,
|
||||
expected: (err as { expected?: string }).expected,
|
||||
actual: (err as { actual?: string }).actual
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
__userGlobals.destroy();
|
||||
}
|
||||
};
|
||||
@@ -1,163 +0,0 @@
|
||||
import chai from 'chai';
|
||||
import { toString as __toString } from 'lodash-es';
|
||||
import * as curriculumHelpers from '@freecodecamp/curriculum-helpers';
|
||||
import { format as __format } from './utils/format';
|
||||
|
||||
const ctx: Worker & typeof globalThis = self as unknown as Worker &
|
||||
typeof globalThis;
|
||||
|
||||
const __utils = (() => {
|
||||
const MAX_LOGS_SIZE = 64 * 1024;
|
||||
|
||||
let logs: string[] = [];
|
||||
|
||||
function flushLogs() {
|
||||
if (logs.length) {
|
||||
ctx.postMessage({
|
||||
type: 'LOG',
|
||||
data: logs.join('\n')
|
||||
});
|
||||
logs = [];
|
||||
}
|
||||
}
|
||||
|
||||
function pushLogs(logs: string[], args: string[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
logs.push(args.map(arg => __format(arg)).join(' '));
|
||||
if (logs.join('\n').length > MAX_LOGS_SIZE) {
|
||||
flushLogs();
|
||||
}
|
||||
}
|
||||
|
||||
const oldLog = ctx.console.log.bind(ctx.console);
|
||||
function proxyLog(...args: string[]) {
|
||||
pushLogs(logs, args);
|
||||
return oldLog(...args);
|
||||
}
|
||||
|
||||
const oldInfo = ctx.console.info.bind(ctx.console);
|
||||
function proxyInfo(...args: string[]) {
|
||||
pushLogs(logs, args);
|
||||
return oldInfo(...args);
|
||||
}
|
||||
|
||||
const oldWarn = ctx.console.warn.bind(ctx.console);
|
||||
function proxyWarn(...args: string[]) {
|
||||
pushLogs(logs, args);
|
||||
return oldWarn(...args);
|
||||
}
|
||||
|
||||
const oldError = ctx.console.error.bind(ctx.console);
|
||||
function proxyError(...args: string[]) {
|
||||
pushLogs(logs, args);
|
||||
return oldError(...args);
|
||||
}
|
||||
|
||||
function log(...msgs: Error[]) {
|
||||
if (msgs && msgs[0] && !(msgs[0] instanceof chai.AssertionError)) {
|
||||
// discards the stack trace via toString as it only useful to debug the
|
||||
// site, not a specific challenge.
|
||||
console.log(...msgs.map(msg => msg.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
const toggleProxyLogger = (on: unknown) => {
|
||||
ctx.console.log = on ? proxyLog : oldLog;
|
||||
ctx.console.info = on ? proxyInfo : oldInfo;
|
||||
ctx.console.warn = on ? proxyWarn : oldWarn;
|
||||
ctx.console.error = on ? proxyError : oldError;
|
||||
};
|
||||
|
||||
return {
|
||||
log,
|
||||
toggleProxyLogger,
|
||||
flushLogs
|
||||
};
|
||||
})();
|
||||
|
||||
// We can't simply import these because of how webpack names them when building
|
||||
// the bundle. Since both assert and __helpers have to exist in the global
|
||||
// scope, we have to declare them.
|
||||
const assert = chai.assert;
|
||||
const __helpers = curriculumHelpers;
|
||||
|
||||
// We freeze to prevent learners from getting the tester into a weird
|
||||
// state by modifying these objects.
|
||||
Object.freeze(self);
|
||||
Object.freeze(__utils);
|
||||
Object.freeze(assert);
|
||||
Object.freeze(__helpers);
|
||||
|
||||
interface TestEvaluatorEvent extends MessageEvent {
|
||||
data: {
|
||||
code: {
|
||||
contents: string;
|
||||
editableContents: string;
|
||||
};
|
||||
firstTest: unknown;
|
||||
testString: string;
|
||||
build: string;
|
||||
};
|
||||
}
|
||||
|
||||
/* Run the test if there is one. If not just evaluate the user code */
|
||||
ctx.onmessage = async (e: TestEvaluatorEvent) => {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
const code = e.data?.code?.contents || '';
|
||||
const editableContents = e.data?.code?.editableContents || '';
|
||||
|
||||
// Build errors should be reported, but only once:
|
||||
__utils.toggleProxyLogger(e.data.firstTest);
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
try {
|
||||
// This can be reassigned by the eval inside the try block, so it should be declared as a let
|
||||
// eslint-disable-next-line prefer-const
|
||||
let __userCodeWasExecuted = false;
|
||||
try {
|
||||
// Logging is proxyed after the build to catch console.log messages
|
||||
// generated during testing.
|
||||
await eval(`${e.data.build}
|
||||
__utils.flushLogs();
|
||||
__userCodeWasExecuted = true;
|
||||
__utils.toggleProxyLogger(true);
|
||||
(async () => {${e.data.testString}})()`);
|
||||
} catch (err) {
|
||||
if (__userCodeWasExecuted) {
|
||||
// rethrow error, since test failed.
|
||||
throw err;
|
||||
}
|
||||
// log build errors unless they're related to import/export/require (there
|
||||
// are challenges that use them and they should not trigger warnings)
|
||||
if (
|
||||
(err as Error).name !== 'ReferenceError' ||
|
||||
((err as Error).message !== 'require is not defined' &&
|
||||
(err as Error).message !== 'exports is not defined')
|
||||
) {
|
||||
__utils.log(err as Error);
|
||||
}
|
||||
// the tests may not require working code, so they are evaluated even if
|
||||
// the user code does not get executed.
|
||||
eval(e.data.testString);
|
||||
}
|
||||
__utils.flushLogs();
|
||||
ctx.postMessage({ pass: true });
|
||||
} catch (err) {
|
||||
// Errors from testing go to the browser console only.
|
||||
__utils.toggleProxyLogger(false);
|
||||
// Report execution errors in case user code has errors that are only
|
||||
// uncovered during testing.
|
||||
__utils.log(err as Error);
|
||||
// Now that all logs have been created we can flush them.
|
||||
__utils.flushLogs();
|
||||
ctx.postMessage({
|
||||
err: {
|
||||
message: (err as Error).message,
|
||||
stack: (err as Error).stack,
|
||||
expected: (err as { expected?: string }).expected,
|
||||
actual: (err as { actual?: string }).actual
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ctx.postMessage({ type: 'contentLoaded' });
|
||||
3
tools/client-plugins/browser-scripts/test-runner.ts
Normal file
3
tools/client-plugins/browser-scripts/test-runner.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { FCCTestRunner } from '@freecodecamp/curriculum-helpers/test-runner.js';
|
||||
|
||||
export { version } from '@freecodecamp/curriculum-helpers/package.json';
|
||||
@@ -2,6 +2,9 @@ const { writeFileSync } = require('fs');
|
||||
const path = require('path');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
const {
|
||||
version: helperVersion
|
||||
} = require('@freecodecamp/curriculum-helpers/package.json');
|
||||
|
||||
module.exports = (env = {}) => {
|
||||
const __DEV__ = env.production !== true;
|
||||
@@ -14,11 +17,8 @@ module.exports = (env = {}) => {
|
||||
cache: __DEV__ ? { type: 'filesystem' } : false,
|
||||
mode: __DEV__ ? 'development' : 'production',
|
||||
entry: {
|
||||
'frame-runner': './frame-runner.ts',
|
||||
'sass-compile': './sass-compile.ts',
|
||||
'test-evaluator': './test-evaluator.ts',
|
||||
'python-worker': './python-worker.ts',
|
||||
'python-test-evaluator': './python-test-evaluator.ts',
|
||||
'typescript-worker': './typescript-worker.ts'
|
||||
},
|
||||
devtool: __DEV__ ? 'inline-source-map' : 'source-map',
|
||||
@@ -71,7 +71,11 @@ module.exports = (env = {}) => {
|
||||
patterns: [
|
||||
'./node_modules/sass.js/dist/sass.sync.js',
|
||||
// TODO: copy this into the css folder, not the js folder
|
||||
'./node_modules/xterm/css/xterm.css'
|
||||
'./node_modules/xterm/css/xterm.css',
|
||||
{
|
||||
from: './node_modules/@freecodecamp/curriculum-helpers/dist/test-runner',
|
||||
to: `test-runner/${helperVersion}/`
|
||||
}
|
||||
]
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
|
||||
Reference in New Issue
Block a user