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
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:
@@ -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",
|
||||
"build": "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.18.1"
|
||||
}
|
||||
}
|
||||
@@ -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 {}'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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