feat: python in the browser (#50913)

Co-authored-by: Beau Carnes <1513130+beaucarnes@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2023-07-28 07:36:25 +02:00
committed by GitHub
parent 0caaf7ec66
commit 69d6ee32bf
61 changed files with 1937 additions and 356 deletions

View File

@@ -70,6 +70,7 @@
"./api/tsconfig.json",
"./config/tsconfig.json",
"./tools/ui-components/tsconfig.json",
"./tools/client-plugins/browser-scripts/tsconfig.json",
"./utils/tsconfig.json",
"./web/tsconfig.json",
"./curriculum-server/tsconfig.json",

1
.gitignore vendored
View File

@@ -161,6 +161,7 @@ config/env.json
config/client/sass-compile.json
config/client/frame-runner.json
config/client/test-evaluator.json
config/client/python-runner.json
config/curriculum.json
config/i18n.js
config/misc.js

View File

@@ -51,7 +51,9 @@ module.exports = {
}
},
{
resolve: 'fcc-source-challenges',
resolve: require.resolve(
'../tools/client-plugins/gatsby-source-challenges'
),
options: {
name: 'challenges',
source: buildChallenges,
@@ -70,7 +72,9 @@ module.exports = {
resolve: 'gatsby-transformer-remark'
},
{
resolve: 'gatsby-remark-node-identity',
resolve: require.resolve(
'../tools/client-plugins/gatsby-remark-node-identity'
),
options: {
identity: 'blockIntroMarkdown',
predicate: ({ frontmatter }) => {
@@ -83,7 +87,9 @@ module.exports = {
}
},
{
resolve: 'gatsby-remark-node-identity',
resolve: require.resolve(
'../tools/client-plugins/gatsby-remark-node-identity'
),
options: {
identity: 'superBlockIntroMarkdown',
predicate: ({ frontmatter }) => {

View File

@@ -1057,6 +1057,20 @@
}
}
},
"upcoming-python": {
"title": "Upcoming Python",
"intro": ["placeholder"],
"blocks": {
"learn-python-by-building-a-blackjack-game": {
"title": "Learn Python by Building a Blackjack Game",
"intro": ["Learn Python.", ""]
},
"upcoming-python-project": {
"title": "Upcoming Python Project",
"intro": ["placeholder"]
}
}
},
"example-certification": {
"title": "Example Certification",
"intro": ["placeholder"],

View File

@@ -19,11 +19,11 @@
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none",
"scripts": {
"prebuild": "pnpm -w run create:config && pnpm run build:workers --env production && pnpm run build:components-library",
"prebuild": "pnpm -w run create:config && pnpm run build:scripts --env production && pnpm run build:components-library",
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=7168\" gatsby build --prefix-paths",
"build:workers": "cross-env NODE_OPTIONS=\"--max-old-space-size=7168\" webpack --config ./webpack-workers.js",
"build:scripts": "pnpm run -F=browser-scripts build",
"clean": "gatsby clean",
"predevelop": "pnpm run build:workers --env development && pnpm run build:components-library",
"predevelop": "pnpm run build:scripts --env development && pnpm run build:components-library",
"build:components-library": "pnpm run -F=@freecodecamp/ui build",
"develop": "cross-env NODE_OPTIONS=\"--max-old-space-size=5000\" gatsby develop --inspect=9230",
"lint": "ts-node ./i18n/schema-validation.ts",
@@ -71,7 +71,6 @@
"crypto-browserify": "3.12.0",
"date-fns": "2.30.0",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"final-form": "4.20.9",
"gatsby": "3.15.0",
"gatsby-cli": "3.15.0",

View File

@@ -36,7 +36,8 @@ const iconMap = {
[SuperBlocks.ProjectEuler]: Graduation,
[SuperBlocks.CollegeAlgebraPy]: CollegeAlgebra,
[SuperBlocks.FoundationalCSharp]: CSharpLogo,
[SuperBlocks.ExampleCertification]: ResponsiveDesign
[SuperBlocks.ExampleCertification]: ResponsiveDesign,
[SuperBlocks.UpcomingPython]: PythonIcon
};
const generateIconComponent = (

View File

@@ -0,0 +1,9 @@
---
title: Upcoming Python Certification
superBlock: upcoming-python
certification: upcoming-python
---
## Upcoming Python Certification
Learn the basics of Python.

View File

@@ -0,0 +1,9 @@
---
title: Introduction to Learn Python by Building a Blackjack Game
block: learn-python-by-building-a-blackjack-game
superBlock: scientific-computing-with-python
---
## Introduction to Learn Python by Building a Blackjack Game
Learn Python!

View File

@@ -28,6 +28,7 @@ const machineLearningPyBase =
const collegeAlgebraPyBase = '/learn/college-algebra-with-python';
const takeHomeBase = '/learn/coding-interview-prep/take-home-projects';
const foundationalCSharpBase = '/learn/foundational-c-sharp-with-microsoft';
const upcomingPythonBase = '/learn/upcoming-python';
const exampleCertBase = '/learn/example-certification';
const legacyFrontEndBase = feLibsBase;
const legacyFrontEndResponsiveBase = responsiveWebBase;
@@ -769,6 +770,19 @@ const upcomingCertMap = [
certSlug: 'example-certification-v8'
}
]
},
{
id: '64afc4e8f3b37856e035b85f',
title: 'Upcoming Python Certification',
certSlug: 'upcoming-python-v8',
projects: [
{
id: '64afc37bf3b37856e035b85e',
title: 'Upcoming Python Project',
link: `${upcomingPythonBase}/upcoming-python-project`,
certSlug: 'upcoming-python-v8'
}
]
}
] as const;

View File

@@ -11,9 +11,8 @@ import {
challengeFilesSelector
} from '../redux/selectors';
type VisibleEditors = {
[key: string]: boolean;
};
import type { VisibleEditors } from './multifile-editor';
interface EditorTabsProps {
challengeFiles: ChallengeFiles;
toggleVisibleEditor: typeof toggleVisibleEditor;
@@ -43,7 +42,13 @@ class EditorTabs extends Component<EditorTabsProps> {
{sortChallengeFiles(challengeFiles).map(
(challengeFile: ChallengeFile) => (
<button
aria-expanded={visibleEditors[challengeFile.fileKey] ?? 'false'}
aria-expanded={
// @ts-expect-error TODO: validate challengeFile on io-boundary,
// then we won't need to ignore this error and we can drop the
// nullish handling.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
visibleEditors[challengeFile.fileKey] ?? 'false'
}
key={challengeFile.fileKey}
data-cy={`editor-tab-${challengeFile.fileKey}`}
onClick={() => toggleVisibleEditor(challengeFile.fileKey)}

View File

@@ -187,7 +187,9 @@ const modeMap = {
css: 'css',
html: 'html',
js: 'javascript',
jsx: 'javascript'
jsx: 'javascript',
py: 'python',
python: 'python'
};
let monacoThemesDefined = false;

View File

@@ -17,8 +17,12 @@ import { FileKey } from '../../../redux/prop-types';
import { Themes } from '../../../components/settings/theme';
import Editor, { type EditorProps } from './editor';
type VisibleEditors = {
[key: string]: boolean;
export type VisibleEditors = {
indexhtml?: boolean;
indexjsx?: boolean;
stylescss?: boolean;
scriptjs?: boolean;
mainpy?: boolean;
};
type MultifileEditorProps = Pick<
EditorProps,
@@ -36,12 +40,7 @@ type MultifileEditorProps = Pick<
// We use dimensions to trigger a re-render of the editor
| 'dimensions'
> & {
visibleEditors: {
indexhtml?: boolean;
indexjsx?: boolean;
stylescss?: boolean;
scriptjs?: boolean;
};
visibleEditors: VisibleEditors;
};
const mapStateToProps = createSelector(
visibleEditorsSelector,
@@ -74,7 +73,7 @@ const MultifileEditor = (props: MultifileEditorProps) => {
isUsingKeyboardInTablist,
resizeProps,
title,
visibleEditors: { stylescss, indexhtml, scriptjs, indexjsx },
visibleEditors: { stylescss, indexhtml, scriptjs, indexjsx, mainpy },
usesMultifileEditor,
showProjectPreview
} = props;
@@ -98,6 +97,7 @@ const MultifileEditor = (props: MultifileEditorProps) => {
if (indexhtml) editorKeys.push('indexhtml');
if (stylescss) editorKeys.push('stylescss');
if (scriptjs) editorKeys.push('scriptjs');
if (mainpy) editorKeys.push('mainpy');
const editorAndSplitterKeys = editorKeys.reduce((acc: string[] | [], key) => {
if (acc.length === 0) {

View File

@@ -222,10 +222,14 @@ function ShowClassic({
`intro:${superBlock}.blocks.${block}.title`
)}: ${title}`;
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
// TODO: show preview should NOT be computed like this. That determination is
// made during the build (at least twice!). It should be either a prop or
// computed from challengeType
const showPreview =
challengeType === challengeTypes.html ||
challengeType === challengeTypes.modern ||
challengeType === challengeTypes.multifileCertProject;
challengeType === challengeTypes.multifileCertProject ||
challengeType === challengeTypes.python;
const getLayoutState = () => {
const reflexLayout = store.get(REFLEX_LAYOUT) as ReflexLayout;

View File

@@ -42,3 +42,11 @@ A required file can not have both a src and a link: src = ${src}, link = ${link}
embedSource({ source: contents }) || ''
}${testRunnerScript}`;
}
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>`;
}

View File

@@ -0,0 +1,120 @@
export const indent = (code, spaces) => {
const lines = code.split('\n');
return lines.map(line => `${' '.repeat(spaces)}${line}`).join('\n');
};
// Requirements:
// - run in a single instance of pyodide (because loadPyodide is slow)
// - be able to stop execution of learner code
//
// This wrapper lets us meet the second requirement, since tasks are
// cancellable. This creates a second issue: the learner code no longer modifies
// the global scope, so we need to copy the locals to globals.
//
// Finally, we have to await the task, or there's no way for the JavaScript
// context to know when the task is complete.
export const makeCancellable = code => `import asyncio
async def cancellable_coroutine():
try:
${indent(code, 8)}
globals()['__locals'] = locals()
except asyncio.CancelledError:
pass
__task = asyncio.create_task(cancellable_coroutine())
def __cancel():
__task.cancel()
await __task`;
export function modifyInputStatements(line) {
// Use a regular expression to match input statements with chained methods
const inputRegex = /(.*=\s*)input\((["'].*?["']\))(\.\w+\([^)]*\))*/;
const match = line.match(inputRegex);
if (match) {
const inputStatement = match[0];
const varAssignment = match[1];
const inputCall =
'input' +
inputStatement
.slice(varAssignment.length)
.split('input')[1]
.split('.')[0];
const methods = inputStatement
.slice(varAssignment.length + inputCall.length)
.split('.')
.slice(1);
const tempVar = '_temp_input_var';
const newStatements = [
`${tempVar} = ${inputCall}`,
...methods.map(method => `${tempVar} = ${tempVar}.${method}`),
`${varAssignment.trim()} ${tempVar}`
];
// Get the indentation of the original line
const indentation = line.match(/^\s*/)[0];
// Apply the same indentation to each new statement
const indentedStatements = newStatements.map(stmt => indentation + stmt);
// Replace the original input statement in the line with the temporary variable
const updatedLine = line.replace(
inputStatement,
indentedStatements.join('\n')
);
return updatedLine.split('\n');
}
return [line];
}
export function makeInputAwaitable(code) {
const lines = code.split('\n');
const asyncFunctions = new Set();
const modifiedLines = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Modify input statements with chained methods
const updatedLines = modifyInputStatements(line);
// If the line contains an input statement, update it to use "await"
if (updatedLines.some(updatedLine => updatedLine.includes('input('))) {
updatedLines.forEach((updatedLine, index) => {
if (updatedLine.includes('input(')) {
updatedLines[index] = updatedLine.replace('input(', 'await input(');
}
});
// Find the outer function definition and make it async
for (let j = i - 1; j >= 0; j--) {
if (lines[j].includes('def ')) {
if (!modifiedLines[j].includes('async def ')) {
const functionName = lines[j].match(
/def\s+([a-zA-Z_][a-zA-Z_0-9]*)/
)[1];
asyncFunctions.add(functionName);
modifiedLines[j] = modifiedLines[j].replace('def ', 'async def ');
}
break;
}
}
}
// Update function calls to include 'await' for async functions
asyncFunctions.forEach(funcName => {
updatedLines.forEach((updatedLine, index) => {
if (
updatedLine.includes(` ${funcName}(`) &&
!updatedLine.includes(`await ${funcName}(`)
) {
updatedLines[index] = updatedLine.replace(
`${funcName}(`,
`await ${funcName}(`
);
}
});
});
modifiedLines.push(...updatedLines);
}
return modifiedLines.join('\n');
}

View File

@@ -0,0 +1,41 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { indent, makeCancellable } from './transform-python';
describe('transform-python', () => {
describe('indent', () => {
it('should indent n spaces', () => {
const inputCode = `def foo():
print('bar')`;
const fourSpaces = ` def foo():
print('bar')`;
const eightSpaces = ` def foo():
print('bar')`;
expect(indent(inputCode, 4)).toEqual(fourSpaces);
expect(indent(inputCode, 8)).toEqual(eightSpaces);
});
});
describe('makeCancellable', () => {
it('should wrap a code string in a cancellable coroutine', () => {
const inputCode = `def foo():
print('bar')`;
const wrappedCode = `import asyncio
async def cancellable_coroutine():
try:
def foo():
print('bar')
globals()['__locals'] = locals()
except asyncio.CancelledError:
pass
__task = asyncio.create_task(cancellable_coroutine())
def __cancel():
__task.cancel()
await __task`;
expect(makeCancellable(inputCode)).toEqual(wrappedCode);
});
});
});

View File

@@ -19,6 +19,7 @@ import {
compileHeadTail
} from '../../../../../utils/polyvinyl';
import createWorker from '../utils/worker-executor';
import { makeCancellable, makeInputAwaitable } from './transform-python';
const { filename: sassCompile } = sassData;
@@ -98,6 +99,7 @@ const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const testJS = matchesProperty('ext', 'js');
const testJSX = matchesProperty('ext', 'jsx');
const testHTML = matchesProperty('ext', 'html');
const testPython = matchesProperty('ext', 'py');
const testHTML$JS$JSX = overSome(testHTML, testJS, testJSX);
const replaceNBSP = cond([
@@ -297,9 +299,26 @@ const htmlTransformer = cond([
[stubTrue, identity]
]);
const transformPython = async function (file) {
const awaitableCode = makeInputAwaitable(file.contents);
const cancellableCode = makeCancellable(awaitableCode);
return transformContents(() => cancellableCode, file);
};
const pythonTransformer = cond([
[testPython, transformPython],
[stubTrue, identity]
]);
export const getTransformers = loopProtectOptions => [
replaceNBSP,
babelTransformer(loopProtectOptions),
partial(compileHeadTail, ''),
htmlTransformer
];
export const getPythonTransformers = () => [
replaceNBSP,
partial(compileHeadTail, ''),
pythonTransformer
];

View File

@@ -34,6 +34,7 @@ import {
updatePreview,
updateProjectPreview
} from '../utils/build';
import { runPythonInFrame, mainPreviewId } from '../utils/frame';
import { actionTypes } from './action-types';
import {
disableBuildOnError,
@@ -241,6 +242,15 @@ function* previewChallengeSaga({ flushLogs = true } = {}) {
const finalDocument = portalDocument || document;
yield call(updatePreview, buildData, finalDocument, proxyLogger);
// Python challenges need to be created in two steps:
// 1) build the frame
// 2) evaluate the code in the frame. This is necessary to avoid
// recreating the frame (which is slow since loadPyodide takes a long
// time)on every change.
if (challengeData.challengeType === challengeTypes.python) {
yield updatePython(challengeData);
}
} else if (isJavaScriptChallenge(challengeData)) {
const runUserCode = getTestRunner(buildData, {
proxyLogger,
@@ -251,6 +261,7 @@ function* previewChallengeSaga({ flushLogs = true } = {}) {
}
}
} catch (err) {
console.log('previewChallengeSaga error', err);
if (err[0] === 'timeout') {
// TODO: translate the error
// eslint-disable-next-line no-ex-assign
@@ -261,6 +272,32 @@ function* previewChallengeSaga({ flushLogs = true } = {}) {
}
}
function* updatePreviewSaga() {
const challengeData = yield select(challengeDataSelector);
if (challengeData.challengeType === challengeTypes.python) {
yield updatePython(challengeData);
} else {
// all other challenges have to recreate the preview
yield previewChallengeSaga();
}
}
function* updatePython(challengeData) {
const document = yield getContext('document');
// TODO: refactor the build pipeline so that we have discrete, composable
// functions to handle transforming code, embedding it and building the
// final html. Then we can just use the transformation function here.
const buildData = yield buildChallengeData(challengeData);
const code = buildData.transformedPython;
// TODO: proxy errors to the console
try {
yield call(runPythonInFrame, document, code, mainPreviewId);
} catch (err) {
console.log('Error evaluating python code', code);
console.log('Message:', err.message);
}
}
function* previewProjectSolutionSaga({ payload }) {
if (!payload) return;
const { showProjectPreview, challengeData } = payload;
@@ -282,8 +319,9 @@ function* previewProjectSolutionSaga({ payload }) {
export function createExecuteChallengeSaga(types) {
return [
takeLatest(types.executeChallenge, executeCancellableChallengeSaga),
takeLatest(types.updateFile, updatePreviewSaga),
takeLatest(
[types.updateFile, types.challengeMounted, types.resetChallenge],
[types.challengeMounted, types.resetChallenge],
previewChallengeSaga
),
takeLatest(types.previewMounted, previewChallengeSaga, {

View File

@@ -82,7 +82,8 @@ export const challengeDataSelector = state => {
} else if (
challengeType === challengeTypes.html ||
challengeType === challengeTypes.modern ||
challengeType === challengeTypes.multifileCertProject
challengeType === challengeTypes.multifileCertProject ||
challengeType === challengeTypes.python
) {
const { required = [], template = '' } = challengeMetaSelector(state);
challengeData = {

View File

@@ -1,12 +1,18 @@
import { challengeTypes } from '../../../../../config/challenge-types';
import frameRunnerData from '../../../../../config/client/frame-runner.json';
import testEvaluatorData from '../../../../../config/client/test-evaluator.json';
import { challengeTypes } from '../../../../../config/challenge-types';
import pythonRunnerData from '../../../../../config/client/python-runner.json';
import {
ChallengeFile as PropTypesChallengeFile,
ChallengeMeta
} from '../../../redux/prop-types';
import { concatHtml } from '../rechallenge/builders';
import { getTransformers, embedFilesInHtml } from '../rechallenge/transformers';
import { concatHtml, createPythonTerminal } from '../rechallenge/builders';
import {
getTransformers,
embedFilesInHtml,
getPythonTransformers
} from '../rechallenge/transformers';
import {
createTestFramer,
runTestInTestFrame,
@@ -41,10 +47,11 @@ interface BuildOptions {
usesTestRunner: boolean;
}
const { filename: runner } = frameRunnerData;
const { filename: testEvaluator } = testEvaluatorData;
const frameRunnerSrc = `/js/${runner}.js`;
const frameRunnerSrc = `/js/${frameRunnerData.filename}.js`;
const pythonRunnerSrc = `/js/${pythonRunnerData.filename}.js`;
type ApplyFunctionProps = (file: ChallengeFile) => Promise<ChallengeFile>;
@@ -67,6 +74,8 @@ const applyFunction =
const composeFunctions = (...fns: ApplyFunctionProps[]) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g));
// TODO: split this into at least two functions. One to create 'original' i.e.
// the source and another to create the contents.
function buildSourceMap(challengeFiles: ChallengeFiles): Source | undefined {
// TODO: rename sources.index to sources.contents.
const source: Source | undefined = challengeFiles?.reduce(
@@ -77,7 +86,11 @@ function buildSourceMap(challengeFiles: ChallengeFiles): Source | undefined {
sources.editableContents += challengeFile.editableContents || '';
return sources;
},
{ index: '', editableContents: '', original: {} } as Source
{
index: '',
editableContents: '',
original: {}
} as Source
);
return source;
}
@@ -101,7 +114,8 @@ const buildFunctions = {
[challengeTypes.backEndProject]: buildBackendChallenge,
[challengeTypes.pythonProject]: buildBackendChallenge,
[challengeTypes.multifileCertProject]: buildDOMChallenge,
[challengeTypes.colab]: buildBackendChallenge
[challengeTypes.colab]: buildBackendChallenge,
[challengeTypes.python]: buildPythonChallenge
};
export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
@@ -192,6 +206,9 @@ type BuildResult = {
sources: Source | undefined;
};
// 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 function buildDOMChallenge(
{ challengeFiles, required = [], template = '' }: BuildChallengeData,
{ usesTestRunner } = { usesTestRunner: false }
@@ -206,24 +223,24 @@ export function buildDOMChallenge(
if (finalFiles) {
return Promise.all(finalFiles)
.then(checkFilesErrors)
.then(embedFilesInHtml)
.then(([_challengeFiles, _contents]) => {
const challengeFiles = _challengeFiles as ChallengeFiles;
const contents = _contents as string;
return {
challengeType:
challengeTypes.html || challengeTypes.multifileCertProject,
build: concatHtml({
required,
template,
contents,
...(usesTestRunner && { testRunner: frameRunnerSrc })
}),
sources: buildSourceMap(challengeFiles),
loadEnzyme
};
});
.then(
embedFilesInHtml as (
x: ChallengeFiles
) => Promise<[ChallengeFiles, string]>
)
.then(([challengeFiles, contents]) => ({
// TODO: Stop overwriting challengeType with 'html'. Figure out why it's
// necessary at the moment.
challengeType: challengeTypes.html,
build: concatHtml({
required,
template,
contents,
...(usesTestRunner && { testRunner: frameRunnerSrc })
}),
sources: buildSourceMap(challengeFiles),
loadEnzyme
}));
}
}
@@ -263,20 +280,57 @@ function buildBackendChallenge({ url }: BuildChallengeData) {
};
}
function getTransformedPython(challengeFiles: ChallengeFiles) {
return challengeFiles[0].contents;
}
export function buildPythonChallenge({
challengeFiles
}: BuildChallengeData): Promise<BuildResult> | undefined {
const pipeLine = composeFunctions(...getPythonTransformers());
const finalFiles = challengeFiles.map(pipeLine);
if (finalFiles) {
return (
Promise.all(finalFiles)
.then(checkFilesErrors)
// Unlike the DOM challenges, there's no need to embed the files in HTML
.then(challengeFiles => ({
// TODO: Stop overwriting challengeType with 'html'. Figure out why it's
// necessary at the moment.
challengeType: challengeTypes.html,
// Both the terminal and pyodide are loaded into the browser, so we
// still need to build the HTML.
build: createPythonTerminal(pythonRunnerSrc),
sources: buildSourceMap(challengeFiles),
transformedPython: getTransformedPython(challengeFiles)
}))
);
}
}
export function updatePreview(
buildData: BuildChallengeData,
document: Document,
proxyLogger: ProxyLogger
): void {
): Promise<void> {
// TODO: either create a 'buildType' or use the real challengeType here
// (buildData.challengeType is set to 'html' for challenges that can be
// previewed, hence this being true for python challenges, multifile steps and
// so on).
if (
buildData.challengeType === challengeTypes.html ||
buildData.challengeType === challengeTypes.multifileCertProject
) {
createMainPreviewFramer(
document,
proxyLogger,
getDocumentTitle(buildData)
)(buildData);
return new Promise<void>(resolve =>
createMainPreviewFramer(
document,
proxyLogger,
getDocumentTitle(buildData),
resolve
)(buildData)
);
} else {
throw new Error(
`Cannot show preview for challenge type ${buildData.challengeType}`
@@ -318,7 +372,8 @@ export function challengeHasPreview({ challengeType }: ChallengeMeta): boolean {
return (
challengeType === challengeTypes.html ||
challengeType === challengeTypes.modern ||
challengeType === challengeTypes.multifileCertProject
challengeType === challengeTypes.multifileCertProject ||
challengeType === challengeTypes.python
);
}

View File

@@ -1,21 +1,14 @@
import { toString, flow } from 'lodash-es';
import i18next, { i18n } from 'i18next';
import i18next, { type i18n } from 'i18next';
import { format } from '../../../utils/format';
import type {
FrameDocument,
PythonDocument
} from '../../../../../tools/client-plugins/browser-scripts';
const utilsFormat: <T>(x: T) => string = format;
declare global {
interface Window {
console: {
log: () => void;
info: () => void;
warn: () => void;
error: () => void;
};
i18nContent: i18n;
}
}
export interface Source {
index: string;
contents?: string;
@@ -24,12 +17,15 @@ export interface Source {
}
export interface Context {
window: Window;
document: Document;
window?: Window &
// eslint-disable-next-line @typescript-eslint/naming-convention
typeof globalThis & { i18nContent?: i18n; __pyodide: unknown };
document?: FrameDocument | PythonDocument;
element: HTMLIFrameElement;
build: string;
sources: Source;
loadEnzyme?: () => void;
transformedPython?: string;
}
export interface TestRunnerConfig {
@@ -139,24 +135,44 @@ type TestResult =
| { pass: boolean }
| { err: { message: string; stack?: string } };
function getContentDocument<T extends Document = FrameDocument>(
document: Document,
id: string
) {
const frame = document.getElementById(id);
if (!frame) return null;
const frameDocument = (frame as HTMLIFrameElement).contentDocument;
return frameDocument as T;
}
export const runTestInTestFrame = async function (
document: Document,
test: string,
timeout: number
): Promise<TestResult | undefined> {
const { contentDocument: frame } = document.getElementById(
testId
) as HTMLIFrameElement;
if (frame !== null) {
const contentDocument = getContentDocument(document, testId);
if (contentDocument) {
return await Promise.race([
new Promise<
{ pass: boolean } | { err: { message: string; stack?: string } }
>((_, reject) => setTimeout(() => reject('timeout'), timeout)),
frame.__runTest(test)
contentDocument.__runTest(test)
]);
}
};
export const runPythonInFrame = function (
document: Document,
code: string,
previewId: string
): void {
const contentDocument = getContentDocument<PythonDocument>(
document,
previewId
);
void contentDocument?.__runPython(code);
};
const createFrame =
(document: Document, id: string, title?: string) =>
(frameContext: Context) => {
@@ -253,45 +269,56 @@ const updateWindowI18next = () => (frameContext: Context) => {
const initTestFrame = (frameReady?: () => void) => (frameContext: Context) => {
waitForFrame(frameContext)
.then(async () => {
const { sources, loadEnzyme } = frameContext;
const { sources, loadEnzyme, transformedPython } = frameContext;
// provide the file name and get the original source
const getUserInput = (fileName: string) =>
toString(sources[fileName as keyof typeof sources]);
await frameContext.document.__initTestFrame({
await frameContext.document?.__initTestFrame({
code: sources,
getUserInput,
loadEnzyme
loadEnzyme,
transformedPython
});
if (frameReady) {
frameReady();
}
if (frameReady) frameReady();
})
.catch(handleDocumentNotFound);
return frameContext;
};
const initMainFrame =
(_: unknown, proxyLogger?: ProxyLogger) => (frameContext: Context) => {
(frameReady?: () => void, proxyLogger?: ProxyLogger) =>
(frameContext: Context) => {
waitForFrame(frameContext)
.then(() => {
.then(async () => {
// Overwriting the onerror added by createHeader to catch any errors thrown
// after the frame is ready. It has to be overwritten, as proxyLogger cannot
// be added as part of createHeader.
frameContext.window.onerror = function (msg) {
if (typeof msg === 'string') {
const string = msg.toLowerCase();
if (string.includes('script error')) {
msg = 'Error, open your browser console to learn more.';
if (frameContext.window) {
frameContext.window.onerror = function (msg) {
if (typeof msg === 'string') {
const string = msg.toLowerCase();
if (string.includes('script error')) {
msg = 'Error, open your browser console to learn more.';
}
if (proxyLogger) {
proxyLogger(msg);
}
}
if (proxyLogger) {
proxyLogger(msg);
}
}
// let the error propagate so it appears in the browser console, otherwise
// an error from a cross origin script just appears as 'Script error.'
return false;
};
// let the error propagate so it appears in the browser console, otherwise
// an error from a cross origin script just appears as 'Script error.'
return false;
};
}
if (
frameContext.document &&
'__initPythonFrame' in frameContext.document
) {
await frameContext.document?.__initPythonFrame();
}
if (frameReady) frameReady();
})
.catch(handleDocumentNotFound);
return frameContext;
@@ -305,6 +332,21 @@ function handleDocumentNotFound(err: string) {
const initPreviewFrame = () => (frameContext: Context) => frameContext;
// TODO: reimplement when ready to preview python challenges
// const initPreviewFrame = () => (frameContext: Context) => {
// waitForFrame(frameContext)
// .then(() => {
// if (
// frameContext.document &&
// '__initPythonFrame' in frameContext.document
// ) {
// void frameContext.document?.__initPythonFrame();
// }
// })
// .catch(handleDocumentNotFound);
// return frameContext;
// };
const waitForFrame = (frameContext: Context) => {
return new Promise((resolve, reject) => {
if (!frameContext.document) {
@@ -317,7 +359,7 @@ const waitForFrame = (frameContext: Context) => {
});
};
function writeToFrame(content: string, frame: Document | null) {
function writeToFrame(content: string, frame?: FrameDocument) {
// it's possible, if the preview is rapidly opened and closed, for the frame
// to be null at this point.
if (frame) {
@@ -344,14 +386,15 @@ const writeContentToFrame = (frameContext: Context) => {
export const createMainPreviewFramer = (
document: Document,
proxyLogger: ProxyLogger,
frameTitle: string
frameTitle: string,
frameReady?: () => void
): ((args: Context) => void) =>
createFramer(
document,
mainPreviewId,
initMainFrame,
proxyLogger,
undefined,
frameReady,
frameTitle
);

View File

@@ -80,8 +80,9 @@ const CertChallenge = ({
const [isCertified, setIsCertified] = useState(false);
const [userLoaded, setUserLoaded] = useState(false);
// @ts-expect-error Typescript is confused
const certSlug = fullCertMap.find(x => x.title === title).certSlug;
const cert = fullCertMap.find(x => x.title === title);
if (!cert) throw Error(`Certification ${title} not found`);
const certSlug = cert.certSlug;
useEffect(() => {
const { pending, complete } = fetchState;

View File

@@ -147,7 +147,9 @@ function getProjectPreviewConfig(challenge, allChallengeEdges) {
showProjectPreview:
challengeOrder === 0 &&
usesMultifileEditor &&
challengeType !== challengeTypes.multifileCertProject,
challengeType !== challengeTypes.multifileCertProject &&
// TODO: revert this to enable project previews for python challenges
challengeType !== challengeTypes.python,
challengeData: {
challengeType: lastChallenge.challengeType,
challengeFiles: projectPreviewChallengeFiles

View File

@@ -19,6 +19,7 @@ const colab = 16;
const exam = 17;
const msTrophyUrl = 18;
const multipleChoice = 19;
const python = 20;
export const challengeTypes = {
html,
@@ -41,7 +42,8 @@ export const challengeTypes = {
colab,
exam,
msTrophyUrl,
multipleChoice
multipleChoice,
python
};
export const isFinalProject = (challengeType: number) => {
@@ -91,7 +93,8 @@ export const viewTypes = {
[colab]: 'frontend',
[exam]: 'exam',
[msTrophyUrl]: 'frontend',
[multipleChoice]: 'video'
[multipleChoice]: 'video',
[python]: 'modern'
};
// determine the type of submit function to use for the challenge on completion
@@ -118,5 +121,6 @@ export const submitTypes = {
[colab]: 'project.backEnd',
[exam]: 'exam',
[msTrophyUrl]: 'project.frontEnd',
[multipleChoice]: 'tests'
[multipleChoice]: 'tests',
[python]: 'tests'
};

View File

@@ -20,7 +20,8 @@ export enum SuperBlocks {
ProjectEuler = 'project-euler',
CollegeAlgebraPy = 'college-algebra-with-python',
FoundationalCSharp = 'foundational-c-sharp-with-microsoft',
ExampleCertification = 'example-certification'
ExampleCertification = 'example-certification',
UpcomingPython = 'upcoming-python'
}
/*
@@ -75,7 +76,8 @@ export const superBlockOrder: SuperBlockOrder = {
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp,
SuperBlocks.ExampleCertification
SuperBlocks.ExampleCertification,
SuperBlocks.UpcomingPython
]
};
@@ -97,7 +99,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.Chinese]: [
SuperBlocks.CollegeAlgebraPy,
@@ -105,7 +108,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.ChineseTraditional]: [
SuperBlocks.CollegeAlgebraPy,
@@ -113,29 +117,34 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.Italian]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.Portuguese]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.Ukrainian]: [
SuperBlocks.CodingInterviewPrep,
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.Japanese]: [
SuperBlocks.CollegeAlgebraPy,
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.German]: [
SuperBlocks.RespWebDesignNew,
@@ -152,7 +161,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.Arabic]: [
SuperBlocks.DataVis,
@@ -168,7 +178,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp
SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
],
[Languages.Swahili]: [
SuperBlocks.DataVis,
@@ -187,7 +198,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.RespWebDesign,
SuperBlocks.FrontEndDevLibs,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.JsAlgoDataStruct
SuperBlocks.JsAlgoDataStruct,
SuperBlocks.UpcomingPython
]
};

View File

@@ -0,0 +1,32 @@
{
"name": "Learn Python By Building a Blackjack Game",
"isUpcomingChange": true,
"usesMultifileEditor": true,
"hasEditableBoundaries": true,
"dashedName": "learn-python-by-building-a-blackjack-game",
"helpCategory": "Python",
"order": 0,
"time": "2 hours",
"template": "",
"required": [],
"superBlock": "upcoming-python",
"isBeta": true,
"challengeOrder": [
{
"id": "5daa813381b9e3db6c126b43",
"title": "Step 1"
},
{
"id": "64a5229b99ff0e8250cd9a72",
"title": "Step 2"
},
{
"id": "64b163c20e59cbd4a64940b0",
"title": "Step 3"
},
{
"id": "64b171849f925b0773aa434c",
"title": "Step 4"
}
]
}

View File

@@ -232,4 +232,4 @@
"title": "Data Visualization: Mailing Lists"
}
]
}
}

View File

@@ -28,4 +28,4 @@
"title": "Probability Calculator"
}
]
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "Upcoming Python Project",
"isUpcomingChange": true,
"dashedName": "upcoming-python-project",
"helpCategory": "Python",
"order": 1,
"time": "2 hours",
"template": "",
"required": [],
"superBlock": "upcoming-python",
"challengeOrder": [
{
"id": "64afc37bf3b37856e035b85e",
"title": "Upcoming Python Project"
}
]}

View File

@@ -0,0 +1,9 @@
---
id: 64afc4e8f3b37856e035b85f
title: Upcoming Python Certification
certification: upcoming-python-certification
challengeType: 7
isPrivate: true
tests:
- id: 64afc37bf3b37856e035b85e
title: Upcoming Python Project

View File

@@ -0,0 +1,46 @@
---
id: 5daa813381b9e3db6c126b43
title: Step 1
challengeType: 20
dashedName: step-1
---
# --description--
PYTHON
Set the `hello` variable to "world". Then print the value.
# --hints--
The `hello` variable should equal "world".
```js
({ test: () => assert.equal(__userGlobals.get("hello"), "world") })
```
# --seed--
## --seed-contents--
```py
--fcc-editable-region--
one = 1
hello = "goodbye"
def a_function():
local_thing = "world"
print(local_thing)
a_function()
print(hello)
--fcc-editable-region--
```
# --solutions--
```py
one = 1
hello = "world"
print(hello)
```

View File

@@ -0,0 +1,40 @@
---
id: 64a5229b99ff0e8250cd9a72
title: Step 2
challengeType: 20
dashedName: step-2
---
# --description--
step 2 instructions
# --hints--
Test 1
```js
({ input: ["Beau", "Carnes"], test: () => {
assert.equal( "Beau", __userGlobals.get("name"));
assert.equal( "Carnes", __userGlobals.get("last_name"));
} })
```
# --seed--
## --seed-contents--
```py
--fcc-editable-region--
name = input('What is your name?')
print('Hi ' + name)
--fcc-editable-region--
```
# --solutions--
```py
name = input('What is your name?')
last_name = input('What is your last name?')
print('Hi ' + name + ' ' + last_name)
```

View File

@@ -0,0 +1,36 @@
---
id: 64b163c20e59cbd4a64940b0
title: Step 3
challengeType: 20
dashedName: step-3
---
# --description--
Create a function to add two numbers together.
# --hints--
Adding 3 and 5 should return 8.
```js
({ test: () => assert.equal(__userGlobals.get("add")(3,5), 8) })
```
# --seed--
## --seed-contents--
```py
--fcc-editable-region--
--fcc-editable-region--
```
# --solutions--
```py
def add(a, b):
return a + b
```

View File

@@ -0,0 +1,34 @@
---
id: 64b171849f925b0773aa434c
title: Step 4
challengeType: 20
dashedName: step-4
---
# --description--
Create an list of numbers from 1 to 3 and assign it to the variable `xs`.
# --hints--
`xs` should be the list `[1,2,3]`.
```js
({ test: () => assert.deepEqual(Array.from(__userGlobals.get("xs")), [1,2,3])})
```
# --seed--
## --seed-contents--
```py
--fcc-editable-region--
--fcc-editable-region--
```
# --solutions--
```py
xs = [1,2,3]
```

View File

@@ -0,0 +1,40 @@
---
id: 64afc37bf3b37856e035b85e
title: Upcoming Python Project
challengeType: 20
dashedName: upcoming-python-project
---
# --description--
Python Project instructions
# --hints--
Test 1
```js
({ input: ["Beau", "Carnes"], test: () => {
assert.equal( "Beau", __userGlobals.get("name"));
assert.equal( "Carnes", __userGlobals.get("last_name"));
} })
```
# --seed--
## --seed-contents--
```py
--fcc-editable-region--
name = input('What is your name?')
print('Hi ' + name)
--fcc-editable-region--
```
# --solutions--
```py
name = input('What is your name?')
last_name = input('What is your last name?')
print('Hi ' + name + ' ' + last_name)
```

View File

@@ -33,7 +33,7 @@ const schema = Joi.object()
challengeOrder: Joi.number(),
removeComments: Joi.bool().required(),
certification: Joi.string().regex(slugRE),
challengeType: Joi.number().min(0).max(19).required(),
challengeType: Joi.number().min(0).max(20).required(),
checksum: Joi.number(),
// TODO: require this only for normal challenges, not certs
dashedName: Joi.string().regex(slugRE),

View File

@@ -25,7 +25,8 @@ require('@babel/register')({
});
const {
buildDOMChallenge,
buildJSChallenge
buildJSChallenge,
buildPythonChallenge
} = require('../../client/src/templates/Challenges/utils/build');
const {
default: createWorker
@@ -319,12 +320,14 @@ function populateTestsForLang({ lang, challenges, meta }) {
});
const { challengeType } = challenge;
// TODO: shouldn't this be a function in challenge-types.js?
if (
challengeType !== challengeTypes.html &&
challengeType !== challengeTypes.js &&
challengeType !== challengeTypes.jsProject &&
challengeType !== challengeTypes.modern &&
challengeType !== challengeTypes.backend
challengeType !== challengeTypes.backend &&
challengeType !== challengeTypes.python
) {
return;
}
@@ -349,14 +352,21 @@ function populateTestsForLang({ lang, challenges, meta }) {
return;
}
// TODO(after python PR): simplify pipeline and sync with client.
// buildChallengeData should be called and any errors handled.
// canBuildChallenge does not need to exist independently.
const buildChallenge =
challengeType === challengeTypes.js ||
challengeType === challengeTypes.jsProject
? buildJSChallenge
: buildDOMChallenge;
{
[challengeTypes.js]: buildJSChallenge,
[challengeTypes.jsProject]: buildJSChallenge,
[challengeTypes.python]: buildPythonChallenge
}[challengeType] ?? buildDOMChallenge;
// The python tests are (currently) slow, so we give them more time.
const timePerTest =
challengeType === challengeTypes.python ? 10000 : 5000;
it('Test suite must fail on the initial contents', async function () {
this.timeout(5000 * tests.length + 1000);
this.timeout(timePerTest * tests.length + 1000);
// suppress errors in the console.
const oldConsoleError = console.error;
console.error = () => {};
@@ -445,7 +455,7 @@ function populateTestsForLang({ lang, challenges, meta }) {
it(`Solution ${
index + 1
} must pass the tests`, async function () {
this.timeout(5000 * tests.length + 2000);
this.timeout(timePerTest * tests.length + 2000);
const testRunner = await createTestRunner(
challenge,
solution,
@@ -477,22 +487,27 @@ async function createTestRunner(
solutionFiles
);
const { build, sources, loadEnzyme } = await buildChallenge(
{
challengeFiles,
required,
template
},
{ usesTestRunner: true }
);
const { build, sources, loadEnzyme, transformedPython } =
await buildChallenge(
{
challengeFiles,
required,
template
},
{ usesTestRunner: true }
);
const code = {
contents: sources.index,
editableContents: sources.editableContents
};
const evaluator = await (buildChallenge === buildDOMChallenge
? getContextEvaluator(build, sources, code, loadEnzyme)
const runsInBrowser =
buildChallenge === buildDOMChallenge ||
buildChallenge === buildPythonChallenge;
const evaluator = await (runsInBrowser
? getContextEvaluator(build, sources, code, loadEnzyme, transformedPython)
: getWorkerEvaluator(build, sources, code, removeComments));
return async ({ text, testString }) => {
@@ -537,8 +552,20 @@ function replaceChallengeFilesContentsWithSolutions(
});
}
async function getContextEvaluator(build, sources, code, loadEnzyme) {
await initializeTestRunner(build, sources, code, loadEnzyme);
async function getContextEvaluator(
build,
sources,
code,
loadEnzyme,
transformedPython
) {
await initializeTestRunner(
build,
sources,
code,
loadEnzyme,
transformedPython
);
return {
evaluate: async (testString, timeout) =>
@@ -564,20 +591,30 @@ async function getWorkerEvaluator(build, sources, code, removeComments) {
};
}
async function initializeTestRunner(build, sources, code, loadEnzyme) {
async function initializeTestRunner(
build,
sources,
code,
loadEnzyme,
transformedPython
) {
await page.reload();
await page.setContent(build);
await page.evaluate(
async (code, sources, loadEnzyme) => {
async (code, sources, loadEnzyme, transformedPython) => {
const getUserInput = fileName => sources[fileName];
// TODO: use frame's functions directly, so it behaves more like the
// client. Also, keep an eye on performance - loading pyodide is slow.
await document.__initTestFrame({
code: sources,
getUserInput,
loadEnzyme
loadEnzyme,
transformedPython
});
},
code,
sources,
loadEnzyme
loadEnzyme,
transformedPython
);
}

View File

@@ -81,6 +81,7 @@ const directoryToSuperblock = {
'18-project-euler': 'project-euler',
'19-foundational-c-sharp-with-microsoft':
'foundational-c-sharp-with-microsoft',
'20-upcoming-python': 'upcoming-python',
'99-example-certification': 'example-certification'
};

View File

@@ -143,7 +143,7 @@ describe('getSuperBlockFromPath', () => {
);
it('handles all the directories in ./challenges/english', () => {
expect.assertions(20);
expect.assertions(21);
for (const directory of directories) {
expect(() => getSuperBlockFromDir(directory)).not.toThrow();
@@ -151,7 +151,7 @@ describe('getSuperBlockFromPath', () => {
});
it("returns valid superblocks (or 'certifications') for all valid arguments", () => {
expect.assertions(20);
expect.assertions(21);
const superBlockPaths = directories.filter(x => x !== '00-certifications');

798
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,12 @@ packages:
- 'api'
- 'api-server'
- 'client'
- 'client/plugins/fcc-source-challenges'
- 'client/plugins/gatsby-remark-node-identity'
- 'curriculum'
- 'tools/challenge-editor/api'
- 'tools/challenge-editor/client'
- 'tools/challenge-helper-scripts'
- 'tools/challenge-parser'
- 'tools/client-plugins/*'
- 'tools/crowdin'
- 'tools/scripts/build'
- 'tools/scripts/seed'

View File

@@ -47,6 +47,7 @@ const superBlockFolderMap = {
'project-euler': '18-project-euler',
'foundational-c-sharp-with-microsoft':
'19-foundational-c-sharp-with-microsoft',
'upcoming-python': '20-upcoming-python',
'example-certification': '99-example-certification'
};

View File

@@ -66,5 +66,13 @@ export const superBlockList = [
{
name: 'Project Euler',
path: '18-project-euler'
},
{
name: 'Foundational C# with Microsoft',
path: '19-foundational-c-sharp-with-microsoft'
},
{
name: 'Upcoming Python',
path: '20-upcoming-python'
}
];

View File

@@ -6,7 +6,7 @@
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0",
"codemirror": "5",
"codemirror": "5.65.13",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-router-dom": "6.14.2",

View File

@@ -22,6 +22,7 @@ export function getSuperBlockSubPath(superBlock: SuperBlocks): string {
[SuperBlocks.CollegeAlgebraPy]: '17-college-algebra-with-python',
[SuperBlocks.ProjectEuler]: '18-project-euler',
[SuperBlocks.FoundationalCSharp]: '19-foundational-c-sharp-with-microsoft',
[SuperBlocks.UpcomingPython]: '20-upcoming-python',
[SuperBlocks.ExampleCertification]: '99-example-certification'
};
return pathMap[superBlock];

View File

@@ -24,7 +24,9 @@ function defaultFile(lang, id) {
function getFilenames(lang) {
const langToFilename = {
js: 'script',
css: 'styles'
css: 'styles',
py: 'main',
python: 'main'
};
return langToFilename[lang] ?? 'index';
}

View File

@@ -1,35 +1,13 @@
import jQuery from 'jquery';
import * as helpers from '@freecodecamp/curriculum-helpers';
declare global {
interface Window {
$: JQueryStatic;
}
interface Document {
// eslint-disable-next-line @typescript-eslint/naming-convention
__initTestFrame: (e: InitTestFrameArg) => Promise<void>;
// eslint-disable-next-line @typescript-eslint/naming-convention
__runTest: (
testString: string
) => Promise<
{ pass: boolean } | { err: { message: string; stack?: string } }
>;
}
}
import type { FrameDocument, FrameWindow, InitTestFrameArg } from '.';
window.$ = jQuery;
(window as FrameWindow).$ = jQuery;
document.__initTestFrame = initTestFrame;
const frameDocument = document as FrameDocument;
interface InitTestFrameArg {
code: {
contents?: string;
editableContents?: string;
original?: { [id: string]: string };
};
getUserInput?: (fileName: string) => string;
loadEnzyme?: () => void;
}
frameDocument.__initTestFrame = initTestFrame;
async function initTestFrame(e: InitTestFrameArg = { code: {} }) {
const code = (e.code.contents || '').slice();
@@ -44,12 +22,12 @@ async function initTestFrame(e: InitTestFrameArg = { code: {} }) {
// __testEditable allows test authors to run tests against a transitory dom
// element built using only the code in the editable region.
const __testEditable = (cb: () => () => unknown) => {
const div = document.createElement('div');
const div = frameDocument.createElement('div');
div.id = 'editable-only';
div.innerHTML = editableContents;
document.body.appendChild(div);
frameDocument.body.appendChild(div);
const out = cb();
document.body.removeChild(div);
frameDocument.body.removeChild(div);
return out;
};
@@ -100,7 +78,7 @@ async function initTestFrame(e: InitTestFrameArg = { code: {} }) {
/* eslint-enable prefer-const */
}
document.__runTest = async function runTests(testString: string) {
frameDocument.__runTest = async function runTests(testString: string) {
// uncomment the following line to inspect
// the frame-runner as it runs tests
// make sure the dev tools console is open
@@ -111,7 +89,7 @@ async function initTestFrame(e: InitTestFrameArg = { code: {} }) {
// i.e. function() { assert(true, 'happy coding'); }
const testPromise = new Promise((resolve, reject) =>
// To avoid race conditions, we have to run the test in a final
// document ready:
// frameDocument ready:
$(() => {
try {
const test: unknown = eval(testString);

View File

@@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { PyodideInterface } from 'pyodide';
export interface FrameDocument extends Document {
__initTestFrame: (e: InitTestFrameArg) => Promise<void>;
__runTest: (
testString: string
) => Promise<
{ pass: boolean } | { err: { message: string; stack?: string } }
>;
}
export interface PythonDocument extends FrameDocument {
__initPythonFrame: () => Promise<void>;
__runPython: (code: string) => Promise<PyodideInterface>;
}
export interface InitTestFrameArg {
code: {
contents?: string;
editableContents?: string;
original?: { [id: string]: string };
};
getUserInput?: (fileName: string) => string;
loadEnzyme?: () => void;
transformedPython?: string;
}
export type FrameWindow = Window &
typeof globalThis & {
$: typeof $;
};

View File

@@ -0,0 +1,60 @@
{
"name": "@freecodecamp/browser-scripts",
"version": "1.0.0",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
"private": true,
"engines": {
"node": ">=18",
"pnpm": "8"
},
"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",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=7168\" webpack -c webpack.config.js"
},
"keywords": [],
"devDependencies": {
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/plugin-transform-runtime": "7.19.6",
"@babel/preset-env": "7.22.5",
"@babel/preset-typescript": "7.22.5",
"@freecodecamp/curriculum-helpers": "1.1.0",
"@types/chai": "4.3.4",
"@types/copy-webpack-plugin": "^8.0.1",
"@types/enzyme": "3.10.12",
"@types/enzyme-adapter-react-16": "1.0.6",
"@types/jquery": "3.5.16",
"@types/lodash-es": "4.17.6",
"babel-loader": "8.3.0",
"buffer": "6.0.3",
"chai": "4.3.7",
"copy-webpack-plugin": "9.1.0",
"css-loader": "^6.8.1",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"jquery": "3.6.4",
"lodash-es": "4.17.21",
"process": "0.11.10",
"pyodide": "^0.23.3",
"sass.js": "0.11.1",
"style-loader": "^3.3.3",
"util": "0.12.5",
"webpack": "5.76.2",
"webpack-cli": "4.10.0"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"xterm": "^5.2.1",
"xterm-addon-fit": "^0.7.0"
}
}

View File

@@ -0,0 +1,280 @@
/* eslint-disable @typescript-eslint/naming-convention */
// We have to specify pyodide.js because we need to import that file (not .mjs)
// and 'import' defaults to .mjs
import { loadPyodide, type PyodideInterface } from 'pyodide/pyodide.js';
import pkg from 'pyodide/package.json';
import { IDisposable, Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import jQuery from 'jquery'; // TODO: is jQuery needed for the python runner?
import * as helpers from '@freecodecamp/curriculum-helpers';
import type { PythonDocument, FrameWindow, InitTestFrameArg } from '.';
import 'xterm/css/xterm.css';
(window as FrameWindow).$ = jQuery;
// This will be running in an iframe, so document will be
// element.contentDocument. This declaration is just to add properties we know
// exist on this document (but not on the parent)
const contentDocument = document as PythonDocument;
function createTerminal(disposables: IDisposable[]) {
const terminalContainer = document.getElementById('terminal');
if (!terminalContainer) throw Error('Could not find terminal container');
// Setting convertEol so that \n is converted to \r\n. Otherwise the terminal
// will interpret \n as line feed and just move the cursor to the next line.
// convertEol makes every \n a \r\n.
const term = new Terminal({ convertEol: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalContainer);
fitAddon.fit();
const resetTerminal = () => {
term.reset();
disposables.forEach(disposable => disposable.dispose());
disposables.length = 0;
};
return { term, resetTerminal };
}
async function setupPyodide() {
// I tried setting jsglobals here, to provide 'input' and 'print' to python,
// without having to modify the global window object. However, it didn't work
// because pyodide needs access to that object. Instead, I used
// registerJsModule when setting up runPython.
return await loadPyodide({
indexURL: `https://cdn.jsdelivr.net/pyodide/v${pkg.version}/full/`
});
}
type Input = (text: string) => Promise<string>;
type Print = (...args: unknown[]) => void;
type ResetTerminal = () => void;
function createJSFunctionsForPython(
term: Terminal,
disposables: IDisposable[],
pyodide: PyodideInterface
) {
const writeLine = (text: string) => term.writeln(`>>> ${text}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const str = pyodide.globals.get('str') as (x: unknown) => string;
function print(...args: unknown[]) {
const text = args.map(x => str(x)).join(' ');
writeLine(text);
}
// TODO: prevent user from moving cursor outside the current input line and
// handle insertion and deletion properly. While backspace and delete don't
// seem to work, we can use "\x1b[0K" to clear from the cursor to the end.
// Also, we should not add special characters to the userinput string.
const waitForInput = (): Promise<string> =>
new Promise(resolve => {
let userinput = '';
// Eslint is correct that this only gets assigned once, but we can't use
// const because the declaration (before keyListener is defined) and
// assignment (after keyListener is defined) must be separate.
// eslint-disable-next-line prefer-const
let disposable: IDisposable | undefined;
const done = () => {
disposable?.dispose();
resolve(userinput);
};
const keyListener = (key: string) => {
if (key === '\u007F' || key === '\b') {
// Backspace or delete key
term.write('\b \b'); // Move cursor back, replace character with space, then move cursor back again
userinput = userinput.slice(0, -1); // Remove the last character from userinput
}
if (key == '\r') {
term.write('\r\n');
done();
} else {
userinput += key;
term.write(key);
}
};
disposable = term.onData(keyListener); // Listen for key events and store the disposable
disposables.push(disposable);
});
const input = async (text: string) => {
writeLine(text);
return await waitForInput();
};
return { print, input };
}
function setupRunPython(
pyodide: PyodideInterface,
{
input,
print,
resetTerminal
}: { input: Input; print: Print; resetTerminal: ResetTerminal }
) {
// Make print and input available to python
pyodide.registerJsModule('jscustom', {
input,
print
});
pyodide.runPython(`
import jscustom
from jscustom import print
from jscustom import input
`);
async function runPython(code: string) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
pyodide.globals.get('__cancel')?.();
resetTerminal();
// There's no need to clear out globals between runs, because the user's
// code is always run in a coroutine and shouldn't pollute them. If we
// subsequently want to run code that does interact with globals, we can
// revisit this.
await pyodide.runPythonAsync(code);
return pyodide;
}
contentDocument.__runPython = runPython;
}
async function initPythonFrame() {
const disposables: IDisposable[] = [];
const { term, resetTerminal } = createTerminal(disposables);
const pyodide = await setupPyodide();
const { print, input } = createJSFunctionsForPython(
term,
disposables,
pyodide
);
setupRunPython(pyodide, { input, print, resetTerminal });
}
contentDocument.__initPythonFrame = initPythonFrame;
contentDocument.__initTestFrame = initTestFrame;
// TODO: DRY this and frame-runner.ts's initTestFrame
async function initTestFrame(e: InitTestFrameArg) {
const pyodide = await setupPyodide();
// transformedPython is used here not because it's necessary (it's not since
// the transformation converts `input` into `await input` and the tests
// provide a synchronous `input` function), but because we want to run the
// tests against exactly the same code that runs in the preview.
const code = (e.transformedPython || '').slice();
const __file = (id?: string) => {
if (id && e.code.original) {
return e.code.original[id];
} else {
return code;
}
};
if (!e.getUserInput) {
e.getUserInput = () => code;
}
/* eslint-disable @typescript-eslint/no-unused-vars */
// Fake Deep Equal dependency
const DeepEqual = (a: Record<string, unknown>, b: Record<string, unknown>) =>
JSON.stringify(a) === JSON.stringify(b);
// Hardcode Deep Freeze dependency
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DeepFreeze = (o: Record<string, any>) => {
Object.freeze(o);
Object.getOwnPropertyNames(o).forEach(function (prop) {
if (
Object.prototype.hasOwnProperty.call(o, prop) &&
o[prop] !== null &&
(typeof o[prop] === 'object' || typeof o[prop] === 'function') &&
!Object.isFrozen(o[prop])
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
DeepFreeze(o[prop]);
}
});
return o;
};
const { default: chai } = await import(/* webpackChunkName: "chai" */ 'chai');
const assert = chai.assert;
const __helpers = helpers;
/* eslint-enable @typescript-eslint/no-unused-vars */
contentDocument.__runTest = async function runTests(testString: string) {
// uncomment the following line to inspect
// the frame-runner as it runs tests
// make sure the dev tools console is open
// debugger;
try {
// eval test string to get the dummy input and actual test
const { input, test } = await new Promise<{
input: string[];
test: () => Promise<unknown>;
}>((resolve, reject) =>
// To avoid race conditions, we have to run the test in a final
// frameDocument ready:
$(() => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const test: { input: string[]; test: () => Promise<unknown> } =
eval(testString);
resolve(test);
} catch (err) {
reject(err);
}
})
);
// TODO: throw helpful error if we run out of input values, since it's likely
// that the user added too many input statements.
const inputIterator = input ? input.values() : null;
setupRunPython(pyodide, {
input: () => {
return Promise.resolve(
inputIterator ? inputIterator.next().value : ''
);
},
// We don't, currently, care what print is called with, but it does need
// to exist
print: () => void 0,
// resetTerminal is only necessary when calling __runPython more than
// once, which we don't do in the test frame
resetTerminal: () => void 0
});
// Make __pyodide available to the test code
const __pyodide: PyodideInterface = await this.__runPython(code);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
const __userGlobals = __pyodide.globals.get('__locals');
await test();
return { pass: true };
} catch (err) {
if (!(err instanceof chai.AssertionError)) {
console.error(err);
}
// to provide useful debugging information when debugging the tests, we
// have to extract the message, stack and, if they exist, expected and
// actual before returning
return {
err: {
message: (err as Error).message,
stack: (err as Error).stack,
expected: (err as { expected?: string }).expected,
actual: (err as { actual?: string }).actual
}
};
}
};
}

View File

@@ -1,7 +1,7 @@
import chai from 'chai';
import { toString as __toString } from 'lodash-es';
import * as helpers from '@freecodecamp/curriculum-helpers';
import { format as __format } from '../../utils/format';
import { format as __format } from './utils/format';
const ctx: Worker & typeof globalThis = self as unknown as Worker &
typeof globalThis;

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2022",
"module": "CommonJS",
"allowJs": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,22 @@
// TODO: this is a straight up copy of the format function from the client.
// Figure out a way to share it.
import { inspect } from 'util/util';
export function format(x) {
// we're trying to mimic console.log, so we avoid wrapping strings in quotes:
if (typeof x === 'string') return x;
else if (x instanceof Set) {
return `Set(${x.size}) {${Array.from(x).join(', ')}}`;
} else if (x instanceof Map) {
return `Map(${x.size}) {${Array.from(
x.entries(),
([k, v]) => `${k} => ${v}`
).join(', ')}})`;
} else if (typeof x === 'bigint') {
return x.toString() + 'n';
} else if (typeof x === 'symbol') {
return x.toString();
}
return inspect(x);
}

View File

@@ -5,15 +5,16 @@ const webpack = require('webpack');
module.exports = (env = {}) => {
const __DEV__ = env.production !== true;
const staticPath = path.join(__dirname, './static/js');
const configPath = path.join(__dirname, '../config/client');
const staticPath = path.join(__dirname, '../../../client/static/js');
const configPath = path.join(__dirname, '../../../config/client');
return {
cache: __DEV__ ? { type: 'filesystem' } : false,
mode: __DEV__ ? 'development' : 'production',
entry: {
'frame-runner': './src/client/frame-runner.ts',
'sass-compile': './src/client/workers/sass-compile.ts',
'test-evaluator': './src/client/workers/test-evaluator.ts'
'frame-runner': './frame-runner.ts',
'sass-compile': './sass-compile.ts',
'test-evaluator': './test-evaluator.ts',
'python-runner': './python-runner.ts'
},
devtool: __DEV__ ? 'inline-source-map' : 'source-map',
output: {
@@ -57,6 +58,11 @@ module.exports = (env = {}) => {
]
}
}
},
// xterm doesn't bundle its css, so we need to load it ourselves
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
@@ -76,7 +82,7 @@ module.exports = (env = {}) => {
buffer: require.resolve('buffer'),
util: false,
stream: false,
process: require.resolve('process/browser')
process: require.resolve('process/browser.js')
},
extensions: ['.js', '.ts']
}

View File

@@ -4,12 +4,12 @@ exports.onCreateNode = function remarkNodeIdentityOnCreateNode(
) {
if (typeof predicate !== 'function') {
reporter.panic(
'Please supply a predicate function to `gatsby-plugin-identity`'
'Please supply a predicate function to `gatsby-remark-node-identity`'
);
}
if (typeof identity !== 'string' || identity.length === 0) {
reporter.panic(
'`gatsby-plugin-identity` requires an identify string to add to nodes ' +
'`gatsby-remark-node-identity` requires an identify string to add to nodes ' +
'that match the predicate'
);
}

View File

@@ -1,5 +1,5 @@
{
"name": "@freecodecamp/gatsby-plugin-node-identity",
"name": "@freecodecamp/gatsby-remark-node-identity",
"version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
@@ -17,5 +17,5 @@
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none"
"main": "gatsby-node.js"
}

View File

@@ -1,5 +1,5 @@
{
"name": "@freecodecamp/fcc-source-challenges",
"name": "@freecodecamp/gatsby-source-challenges",
"version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
@@ -17,7 +17,7 @@
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none",
"main": "gatsby-node.js",
"devDependencies": {
"chokidar": "3.5.3",
"readdirp": "3.6.0"

View File

@@ -92,6 +92,7 @@ if (envData.clientLocale == 'english' && !envData.showUpcomingChanges) {
'college-algebra-with-python',
'the-odin-project',
'foundational-c-sharp-with-microsoft',
'upcoming-python',
'example-certification'
];