chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
@@ -0,0 +1,61 @@
export type Messenger<Message> = {
postMessage: (message: Message, options: WindowPostMessageOptions) => void;
};
/**
* Sends a message via a messenger (an object containing postMessage) and awaits a response.
*
* @template MessageOut - The type of the message being sent.
* @template MessageIn - The type of the message expected to be returned. Must include a `type` and `value` property.
* @template Value - The type of the value expected in the response message.
*
* @param {Object} params - The parameters for the function.
* @param {Message} params.messenger - An object cabable of sending messages via postMessage.
* @param {MessageOut} params.message - The message to send .
* @param {Function} params.onMessage - A callback function to handle the response.
* @param {MessageIn} params.onMessage.response - The response message.
* @param {Function} params.onMessage.resolve - A function which, when called, resolves the promise with its argument.
* @param {Function} params.onMessage.reject - A function which, when called, rejects the promise with its argument.
*
* @returns {Promise<Value>} A promise that resolves with the response value or rejects with an error message.
*/
export function awaitResponse<
MessageOut,
MessageIn extends { type: string; value: Value; error: string },
Value
>({
messenger,
message,
onMessage
}: {
messenger: Messenger<MessageOut>;
message: MessageOut;
onMessage: (
response: MessageIn,
onSuccess: (res: Value) => void,
onFailure: (err: Error) => void
) => void;
}): Promise<Value> {
return new Promise(
(resolve: (res: Value) => void, reject: (err: Error) => void) => {
const channel = new MessageChannel();
// TODO: Figure out how to ensure the worker is ready and/or handle when it
// is not.
const id = setTimeout(() => {
channel.port1.close();
reject(Error('No response from worker'));
}, 5000);
channel.port1.onmessage = (event: MessageEvent<MessageIn>) => {
clearTimeout(id);
channel.port1.close();
onMessage(event.data, resolve, reject);
};
messenger.postMessage(message, {
targetOrigin: '*',
transfer: [channel.port2]
});
}
);
}
@@ -0,0 +1,94 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it, vi } from 'vitest';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import { buildChallenge, getTSConfig } from './build';
vi.mock('./typescript-worker-handler.js', () => ({
compileTypeScriptCode: () => Promise.resolve('const compiled = true;'),
setupTSCompiler: () => Promise.resolve(true)
}));
describe('getTSConfig', () => {
it("should return the tsconfig file's contents if it exists", () => {
const compileOptions = 'any string is valid here';
const challengeFiles = [
{ name: 'index', ext: 'ts' },
{ name: 'tsconfig', ext: 'json', contents: compileOptions }
] as ChallengeFile[];
expect(getTSConfig(challengeFiles)).toEqual(compileOptions);
});
it('should return null if there is no tsconfig file', () => {
const challengeFiles = [
{ name: 'index', ext: 'ts' },
{ name: 'app', ext: 'ts' }
] as ChallengeFile[];
expect(getTSConfig(challengeFiles)).toBeNull();
});
it('should throw an error if there are multiple tsconfig.json files', () => {
const challengeFiles = [
{ name: 'index', ext: 'ts' },
{ name: 'tsconfig', ext: 'json' },
{ name: 'tsconfig', ext: 'json' }
] as ChallengeFile[];
expect(() => getTSConfig(challengeFiles)).toThrow(
'TypeScript challenge must include only one tsconfig.json file'
);
});
});
describe('buildChallenge', () => {
it('separates source files when building the test runner source map', async () => {
const challengeFiles = [
{
name: 'index',
ext: 'html',
contents:
'<!doctype html><html><head><link rel="stylesheet" href="styles.css"></head><body><script src="index.ts"></script></body></html>',
fileKey: 'indexhtml',
history: ['index.html']
},
{
name: 'styles',
ext: 'css',
contents: '',
fileKey: 'stylescss',
history: ['styles.css']
},
{
name: 'index',
ext: 'ts',
contents: 'interface FlashCard {}',
fileKey: 'indexts',
history: ['index.ts']
}
] as ChallengeFile[];
const result = await buildChallenge(
{
challengeType: challengeTypes.lab,
challengeFiles,
required: [],
template: '',
url: ''
},
{
preview: false,
disableLoopProtectTests: true,
disableLoopProtectPreview: true
}
);
expect(result.sources?.contents).toContain(
'</html>\n\ninterface FlashCard {}'
);
});
});
+324
View File
@@ -0,0 +1,324 @@
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';
import { setupTSCompiler } from './typescript-worker-handler.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));
// Test helpers parse sources.contents across all files. Without this boundary,
// an index.ts first-line declaration can be concatenated onto empty/no-newline
// HTML or CSS, so AST checks fail.
const joinWithFileBoundaries = (contents: string[]) => contents.join('\n');
function buildSourceMap(challengeFiles: ChallengeFile[]): Source | undefined {
// TODO: rename sources.index to sources.contents.
const index = joinWithFileBoundaries(
challengeFiles.map(challengeFile => challengeFile.source ?? '')
);
const editableContents = joinWithFileBoundaries(
challengeFiles.map(challengeFile => challengeFile.editableContents ?? '')
);
const source: Source | undefined = challengeFiles.length
? {
index,
contents: index,
editableContents
}
: {
index,
editableContents
};
return source;
}
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 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 + build;
};
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;
};
function hasTS(challengeFiles: ChallengeFile[]) {
return challengeFiles.some(
challengeFile => challengeFile.ext === 'ts' || challengeFile.ext === 'tsx'
);
}
const isTSConfig = (f: { name: string; ext: string }) =>
f.name === 'tsconfig' && f.ext === 'json';
export function getTSConfig(challengeFiles: ChallengeFile[]) {
const tsConfigFiles = challengeFiles.filter(isTSConfig);
if (tsConfigFiles.length > 1) {
throw new Error(
'TypeScript challenge must include only one tsconfig.json file'
);
}
return tsConfigFiles.length === 1 ? tsConfigFiles[0].contents : null;
}
async function configureTSCompiler(challengeFiles: ChallengeFile[]) {
if (hasTS(challengeFiles)) {
const tsConfig = getTSConfig(challengeFiles);
if (tsConfig) {
await setupTSCompiler(tsConfig);
} else {
await setupTSCompiler();
}
}
}
// 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.
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'
);
await configureTSCompiler(challengeFiles);
const sourceFiles = challengeFiles.filter(file => !isTSConfig(file));
const isMultifile = sourceFiles.length > 1;
// 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(sourceFiles.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
};
const requiresReact16 = required.some(({ src }) =>
src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.')
);
return {
challengeType,
build: concatHtml(toBuild),
sources: buildSourceMap(finalFiles),
loadEnzyme: requiresReact16,
error
};
}
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[])
);
await configureTSCompiler(challengeFiles);
const sourceFiles = challengeFiles.filter(file => !isTSConfig(file));
const finalFiles = await Promise.all(sourceFiles?.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const toBuild = error ? [] : finalFiles;
return {
challengeType,
build: toBuild
.reduce(
(body, challengeFile) => [...body, challengeFile.contents],
[] as string[]
)
.join('\n'),
sources: buildSourceMap(finalFiles),
error
};
}
function buildBackendChallenge({ url, challengeType }: BuildChallengeData) {
return {
challengeType,
build: '',
sources: { contents: url }
};
}
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
};
}
@@ -0,0 +1,44 @@
import { template as _template } from 'lodash-es';
interface ConcatHTMLOptions {
required?: { src?: string; link?: string }[];
template?: string;
contents?: string;
testRunner?: string;
}
export function concatHtml({
required = [],
template,
contents
}: ConcatHTMLOptions): string {
const embedSource = template
? _template(template)
: ({ source }: { source: ConcatHTMLOptions['contents'] }) => source;
const head = required
.map(({ link, src }) => {
if (link && src) {
throw new Error(`
A required file can not have both a src and a link: src = ${src}, link = ${link}
`);
}
if (src) {
return `<script src='${src}' type='text/javascript'></script>`;
}
if (link) {
return `<link href='${link}' rel='stylesheet' />`;
}
return '';
})
.join('\n');
return `<head>${head}</head>${embedSource({ source: contents }) || ''}`;
}
export function createPythonTerminal(pythonRunnerSrc: string): string {
const head =
'<head><style>#terminal { margin-top: 10px; width: 100%; height: 350px; background-color: #000; color: #00ff00; padding: 5px; overflow: auto; border: 1px solid #ccc; border-radius: 3px; }</style></head>';
const body = `<body><div id='terminal'></div><script src='${pythonRunnerSrc}' type='text/javascript'></script></body>`;
return `<html>${head}${body}</html>`;
}
@@ -0,0 +1,420 @@
import protect from '@freecodecamp/loop-protect';
import {
cond,
flow,
identity,
matchesProperty,
overSome,
partial,
stubTrue
} from 'lodash-es';
import {
transformContents,
createSource
} from '@freecodecamp/shared/utils/polyvinyl';
import { version } from '@freecodecamp/browser-scripts/package.json';
import { WorkerExecutor } from './worker-executor';
import { compileTypeScriptCode } from './typescript-worker-handler';
const protectTimeout = 100;
const testProtectTimeout = 1500;
const loopsPerTimeoutCheck = 100;
const testLoopsPerTimeoutCheck = 2000;
const MODULE_TRANSFORM_PLUGIN = 'transform-modules-umd';
const BABEL_ENV_OPTIONS = {
targets: '> 0.4%, not dead'
};
function loopProtectCB(line) {
console.log(
`Potential infinite loop detected on line ${line}. Tests may fail if this is not changed.`
);
}
function testLoopProtectCB(line) {
console.log(
`Potential infinite loop detected on line ${line}. Tests may be failing because of this.`
);
}
// hold Babel and presets so we don't try to import them multiple times
let Babel;
let presetEnv, presetReact;
let presetsJS, presetsJSX;
async function loadBabel() {
if (Babel) return;
Babel = await import(
/* webpackChunkName: "@babel/standalone" */ '@babel/standalone'
);
Babel.registerPlugin(
'loopProtection',
protect(protectTimeout, loopProtectCB, loopsPerTimeoutCheck)
);
Babel.registerPlugin(
'testLoopProtection',
protect(testProtectTimeout, testLoopProtectCB, testLoopsPerTimeoutCheck)
);
}
async function loadPresetEnv() {
if (!presetEnv)
presetEnv = await import(
/* webpackChunkName: "@babel/preset-env" */ '@babel/preset-env'
);
presetsJS = {
presets: [[presetEnv, BABEL_ENV_OPTIONS]]
};
}
async function loadPresetReact() {
if (!presetReact)
presetReact = await import(
/* webpackChunkName: "@babel/preset-react" */ '@babel/preset-react'
);
if (!presetEnv)
presetEnv = await import(
/* webpackChunkName: "@babel/preset-env" */ '@babel/preset-env'
);
presetsJSX = {
presets: [[presetEnv, BABEL_ENV_OPTIONS], presetReact]
};
}
const babelTransformCode = options => code =>
Babel.transform(code, options).code;
const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const testJS = matchesProperty('ext', 'js');
const testJSX = matchesProperty('ext', 'jsx');
const testTSX = matchesProperty('ext', 'tsx');
const testTypeScript = matchesProperty('ext', 'ts');
const testHTML = matchesProperty('ext', 'html');
const testHTML$JS$JSX$TS$TSX = overSome(
testHTML,
testJS,
testJSX,
testTypeScript,
testTSX
);
const replaceNBSP = cond([
[
testHTML$JS$JSX$TS$TSX,
partial(transformContents, contents => contents.replace(NBSPReg, ' '))
],
[stubTrue, identity]
]);
const getJSTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetEnv();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getJSXTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const babelOptions = getBabelOptions(presetsJSX, loopProtectOptions);
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getJSXModuleTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
const babelOptions = {
...baseOptions,
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
moduleId: 'index' // TODO: this should be dynamic
};
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getTSTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return flow(
partial(transformContents, compileTypeScriptCode),
partial(transformContents, babelTransformCode(babelOptions))
)(challengeFile);
};
const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
const babelOptions = {
...baseOptions,
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
moduleId: 'index' // TODO: this should be dynamic
};
return flow(
partial(transformContents, compileTypeScriptCode),
partial(transformContents, babelTransformCode(babelOptions))
)(challengeFile);
};
const createTranspiler = loopProtectOptions => {
return cond([
[testJS, getJSTranspiler(loopProtectOptions)],
[testJSX, getJSXTranspiler(loopProtectOptions)],
[testTypeScript, getTSTranspiler(loopProtectOptions)],
[testHTML, getHtmlTranspiler({ useModules: false })],
[stubTrue, identity]
]);
};
const createModuleTransformer = loopProtectOptions => {
return cond([
[testJSX, getJSXModuleTranspiler(loopProtectOptions)],
[testTSX, getTSXModuleTranspiler(loopProtectOptions)],
[testHTML, getHtmlTranspiler({ useModules: true })],
[stubTrue, identity]
]);
};
function getBabelOptions(
presets,
{ preview, disableLoopProtectTests, disableLoopProtectPreview } = {
preview: false,
disableLoopProtectTests: false,
disableLoopProtectPreview: false
}
) {
// we protect the preview unless specifically disabled, since it evaluates as
// the user types and they may briefly have infinite looping code accidentally
if (preview && !disableLoopProtectPreview)
return { ...presets, plugins: ['loopProtection'] };
if (!disableLoopProtectTests)
return { ...presets, plugins: ['testLoopProtection'] };
return presets;
}
const sassWorkerExecutor = new WorkerExecutor(
`workers/${version}/sass-compile`
);
async function transformSASS(documentElement) {
// we only teach scss syntax, not sass. Also the compiler does not seem to be
// able to deal with sass.
const styleTags = documentElement.querySelectorAll(
'style[type~="text/scss"]'
);
await Promise.all(
[].map.call(styleTags, async style => {
style.type = 'text/css';
style.innerHTML = await sassWorkerExecutor.execute(style.innerHTML, 5000)
.done;
})
);
}
async function transformScript(documentElement, { useModules }) {
await loadBabel();
await loadPresetEnv();
await loadPresetReact();
const scriptTags = documentElement.querySelectorAll('script');
scriptTags.forEach(script => {
const isBabel = script.type === 'text/babel';
const hasSource = !!script.src;
// TODO: make the use of JSX conditional on more than just the script type.
// It should only be used for React challenges since it would be confusing
// for learners to see the results of a transformation they didn't ask for.
const baseOptions = isBabel ? presetsJSX : presetsJS;
const options = {
...baseOptions,
...(useModules && { plugins: [MODULE_TRANSFORM_PLUGIN] })
};
// The type has to be removed, otherwise the browser will ignore the script.
// However, if we're importing modules, the type will be removed when the
// scripts are embedded in the HTML.
if (isBabel && !useModules) script.removeAttribute('type');
// We could use babel standalone to transform inline code in the preview,
// but that generates a warning that's shown to learner. By removing the
// type attribute and transforming the code we can avoid that warning.
if (isBabel && !hasSource) {
script.removeAttribute('type');
script.setAttribute('data-type', 'text/babel');
}
// Skip unnecessary transformations
script.innerHTML = script.innerHTML
? babelTransformCode(options)(script.innerHTML)
: '';
});
}
const deferScript = scriptCode => {
// Mimic the behavior of a defer script by waiting until the DOM is loaded
// before executing the script.
return `
(() => {
const run = (() => {
if (document.readyState === "interactive") {
${scriptCode}
}
});
document.addEventListener('readystatechange', run, { once: true });
})();
`;
};
export const embedScript = (script, source, contents) => {
const code = contents ?? '';
script.innerHTML = script.hasAttribute('defer') ? deferScript(code) : code;
script.removeAttribute('src');
script.setAttribute('data-src', source);
};
// This does the final transformations of the files needed to embed them into
// HTML.
export const embedFilesInHtml = async function (challengeFiles) {
const { indexHtml, stylesCss, scriptJs, indexJsx, indexTs, indexTsx } =
challengeFilesToObject(challengeFiles);
const embedStylesAndScript = contentDocument => {
const documentElement = contentDocument.documentElement;
const link =
documentElement.querySelector('link[href="styles.css"]') ??
documentElement.querySelector('link[href="./styles.css"]');
const script =
documentElement.querySelector('script[src="script.js"]') ??
documentElement.querySelector('script[src="./script.js"]');
const tsScript =
documentElement.querySelector('script[src="index.ts"]') ??
documentElement.querySelector('script[src="./index.ts"]');
const jsxScript =
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.jsx"]`
) ??
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.jsx"]`
);
const tsxScript =
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.tsx"]`
) ??
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.tsx"]`
);
if (link) {
const style = contentDocument.createElement('style');
style.classList.add('fcc-injected-styles');
style.innerHTML = stylesCss?.contents;
link.parentNode.appendChild(style);
link.removeAttribute('href');
link.dataset.href = 'styles.css';
}
if (script) {
embedScript(script, 'script.js', scriptJs?.contents);
}
if (tsScript) {
embedScript(tsScript, 'index.ts', indexTs?.contents);
}
if (jsxScript) {
embedScript(jsxScript, 'index.jsx', indexJsx?.contents);
jsxScript.removeAttribute('type');
jsxScript.setAttribute('data-type', 'text/babel');
}
if (tsxScript) {
embedScript(tsxScript, 'index.tsx', indexTsx?.contents);
tsxScript.removeAttribute('type');
tsxScript.setAttribute('data-type', 'text/babel');
}
return documentElement.innerHTML;
};
if (indexHtml) {
const contents = await parseAndTransform(
embedStylesAndScript,
indexHtml.contents
);
return contents;
} else if (indexJsx) {
return `<script>${indexJsx.contents}</script>`;
} else if (scriptJs) {
return `<script>${scriptJs.contents}</script>`;
} else if (indexTs) {
return `<script>${indexTs.contents}</script>`;
} else if (indexTsx) {
return `<script>${indexTsx.contents}</script>`;
} else {
throw Error('No html, ts(x) or js(x) file found');
}
};
function challengeFilesToObject(challengeFiles) {
const indexHtml = challengeFiles.find(file => file.fileKey === 'indexhtml');
const indexJsx = challengeFiles.find(file => file.fileKey === 'indexjsx');
const stylesCss = challengeFiles.find(file => file.fileKey === 'stylescss');
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
const indexTsx = challengeFiles.find(file => file.fileKey === 'indextsx');
const tsconfigJson = challengeFiles.find(
file => file.fileKey === 'tsconfigjson'
);
return {
indexHtml,
indexJsx,
stylesCss,
scriptJs,
indexTs,
indexTsx,
tsconfigJson
};
}
const parseAndTransform = async function (transform, contents) {
const parser = new DOMParser();
const newDoc = parser.parseFromString(contents, 'text/html');
return await transform(newDoc);
};
const getHtmlTranspiler = scriptOptions =>
async function (file) {
const transform = async contentDocument => {
const documentElement = contentDocument.documentElement;
await Promise.all([
transformSASS(documentElement),
transformScript(documentElement, scriptOptions)
]);
return documentElement.innerHTML;
};
const contents = await parseAndTransform(transform, file.contents);
return transformContents(() => contents, file);
};
export const getTransformers = loopProtectOptions => [
createSource,
replaceNBSP,
createTranspiler(loopProtectOptions)
];
export const getMultifileJSXTransformers = loopProtectOptions => [
createSource,
replaceNBSP,
createModuleTransformer(loopProtectOptions)
];
export const getPythonTransformers = () => [createSource, replaceNBSP];
@@ -0,0 +1,140 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createPoly } from '@freecodecamp/shared/utils/polyvinyl';
import { embedFilesInHtml, embedScript, getTransformers } from './transformers';
const parseHtml = html => new DOMParser().parseFromString(html, 'text/html');
const defaultLoopProtectOptions = {
preview: false,
disableLoopProtectTests: false,
disableLoopProtectPreview: false
};
const applyTransformers = challengeFile => {
const transformers = getTransformers(defaultLoopProtectOptions);
return transformers.reduce(
(fileP, transformer) => fileP.then(file => transformer(file)),
Promise.resolve(challengeFile)
);
};
describe('embedFilesInHtml', () => {
it('does not transpile const declarations to var', async () => {
const file = createPoly({
name: 'script',
ext: 'js',
contents:
'const location = []; const copiedLocation = { ...window.location };',
head: '',
tail: ''
});
const transformed = await applyTransformers(file);
expect(transformed.contents).toMatch(/\bconst\s+location\b/);
expect(transformed.contents).not.toMatch(/\bvar\s+location\b/);
expect(transformed.contents).toMatch(/\.\.\.\s*window\.location/);
});
it('keeps deferred script.js in place', async () => {
const result = await embedFilesInHtml([
{
fileKey: 'indexhtml',
contents:
'<!doctype html><html><head><script defer src="script.js"></script></head><body><main id="app"></main></body></html>'
},
{
fileKey: 'scriptjs',
contents: 'window.app = document.querySelector("#app");'
}
]);
const doc = parseHtml(result);
const script = doc.querySelector('script[data-src="script.js"]');
expect(script).toBeTruthy();
expect(script?.getAttribute('src')).toBeNull();
expect(script?.textContent).toContain(
'window.app = document.querySelector("#app");'
);
expect(script?.parentElement?.tagName).toBe('HEAD');
expect(doc.body.lastElementChild?.id).toBe('app');
});
it('keeps non-deferred script.js in place when embedding', async () => {
const result = await embedFilesInHtml([
{
fileKey: 'indexhtml',
contents:
'<!doctype html><html><head><script src="script.js"></script></head><body><main id="app"></main></body></html>'
},
{
fileKey: 'scriptjs',
contents: 'window.app = document.querySelector("#app");'
}
]);
const doc = parseHtml(result);
const script = doc.querySelector('script[data-src="script.js"]');
expect(script).toBeTruthy();
expect(script?.getAttribute('src')).toBeNull();
expect(script?.parentElement?.tagName).toBe('HEAD');
expect(doc.body.lastElementChild?.id).toBe('app');
});
});
describe('embedScript', () => {
const rawScript = 'console.log("Hello, world!");';
afterEach(() => {
delete document.__hasRun;
document.body.querySelectorAll('script').forEach(s => s.remove());
vi.restoreAllMocks();
});
it('runs deferred scripts when the readystate becomes interactive', async () => {
const script = document.createElement('script');
script.setAttribute('defer', true);
embedScript(script, 'script.js', 'document.__hasRun = true;');
// By default, the jsdom environment is "complete", so we need to mock it to
// test the defer behavior.
vi.spyOn(document, 'readyState', 'get').mockReturnValueOnce('interactive');
// We have to wait for something to happen inside the script. Since we
// dispatch this event, that is something we can wait for.
const scriptRan = new Promise(resolve =>
document.addEventListener('readystatechange', resolve, {
once: true
})
);
document.body.appendChild(script);
document.dispatchEvent(new Event('readystatechange'));
await scriptRan;
expect(document.__hasRun).toBe(true);
});
it('embeds script content into a script tag', () => {
const script = document.createElement('script');
embedScript(script, 'script.js', rawScript);
expect(script.getAttribute('src')).toBeNull();
expect(script.textContent).toEqual(rawScript);
});
it('embeds defered scripts content', () => {
const script = document.createElement('script');
script.setAttribute('defer', true);
embedScript(script, 'script.js', rawScript);
expect(script.getAttribute('defer')).toBe('true');
expect(script.getAttribute('src')).toBeNull();
expect(script.textContent).toContain(rawScript);
});
});
@@ -0,0 +1,45 @@
import browserScripts from '@freecodecamp/browser-scripts/package.json';
import { awaitResponse } from './awaitable-messenger.js';
const typeScriptWorkerSrc = `/js/workers/${browserScripts.version}/typescript-worker.js`;
let worker: Worker | null = null;
function getTypeScriptWorker(): Worker {
if (!worker) {
worker = new Worker(typeScriptWorkerSrc);
}
return worker;
}
export function compileTypeScriptCode(code: string): Promise<string> {
return awaitResponse({
messenger: getTypeScriptWorker(),
message: { type: 'compile', code },
onMessage: (data, onSuccess, onFailure) => {
if (data.type === 'compiled') {
if (!data.error) {
onSuccess(data.value);
} else {
onFailure(Error(data.error));
}
} else {
onFailure(Error('unable to compile code'));
}
}
});
}
export function setupTSCompiler(tsconfig?: string): Promise<boolean> {
return awaitResponse({
messenger: getTypeScriptWorker(),
message: { type: 'setup', ...(tsconfig && { tsconfig }) },
onMessage: (data, onSuccess) => {
if (data.type === 'ready') {
onSuccess(true);
}
// otherwise it times out.
}
});
}
@@ -0,0 +1,149 @@
export class WorkerExecutor {
constructor(
workerName,
{ location = '/js/', maxWorkers = 2, terminateWorker = false } = {}
) {
this._workerPool = [];
this._taskQueue = [];
this._workersInUse = 0;
this._maxWorkers = maxWorkers;
this._terminateWorker = terminateWorker;
this._scriptURL = `${location}${workerName}.js`;
this._getWorker = this._getWorker.bind(this);
}
async _getWorker() {
return this._workerPool.length
? this._workerPool.shift()
: this._createWorker();
}
_createWorker() {
return new Promise((resolve, reject) => {
const newWorker = new Worker(this._scriptURL);
newWorker.onmessage = e => {
if (e.data?.type === 'contentLoaded') {
resolve(newWorker);
}
};
newWorker.onerror = err => reject(err);
});
}
_handleTaskEnd(task) {
return () => {
this._workersInUse--;
const worker = task._worker;
if (worker) {
if (this._terminateWorker) {
worker.terminate();
} else {
worker.onmessage = null;
worker.onerror = null;
this._workerPool.push(worker);
}
}
this._processQueue();
};
}
_processQueue() {
while (this._workersInUse < this._maxWorkers && this._taskQueue.length) {
const task = this._taskQueue.shift();
const handleTaskEnd = this._handleTaskEnd(task);
task._execute(this._getWorker).done.then(handleTaskEnd, handleTaskEnd);
this._workersInUse++;
}
}
execute(data, timeout = 1000) {
const task = eventify({});
task._execute = function (getWorker) {
getWorker().then(
worker => {
task._worker = worker;
const timeoutId = setTimeout(() => {
task._worker.terminate();
task._worker = null;
this.emit('error', { message: 'timeout' });
}, timeout);
worker.onmessage = e => {
clearTimeout(timeoutId);
// data.type is undefined when the message has been processed
// successfully and defined when something else has happened (e.g.
// an error occurred)
if (e.data?.type) {
this.emit(e.data.type, e.data.data);
} else {
this.emit('done', e.data);
}
};
worker.onerror = e => {
clearTimeout(timeoutId);
this.emit('error', { message: e.message });
};
worker.postMessage(data);
},
err => this.emit('error', err)
);
return this;
};
task.done = new Promise((resolve, reject) => {
task
.once('done', data => resolve(data))
.once('error', err => reject(err.message));
});
this._taskQueue.push(task);
this._processQueue();
return task;
}
}
// Error and completion handling
const eventify = self => {
self._events = {};
self.on = (event, listener) => {
if (typeof self._events[event] === 'undefined') {
self._events[event] = [];
}
self._events[event].push(listener);
return self;
};
self.removeListener = (event, listener) => {
if (typeof self._events[event] !== 'undefined') {
const index = self._events[event].indexOf(listener);
if (index !== -1) {
self._events[event].splice(index, 1);
}
}
return self;
};
self.emit = (event, ...args) => {
if (typeof self._events[event] !== 'undefined') {
const listeners = self._events[event].slice();
for (let listener of listeners) {
listener.apply(self, args);
}
}
return self;
};
self.once = (event, listener) => {
self.on(event, function handler(...args) {
self.removeListener(event, handler);
listener.apply(self, args);
});
return self;
};
return self;
};
@@ -0,0 +1,249 @@
/**
* @vitest-environment node
*/
import { it, expect, afterEach, vi } from 'vitest';
import { WorkerExecutor } from './worker-executor';
function mockWorker({ init, postMessage, terminate } = {}) {
global.Worker = vi.fn(function () {
setImmediate(
(init && init(this)) ||
(() =>
this.onmessage && this.onmessage({ data: { type: 'contentLoaded' } }))
);
this.onmessage = null;
this.postMessage =
postMessage ||
function (data) {
setImmediate(
() => this.onmessage && this.onmessage({ data: `${data} processed` })
);
};
this.terminate = terminate || (() => {});
return this;
});
}
afterEach(() => {
delete global.Worker;
});
it('Worker executor should successfully execute one task', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test');
expect(testWorker).not.toBeUndefined();
const task = testWorker.execute('test');
expect(task).not.toBeUndefined();
expect(task.done).not.toBeUndefined();
const handler = vi.fn();
task.on('done', handler);
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).resolves.toBe('test processed');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('test processed');
expect(errorHandler).not.toHaveBeenCalled();
expect(terminateHandler).not.toHaveBeenCalled();
expect(global.Worker).toHaveBeenCalledTimes(1);
expect(global.Worker).toHaveBeenCalledWith('/js/test.js');
});
it('Worker executor should successfully execute two tasks in parallel', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test');
const task1 = testWorker.execute('test1');
const handler1 = vi.fn();
task1.on('done', handler1);
const errorHandler1 = vi.fn();
task1.on('error', errorHandler1);
const task2 = testWorker.execute('test2');
const handler2 = vi.fn();
task2.on('done', handler2);
const errorHandler2 = vi.fn();
task2.on('error', errorHandler2);
await expect(Promise.all([task1.done, task2.done])).resolves.toEqual([
'test1 processed',
'test2 processed'
]);
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith('test1 processed');
expect(errorHandler1).not.toHaveBeenCalled();
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith('test2 processed');
expect(errorHandler2).not.toHaveBeenCalled();
expect(terminateHandler).not.toHaveBeenCalled();
expect(global.Worker).toHaveBeenCalledTimes(2);
});
it('Worker executor should successfully execute 3 tasks in parallel and use two workers', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test');
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(2);
});
it('Worker executor should successfully execute 3 tasks, use 3 workers and terminate each worker', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test', { terminateWorker: true });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(terminateHandler).toHaveBeenCalledTimes(3);
expect(global.Worker).toHaveBeenCalledTimes(3);
});
it('Worker executor should successfully execute 3 tasks in parallel and use 3 workers', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', { maxWorkers: 3 });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(3);
});
it('Worker executor should successfully execute 3 tasks and use 1 worker', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', { maxWorkers: 1 });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(1);
});
it('Worker executor should reject task', async () => {
const error = { message: 'Error on init worker' };
mockWorker({
init: () => {
throw error;
}
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).rejects.toBe(error.message);
expect(errorHandler).toHaveBeenCalledTimes(1);
expect(errorHandler).toHaveBeenCalledWith(error);
});
it('Worker executor should emit LOG events', async () => {
mockWorker({
postMessage: function (data) {
setImmediate(() => {
for (let i = 0; i < 3; i++) {
// eslint-disable-next-line no-unused-expressions
this.onmessage && this.onmessage({ data: { type: 'LOG', data: i } });
}
// eslint-disable-next-line no-unused-expressions
this.onmessage && this.onmessage({ data: `${data} processed` });
setImmediate(
() =>
this.onmessage && this.onmessage({ data: { type: 'LOG', data: 3 } })
);
});
}
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const handler = vi.fn();
task.on('done', handler);
const errorHandler = vi.fn();
task.on('error', errorHandler);
const logHandler = vi.fn();
task.on('LOG', logHandler);
await expect(task.done).resolves.toBe('test processed');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('test processed');
expect(errorHandler).not.toHaveBeenCalled();
expect(logHandler).toHaveBeenCalledTimes(3);
for (let i = 0; i < 3; i++) {
expect(logHandler.mock.calls[i][0]).toBe(i);
}
});
it('Worker executor should reject task on timeout', async () => {
const terminateHandler = vi.fn();
mockWorker({
postMessage: () => {},
terminate: terminateHandler
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test', 0);
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).rejects.toBe('timeout');
expect(errorHandler).toHaveBeenCalledTimes(1);
expect(errorHandler.mock.calls[0][0]).toEqual({ message: 'timeout' });
expect(terminateHandler).toHaveBeenCalledTimes(1);
});
it('Worker executor should get worker from specified location', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', {
location: '/other/location/'
});
const task = testWorker.execute('test');
await expect(task.done).resolves.toBe('test processed');
expect(global.Worker).toHaveBeenCalledTimes(1);
expect(global.Worker).toHaveBeenCalledWith('/other/location/test.js');
});
it('Task should only emit handler once', () => {
mockWorker();
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const handler = vi.fn();
task.once('testOnce', handler);
task.emit('testOnce', handler);
task.emit('testOnce', handler);
expect(handler).toHaveBeenCalledTimes(1);
});