refactor: move challenge build outside client (#65513)

This commit is contained in:
Oliver Eyton-Williams
2026-01-27 05:53:51 +01:00
committed by GitHub
parent c8b21dfc4a
commit 5ff971687c
20 changed files with 766 additions and 329 deletions
+3 -4
View File
@@ -47,14 +47,13 @@
"@babel/preset-env": "7.23.7",
"@babel/preset-react": "7.23.3",
"@babel/preset-typescript": "7.23.3",
"@babel/standalone": "7.23.7",
"@codesandbox/sandpack-react": "2.6.9",
"@codesandbox/sandpack-themes": "2.0.21",
"@fortawesome/fontawesome-svg-core": "6.7.1",
"@fortawesome/free-brands-svg-icons": "6.7.1",
"@fortawesome/free-solid-svg-icons": "6.7.1",
"@fortawesome/react-fontawesome": "0.2.2",
"@freecodecamp/loop-protect": "3.0.0",
"@freecodecamp/challenge-builder": "workspace:*",
"@freecodecamp/ui": "5.0.1",
"@gatsbyjs/reach-router": "1.3.9",
"@growthbook/growthbook-react": "1.6.0",
@@ -141,10 +140,10 @@
},
"devDependencies": {
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@freecodecamp/browser-scripts": "workspace:*",
"@freecodecamp/curriculum": "workspace:*",
"@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/shared": "workspace:*",
"@freecodecamp/curriculum": "workspace:*",
"@freecodecamp/browser-scripts": "workspace:*",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
@@ -1,6 +1,8 @@
import React from 'react';
import { runSaga } from 'redux-saga';
import { describe, test, it, expect, beforeEach, vi, Mock } from 'vitest';
import { buildChallenge } from '@freecodecamp/challenge-builder/build';
import { render } from '../../../../utils/test-utils';
import { getCompletedPercentage } from '../../../utils/get-completion-percentage';
@@ -14,7 +16,7 @@ import {
isBuildEnabledSelector,
isBlockNewlyCompletedSelector
} from '../redux/selectors';
import { buildChallenge, getTestRunner } from '../utils/build';
import { getTestRunner } from '../utils/build';
import CompletionModal, { combineFileData } from './completion-modal';
vi.mock('../../../analytics');
vi.mock('../../../utils/fire-confetti');
@@ -22,6 +24,7 @@ vi.mock('../../../components/Progress');
vi.mock('../redux/selectors');
vi.mock('../utils/build');
vi.mock('../../../utils/get-words');
vi.mock('@freecodecamp/challenge-builder/build');
const mockFireConfetti = fireConfetti as Mock;
const mockTestRunner = vi.fn().mockReturnValue({ pass: true });
const mockBuildEnabledSelector = isBuildEnabledSelector as Mock;
@@ -15,6 +15,8 @@ import {
} from 'redux-saga/effects';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import { buildChallenge } from '@freecodecamp/challenge-builder/build';
import { createFlashMessage } from '../../../components/Flash/redux';
import { FlashMessages } from '../../../components/Flash/redux/flash-messages';
import {
@@ -25,7 +27,6 @@ import {
} from '../../../utils/challenge-request-helpers';
import { playTone } from '../../../utils/tone';
import {
buildChallenge,
canBuildChallenge,
challengeHasPreview,
getTestRunner,
+5 -53
View File
@@ -1,13 +1,15 @@
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import type { ChallengeFile } from '../../../redux/prop-types';
import { concatHtml } from '../rechallenge/builders';
import {
getTransformers,
embedFilesInHtml,
getPythonTransformers,
getMultifileJSXTransformers
} from '../rechallenge/transformers';
} from '@freecodecamp/challenge-builder/transformers';
import { concatHtml } from '@freecodecamp/challenge-builder/builders';
import { runnerTypes } from '@freecodecamp/challenge-builder/build';
import {
runTestsInTestFrame,
createMainPreviewFramer,
@@ -97,56 +99,6 @@ export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
return Object.prototype.hasOwnProperty.call(buildFunctions, challengeType);
}
export async function buildChallenge(
challengeData: BuildChallengeData,
options: BuildOptions
) {
const { challengeType } = challengeData;
const build = buildFunctions[challengeType];
if (build) {
return build(challengeData, options);
}
throw new Error(`Cannot build challenge of type ${challengeType}`);
}
export const runnerTypes: Record<
(typeof challengeTypes)[keyof typeof challengeTypes],
'javascript' | 'dom' | 'python'
> = {
[challengeTypes.html]: 'dom',
[challengeTypes.js]: 'javascript',
[challengeTypes.backend]: 'dom',
[challengeTypes.zipline]: 'dom',
[challengeTypes.frontEndProject]: 'dom',
[challengeTypes.backEndProject]: 'dom',
[challengeTypes.pythonProject]: 'python',
[challengeTypes.jsProject]: 'javascript',
[challengeTypes.modern]: 'dom',
[challengeTypes.step]: 'dom',
[challengeTypes.quiz]: 'dom',
[challengeTypes.invalid]: 'dom',
[challengeTypes.video]: 'dom',
[challengeTypes.codeAllyPractice]: 'dom',
[challengeTypes.codeAllyCert]: 'dom',
[challengeTypes.multifileCertProject]: 'dom',
[challengeTypes.theOdinProject]: 'dom',
[challengeTypes.colab]: 'dom',
[challengeTypes.exam]: 'dom',
[challengeTypes.msTrophy]: 'dom',
[challengeTypes.multipleChoice]: 'dom',
[challengeTypes.python]: 'python',
[challengeTypes.dialogue]: 'dom',
[challengeTypes.fillInTheBlank]: 'dom',
[challengeTypes.multifilePythonCertProject]: 'python',
[challengeTypes.generic]: 'dom',
[challengeTypes.lab]: 'dom',
[challengeTypes.jsLab]: 'javascript',
[challengeTypes.pyLab]: 'python',
[challengeTypes.dailyChallengeJs]: 'javascript',
[challengeTypes.dailyChallengePy]: 'python',
[challengeTypes.review]: 'dom'
};
export async function getTestRunner(buildData: BuildChallengeData) {
const { challengeType } = buildData;
// TODO: Fully type BuildChallengeData
+2 -1
View File
@@ -48,9 +48,10 @@
"devDependencies": {
"@babel/core": "7.23.7",
"@babel/register": "7.23.7",
"@freecodecamp/browser-scripts": "workspace:*",
"@freecodecamp/challenge-builder": "workspace:*",
"@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/shared": "workspace:*",
"@freecodecamp/browser-scripts": "workspace:*",
"@total-typescript/ts-reset": "^0.6.1",
"@types/debug": "^4.1.12",
"@types/js-yaml": "4.0.5",
+5 -7
View File
@@ -26,16 +26,16 @@ import { sortChallenges } from './utils/sort-challenges.js';
const { flatten, isEmpty, cloneDeep } = lodash;
vi.mock(
'../../../client/src/templates/Challenges/utils/typescript-worker-handler',
'@freecodecamp/challenge-builder/typescript-worker-handler',
async importOriginal => {
const actual = await importOriginal();
// ts and tsvfs must match the versions used in the typescript-worker.
const tsvfs = await import('@typescript/vfs-1.6.1');
const ts = await import('typescript-5.9.2');
// use the same TS compiler as the client
// use the same TS compiler as the challenge-builder
const tsCompiler = await import(
'../../../tools/client-plugins/browser-scripts/modules/typescript-compiler'
'@freecodecamp/browser-scripts/ts-compiler'
);
const compiler = new tsCompiler.Compiler(ts, tsvfs);
await compiler.setup({ useNodeModules: true });
@@ -151,7 +151,7 @@ async function populateTestsForLang({ lang, challenges, meta }) {
// Presumably this is because we import from_this file in the generated block
// test files and that happens before the mock is applied.
const { buildChallenge } = await import(
'../../../client/src/templates/Challenges/utils/build'
'@freecodecamp/challenge-builder/build'
);
const validateChallenge = challengeSchemaValidator();
@@ -370,9 +370,7 @@ async function createTestRunner(
buildChallenge,
solutionFromNext
) {
const { runnerTypes } = await import(
'../../../client/src/templates/Challenges/utils/build'
);
const { runnerTypes } = await import('@freecodecamp/challenge-builder/build');
const challengeFiles = replaceChallengeFilesContentsWithSolutions(
challenge.challengeFiles,
+1
View File
@@ -0,0 +1 @@
dist
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
@@ -0,0 +1,18 @@
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
import globals from 'globals';
import { defineConfig } from 'eslint/config';
const baseLanguageOptions = {
globals: {
...globals.browser,
...globals.node // TODO: necessary?
}
};
export default defineConfig({
extends: [configTypeChecked],
languageOptions: {
...baseLanguageOptions
}
});
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@freecodecamp/challenge-builder",
"version": "0.0.1",
"author": "freeCodeCamp <team@freecodecamp.org>",
"license": "BSD-3-Clause",
"description": "Builds challenges for testing and rendering",
"private": false,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"exports": {
"./build": "./dist/build.js",
"./transformers": "./dist/transformers.js",
"./typescript-worker-handler": "./dist/typescript-worker-handler.js",
"./builders": "./dist/builders.js"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit",
"compile": "tsc",
"lint": "eslint --max-warnings 0"
},
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@types/lodash-es": "4.17.12",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.1",
"vitest": "^3.2.4"
},
"dependencies": {
"@babel/preset-env": "7.23.7",
"@babel/preset-react": "7.28.5",
"@babel/standalone": "7.23.7",
"@freecodecamp/browser-scripts": "workspace:*",
"@freecodecamp/loop-protect": "3.0.0",
"@freecodecamp/shared": "workspace:*",
"lodash-es": "4.17.23"
}
}
+269
View File
@@ -0,0 +1,269 @@
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import { concatHtml } from './builders.js';
import {
getTransformers,
embedFilesInHtml,
getPythonTransformers,
getMultifileJSXTransformers
} from './transformers.js';
interface Source {
index: string;
contents?: string;
editableContents: string;
}
interface BuildChallengeData {
challengeType: number;
challengeFiles?: ChallengeFile[];
required: { src?: string }[];
template: string;
url: string;
}
interface BuildOptions {
preview: boolean;
disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean;
usesTestRunner?: boolean;
}
type ApplyFunctionProps = (
file: ChallengeFile
) => Promise<ChallengeFile> | ChallengeFile;
const applyFunction =
(fn: ApplyFunctionProps) => async (file: ChallengeFile) => {
try {
if (file.error) {
return file;
}
const newFile = await fn.call(this, file);
if (typeof newFile !== 'undefined') {
return newFile;
}
return file;
} catch (error) {
return { ...file, error };
}
};
const composeFunctions = (...fns: ApplyFunctionProps[]) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g));
function buildSourceMap(challengeFiles: ChallengeFile[]): Source | undefined {
// TODO: rename sources.index to sources.contents.
const source: Source | undefined = challengeFiles?.reduce(
(sources, challengeFile) => {
sources.index += challengeFile.source || '';
sources.contents = sources.index;
sources.editableContents += challengeFile.editableContents || '';
return sources;
},
{
index: '',
editableContents: ''
} as Source
);
return source;
}
export const buildFunctions = {
[challengeTypes.js]: buildJSChallenge,
[challengeTypes.jsProject]: buildJSChallenge,
[challengeTypes.html]: buildDOMChallenge,
[challengeTypes.modern]: buildDOMChallenge,
[challengeTypes.backend]: buildBackendChallenge,
[challengeTypes.backEndProject]: buildBackendChallenge,
[challengeTypes.pythonProject]: buildBackendChallenge,
[challengeTypes.multifileCertProject]: buildDOMChallenge,
[challengeTypes.colab]: buildBackendChallenge,
[challengeTypes.python]: buildPythonChallenge,
[challengeTypes.multifilePythonCertProject]: buildPythonChallenge,
[challengeTypes.lab]: buildDOMChallenge,
[challengeTypes.jsLab]: buildJSChallenge,
[challengeTypes.pyLab]: buildPythonChallenge,
[challengeTypes.dailyChallengeJs]: buildJSChallenge,
[challengeTypes.dailyChallengePy]: buildPythonChallenge
};
export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
const { challengeType } = challengeData;
return Object.prototype.hasOwnProperty.call(buildFunctions, challengeType);
}
export async function buildChallenge(
challengeData: BuildChallengeData,
options: BuildOptions
) {
const { challengeType } = challengeData;
const build = buildFunctions[challengeType];
if (build) {
return build(challengeData, options);
}
throw new Error(`Cannot build challenge of type ${challengeType}`);
}
export const runnerTypes: Record<
(typeof challengeTypes)[keyof typeof challengeTypes],
'javascript' | 'dom' | 'python'
> = {
[challengeTypes.html]: 'dom',
[challengeTypes.js]: 'javascript',
[challengeTypes.backend]: 'dom',
[challengeTypes.zipline]: 'dom',
[challengeTypes.frontEndProject]: 'dom',
[challengeTypes.backEndProject]: 'dom',
[challengeTypes.pythonProject]: 'python',
[challengeTypes.jsProject]: 'javascript',
[challengeTypes.modern]: 'dom',
[challengeTypes.step]: 'dom',
[challengeTypes.quiz]: 'dom',
[challengeTypes.invalid]: 'dom',
[challengeTypes.video]: 'dom',
[challengeTypes.codeAllyPractice]: 'dom',
[challengeTypes.codeAllyCert]: 'dom',
[challengeTypes.multifileCertProject]: 'dom',
[challengeTypes.theOdinProject]: 'dom',
[challengeTypes.colab]: 'dom',
[challengeTypes.exam]: 'dom',
[challengeTypes.msTrophy]: 'dom',
[challengeTypes.multipleChoice]: 'dom',
[challengeTypes.python]: 'python',
[challengeTypes.dialogue]: 'dom',
[challengeTypes.fillInTheBlank]: 'dom',
[challengeTypes.multifilePythonCertProject]: 'python',
[challengeTypes.generic]: 'dom',
[challengeTypes.lab]: 'dom',
[challengeTypes.jsLab]: 'javascript',
[challengeTypes.pyLab]: 'python',
[challengeTypes.dailyChallengeJs]: 'javascript',
[challengeTypes.dailyChallengePy]: 'python',
[challengeTypes.review]: 'dom'
};
type BuildResult = {
challengeType: number;
build?: string;
sources: Source | undefined;
loadEnzyme?: boolean;
error?: unknown;
};
// TODO: All the buildXChallenge files have a similar structure, so make that
// abstraction (function, class, whatever) and then create the various functions
// out of it.
export async function buildDOMChallenge(
{
challengeFiles,
required = [],
template = '',
challengeType
}: BuildChallengeData,
options?: BuildOptions
): Promise<BuildResult> {
// TODO: make this required in the schema.
if (!challengeFiles) throw Error('No challenge files provided');
const hasJsx = challengeFiles.some(
challengeFile => challengeFile.ext === 'jsx' || challengeFile.ext === 'tsx'
);
const isMultifile = challengeFiles.length > 1;
const requiresReact16 = required.some(({ src }) =>
src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.')
);
// I'm reasonably sure this is fine, but we need to migrate transformers to
// TypeScript to be sure.
const transformers: ApplyFunctionProps[] = (isMultifile && hasJsx
? getMultifileJSXTransformers(options)
: getTransformers(options)) as unknown as ApplyFunctionProps[];
const pipeLine = composeFunctions(...transformers);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const contents = (await embedFilesInHtml(finalFiles)) as string;
// 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
? {}
: {
required,
template,
contents
};
return {
challengeType,
build: concatHtml(toBuild),
sources: buildSourceMap(finalFiles),
loadEnzyme: requiresReact16,
error
};
}
export async function buildJSChallenge(
{
challengeFiles,
challengeType
}: { challengeFiles?: ChallengeFile[]; challengeType: number },
options: BuildOptions
): Promise<BuildResult> {
if (!challengeFiles) throw Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getTransformers(options) as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles?.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const toBuild = error ? [] : finalFiles;
return {
challengeType,
build: toBuild
.reduce(
(body, challengeFile) => [
...body,
challengeFile.head,
challengeFile.contents,
challengeFile.tail
],
[] as string[]
)
.join('\n'),
sources: buildSourceMap(finalFiles),
error
};
}
function buildBackendChallenge({ url, challengeType }: BuildChallengeData) {
return {
challengeType,
build: '',
sources: { contents: url }
};
}
export async function buildPythonChallenge({
challengeFiles,
challengeType
}: BuildChallengeData): Promise<BuildResult> {
if (!challengeFiles) throw new Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getPythonTransformers() as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const sources = buildSourceMap(finalFiles);
return {
challengeType,
sources,
build: sources?.contents,
error
};
}
@@ -17,11 +17,11 @@ import {
} from '@freecodecamp/shared/utils/polyvinyl';
import { version } from '@freecodecamp/browser-scripts/package.json';
import { WorkerExecutor } from '../utils/worker-executor';
import { WorkerExecutor } from './worker-executor';
import {
compileTypeScriptCode,
checkTSServiceIsReady
} from '../utils/typescript-worker-handler';
} from './typescript-worker-handler';
const protectTimeout = 100;
const testProtectTimeout = 1500;
@@ -1,8 +1,8 @@
import { version } from '@freecodecamp/browser-scripts/package.json';
import browserScripts from '@freecodecamp/browser-scripts/package.json';
import { awaitResponse } from './awaitable-messenger';
import { awaitResponse } from './awaitable-messenger.js';
const typeScriptWorkerSrc = `/js/workers/${version}/typescript-worker.js`;
const typeScriptWorkerSrc = `/js/workers/${browserScripts.version}/typescript-worker.js`;
let worker: Worker | null = null;
+12
View File
@@ -0,0 +1,12 @@
{
"include": ["src"],
"extends": "../../tsconfig-base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"module": "es2020",
"moduleResolution": "bundler",
"outDir": "dist",
"noEmit": false
}
}
+383 -257
View File
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,8 @@
"dist"
],
"exports": {
".": "./index.d.ts",
"./ts-compiler": "./modules/typescript-compiler.ts",
"./test-runner": "./test-runner.ts",
"./package.json": "./package.json"
},