mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
refactor: move challenge build outside client (#65513)
This commit is contained in:
committed by
GitHub
parent
c8b21dfc4a
commit
5ff971687c
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
};
|
||||
}
|
||||
@@ -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,407 @@
|
||||
import protect from '@freecodecamp/loop-protect';
|
||||
import {
|
||||
cond,
|
||||
flow,
|
||||
identity,
|
||||
matchesProperty,
|
||||
overSome,
|
||||
partial,
|
||||
stubTrue
|
||||
} from 'lodash-es';
|
||||
|
||||
import {
|
||||
transformContents,
|
||||
transformHeadTailAndContents,
|
||||
compileHeadTail,
|
||||
createSource
|
||||
} from '@freecodecamp/shared/utils/polyvinyl';
|
||||
import { version } from '@freecodecamp/browser-scripts/package.json';
|
||||
|
||||
import { WorkerExecutor } from './worker-executor';
|
||||
import {
|
||||
compileTypeScriptCode,
|
||||
checkTSServiceIsReady
|
||||
} from './typescript-worker-handler';
|
||||
|
||||
const protectTimeout = 100;
|
||||
const testProtectTimeout = 1500;
|
||||
const loopsPerTimeoutCheck = 100;
|
||||
const testLoopsPerTimeoutCheck = 2000;
|
||||
const MODULE_TRANSFORM_PLUGIN = 'transform-modules-umd';
|
||||
|
||||
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]
|
||||
};
|
||||
}
|
||||
|
||||
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, 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 transformHeadTailAndContents(
|
||||
babelTransformCode(babelOptions),
|
||||
challengeFile
|
||||
);
|
||||
};
|
||||
|
||||
const getJSXTranspiler = loopProtectOptions => async challengeFile => {
|
||||
await loadBabel();
|
||||
await loadPresetReact();
|
||||
const babelOptions = getBabelOptions(presetsJSX, loopProtectOptions);
|
||||
return transformHeadTailAndContents(
|
||||
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();
|
||||
await checkTSServiceIsReady();
|
||||
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
|
||||
return flow(
|
||||
partial(transformHeadTailAndContents, compileTypeScriptCode),
|
||||
partial(transformHeadTailAndContents, babelTransformCode(babelOptions))
|
||||
)(challengeFile);
|
||||
};
|
||||
|
||||
const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => {
|
||||
await loadBabel();
|
||||
await loadPresetReact();
|
||||
await checkTSServiceIsReady();
|
||||
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
|
||||
const babelOptions = {
|
||||
...baseOptions,
|
||||
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
|
||||
moduleId: 'index' // TODO: this should be dynamic
|
||||
};
|
||||
return flow(
|
||||
partial(transformHeadTailAndContents, compileTypeScriptCode),
|
||||
partial(transformHeadTailAndContents, 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)
|
||||
: '';
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
script.innerHTML = scriptJs?.contents;
|
||||
script.removeAttribute('src');
|
||||
script.setAttribute('data-src', 'script.js');
|
||||
}
|
||||
if (tsScript) {
|
||||
tsScript.innerHTML = indexTs?.contents;
|
||||
tsScript.removeAttribute('src');
|
||||
tsScript.setAttribute('data-src', 'index.ts');
|
||||
}
|
||||
if (jsxScript) {
|
||||
jsxScript.innerHTML = indexJsx?.contents;
|
||||
jsxScript.removeAttribute('src');
|
||||
jsxScript.removeAttribute('type');
|
||||
jsxScript.setAttribute('data-src', 'index.jsx');
|
||||
jsxScript.setAttribute('data-type', 'text/babel');
|
||||
}
|
||||
if (tsxScript) {
|
||||
tsxScript.innerHTML = indexTsx?.contents;
|
||||
tsxScript.removeAttribute('src');
|
||||
tsxScript.removeAttribute('type');
|
||||
tsxScript.setAttribute('data-src', 'index.tsx');
|
||||
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');
|
||||
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs, indexTsx };
|
||||
}
|
||||
|
||||
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),
|
||||
partial(compileHeadTail, '')
|
||||
];
|
||||
|
||||
export const getMultifileJSXTransformers = loopProtectOptions => [
|
||||
createSource,
|
||||
replaceNBSP,
|
||||
createModuleTransformer(loopProtectOptions)
|
||||
];
|
||||
|
||||
export const getPythonTransformers = () => [
|
||||
createSource,
|
||||
replaceNBSP,
|
||||
partial(compileHeadTail, '')
|
||||
];
|
||||
@@ -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 checkTSServiceIsReady(): Promise<boolean> {
|
||||
return awaitResponse({
|
||||
messenger: getTypeScriptWorker(),
|
||||
message: { type: 'check-is-ready' },
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"include": ["src"],
|
||||
"extends": "../../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"module": "es2020",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user