feat(client): integrate new test runner (#60318)

This commit is contained in:
Oliver Eyton-Williams
2025-06-12 09:25:37 +02:00
committed by GitHub
parent 18e2f919c2
commit 49fbe88369
23 changed files with 922 additions and 1393 deletions

View File

@@ -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": [

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
"lib": ["WebWorker", "DOM", "DOM.Iterable", "es2023"],
"target": "ES2023",
"module": "es2020",
"moduleResolution": "node",
"moduleResolution": "bundler",
"allowJs": true,
"jsx": "react",
"strict": true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`.

View File

@@ -0,0 +1,3 @@
<body>
<script type="module" src="dist/index.js"></script>
</body>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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();
}
};

View File

@@ -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' });

View File

@@ -0,0 +1,3 @@
export type { FCCTestRunner } from '@freecodecamp/curriculum-helpers/test-runner.js';
export { version } from '@freecodecamp/curriculum-helpers/package.json';

View File

@@ -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({