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", "./api/tsconfig.json",
"./config/tsconfig.json", "./config/tsconfig.json",
"./tools/ui-components/tsconfig.json", "./tools/ui-components/tsconfig.json",
"./tools/client-plugins/browser-scripts/tsconfig.json",
"./utils/tsconfig.json", "./utils/tsconfig.json",
"./web/tsconfig.json", "./web/tsconfig.json",
"./curriculum-server/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/sass-compile.json
config/client/frame-runner.json config/client/frame-runner.json
config/client/test-evaluator.json config/client/test-evaluator.json
config/client/python-runner.json
config/curriculum.json config/curriculum.json
config/i18n.js config/i18n.js
config/misc.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: { options: {
name: 'challenges', name: 'challenges',
source: buildChallenges, source: buildChallenges,
@@ -70,7 +72,9 @@ module.exports = {
resolve: 'gatsby-transformer-remark' resolve: 'gatsby-transformer-remark'
}, },
{ {
resolve: 'gatsby-remark-node-identity', resolve: require.resolve(
'../tools/client-plugins/gatsby-remark-node-identity'
),
options: { options: {
identity: 'blockIntroMarkdown', identity: 'blockIntroMarkdown',
predicate: ({ frontmatter }) => { predicate: ({ frontmatter }) => {
@@ -83,7 +87,9 @@ module.exports = {
} }
}, },
{ {
resolve: 'gatsby-remark-node-identity', resolve: require.resolve(
'../tools/client-plugins/gatsby-remark-node-identity'
),
options: { options: {
identity: 'superBlockIntroMarkdown', identity: 'superBlockIntroMarkdown',
predicate: ({ frontmatter }) => { 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": { "example-certification": {
"title": "Example Certification", "title": "Example Certification",
"intro": ["placeholder"], "intro": ["placeholder"],

View File

@@ -19,11 +19,11 @@
"author": "freeCodeCamp <team@freecodecamp.org>", "author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none", "main": "none",
"scripts": { "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": "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", "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", "build:components-library": "pnpm run -F=@freecodecamp/ui build",
"develop": "cross-env NODE_OPTIONS=\"--max-old-space-size=5000\" gatsby develop --inspect=9230", "develop": "cross-env NODE_OPTIONS=\"--max-old-space-size=5000\" gatsby develop --inspect=9230",
"lint": "ts-node ./i18n/schema-validation.ts", "lint": "ts-node ./i18n/schema-validation.ts",
@@ -71,7 +71,6 @@
"crypto-browserify": "3.12.0", "crypto-browserify": "3.12.0",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"enzyme": "3.11.0", "enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"final-form": "4.20.9", "final-form": "4.20.9",
"gatsby": "3.15.0", "gatsby": "3.15.0",
"gatsby-cli": "3.15.0", "gatsby-cli": "3.15.0",

View File

@@ -36,7 +36,8 @@ const iconMap = {
[SuperBlocks.ProjectEuler]: Graduation, [SuperBlocks.ProjectEuler]: Graduation,
[SuperBlocks.CollegeAlgebraPy]: CollegeAlgebra, [SuperBlocks.CollegeAlgebraPy]: CollegeAlgebra,
[SuperBlocks.FoundationalCSharp]: CSharpLogo, [SuperBlocks.FoundationalCSharp]: CSharpLogo,
[SuperBlocks.ExampleCertification]: ResponsiveDesign [SuperBlocks.ExampleCertification]: ResponsiveDesign,
[SuperBlocks.UpcomingPython]: PythonIcon
}; };
const generateIconComponent = ( 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 collegeAlgebraPyBase = '/learn/college-algebra-with-python';
const takeHomeBase = '/learn/coding-interview-prep/take-home-projects'; const takeHomeBase = '/learn/coding-interview-prep/take-home-projects';
const foundationalCSharpBase = '/learn/foundational-c-sharp-with-microsoft'; const foundationalCSharpBase = '/learn/foundational-c-sharp-with-microsoft';
const upcomingPythonBase = '/learn/upcoming-python';
const exampleCertBase = '/learn/example-certification'; const exampleCertBase = '/learn/example-certification';
const legacyFrontEndBase = feLibsBase; const legacyFrontEndBase = feLibsBase;
const legacyFrontEndResponsiveBase = responsiveWebBase; const legacyFrontEndResponsiveBase = responsiveWebBase;
@@ -769,6 +770,19 @@ const upcomingCertMap = [
certSlug: 'example-certification-v8' 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; ] as const;

View File

@@ -11,9 +11,8 @@ import {
challengeFilesSelector challengeFilesSelector
} from '../redux/selectors'; } from '../redux/selectors';
type VisibleEditors = { import type { VisibleEditors } from './multifile-editor';
[key: string]: boolean;
};
interface EditorTabsProps { interface EditorTabsProps {
challengeFiles: ChallengeFiles; challengeFiles: ChallengeFiles;
toggleVisibleEditor: typeof toggleVisibleEditor; toggleVisibleEditor: typeof toggleVisibleEditor;
@@ -43,7 +42,13 @@ class EditorTabs extends Component<EditorTabsProps> {
{sortChallengeFiles(challengeFiles).map( {sortChallengeFiles(challengeFiles).map(
(challengeFile: ChallengeFile) => ( (challengeFile: ChallengeFile) => (
<button <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} key={challengeFile.fileKey}
data-cy={`editor-tab-${challengeFile.fileKey}`} data-cy={`editor-tab-${challengeFile.fileKey}`}
onClick={() => toggleVisibleEditor(challengeFile.fileKey)} onClick={() => toggleVisibleEditor(challengeFile.fileKey)}

View File

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

View File

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

View File

@@ -222,10 +222,14 @@ function ShowClassic({
`intro:${superBlock}.blocks.${block}.title` `intro:${superBlock}.blocks.${block}.title`
)}: ${title}`; )}: ${title}`;
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`; 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 = const showPreview =
challengeType === challengeTypes.html || challengeType === challengeTypes.html ||
challengeType === challengeTypes.modern || challengeType === challengeTypes.modern ||
challengeType === challengeTypes.multifileCertProject; challengeType === challengeTypes.multifileCertProject ||
challengeType === challengeTypes.python;
const getLayoutState = () => { const getLayoutState = () => {
const reflexLayout = store.get(REFLEX_LAYOUT) as ReflexLayout; 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 }) || '' embedSource({ source: contents }) || ''
}${testRunnerScript}`; }${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 compileHeadTail
} from '../../../../../utils/polyvinyl'; } from '../../../../../utils/polyvinyl';
import createWorker from '../utils/worker-executor'; import createWorker from '../utils/worker-executor';
import { makeCancellable, makeInputAwaitable } from './transform-python';
const { filename: sassCompile } = sassData; const { filename: sassCompile } = sassData;
@@ -98,6 +99,7 @@ const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const testJS = matchesProperty('ext', 'js'); const testJS = matchesProperty('ext', 'js');
const testJSX = matchesProperty('ext', 'jsx'); const testJSX = matchesProperty('ext', 'jsx');
const testHTML = matchesProperty('ext', 'html'); const testHTML = matchesProperty('ext', 'html');
const testPython = matchesProperty('ext', 'py');
const testHTML$JS$JSX = overSome(testHTML, testJS, testJSX); const testHTML$JS$JSX = overSome(testHTML, testJS, testJSX);
const replaceNBSP = cond([ const replaceNBSP = cond([
@@ -297,9 +299,26 @@ const htmlTransformer = cond([
[stubTrue, identity] [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 => [ export const getTransformers = loopProtectOptions => [
replaceNBSP, replaceNBSP,
babelTransformer(loopProtectOptions), babelTransformer(loopProtectOptions),
partial(compileHeadTail, ''), partial(compileHeadTail, ''),
htmlTransformer htmlTransformer
]; ];
export const getPythonTransformers = () => [
replaceNBSP,
partial(compileHeadTail, ''),
pythonTransformer
];

View File

@@ -34,6 +34,7 @@ import {
updatePreview, updatePreview,
updateProjectPreview updateProjectPreview
} from '../utils/build'; } from '../utils/build';
import { runPythonInFrame, mainPreviewId } from '../utils/frame';
import { actionTypes } from './action-types'; import { actionTypes } from './action-types';
import { import {
disableBuildOnError, disableBuildOnError,
@@ -241,6 +242,15 @@ function* previewChallengeSaga({ flushLogs = true } = {}) {
const finalDocument = portalDocument || document; const finalDocument = portalDocument || document;
yield call(updatePreview, buildData, finalDocument, proxyLogger); 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)) { } else if (isJavaScriptChallenge(challengeData)) {
const runUserCode = getTestRunner(buildData, { const runUserCode = getTestRunner(buildData, {
proxyLogger, proxyLogger,
@@ -251,6 +261,7 @@ function* previewChallengeSaga({ flushLogs = true } = {}) {
} }
} }
} catch (err) { } catch (err) {
console.log('previewChallengeSaga error', err);
if (err[0] === 'timeout') { if (err[0] === 'timeout') {
// TODO: translate the error // TODO: translate the error
// eslint-disable-next-line no-ex-assign // 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 }) { function* previewProjectSolutionSaga({ payload }) {
if (!payload) return; if (!payload) return;
const { showProjectPreview, challengeData } = payload; const { showProjectPreview, challengeData } = payload;
@@ -282,8 +319,9 @@ function* previewProjectSolutionSaga({ payload }) {
export function createExecuteChallengeSaga(types) { export function createExecuteChallengeSaga(types) {
return [ return [
takeLatest(types.executeChallenge, executeCancellableChallengeSaga), takeLatest(types.executeChallenge, executeCancellableChallengeSaga),
takeLatest(types.updateFile, updatePreviewSaga),
takeLatest( takeLatest(
[types.updateFile, types.challengeMounted, types.resetChallenge], [types.challengeMounted, types.resetChallenge],
previewChallengeSaga previewChallengeSaga
), ),
takeLatest(types.previewMounted, previewChallengeSaga, { takeLatest(types.previewMounted, previewChallengeSaga, {

View File

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

View File

@@ -1,12 +1,18 @@
import { challengeTypes } from '../../../../../config/challenge-types';
import frameRunnerData from '../../../../../config/client/frame-runner.json'; import frameRunnerData from '../../../../../config/client/frame-runner.json';
import testEvaluatorData from '../../../../../config/client/test-evaluator.json'; import testEvaluatorData from '../../../../../config/client/test-evaluator.json';
import { challengeTypes } from '../../../../../config/challenge-types'; import pythonRunnerData from '../../../../../config/client/python-runner.json';
import { import {
ChallengeFile as PropTypesChallengeFile, ChallengeFile as PropTypesChallengeFile,
ChallengeMeta ChallengeMeta
} from '../../../redux/prop-types'; } from '../../../redux/prop-types';
import { concatHtml } from '../rechallenge/builders'; import { concatHtml, createPythonTerminal } from '../rechallenge/builders';
import { getTransformers, embedFilesInHtml } from '../rechallenge/transformers'; import {
getTransformers,
embedFilesInHtml,
getPythonTransformers
} from '../rechallenge/transformers';
import { import {
createTestFramer, createTestFramer,
runTestInTestFrame, runTestInTestFrame,
@@ -41,10 +47,11 @@ interface BuildOptions {
usesTestRunner: boolean; usesTestRunner: boolean;
} }
const { filename: runner } = frameRunnerData;
const { filename: testEvaluator } = testEvaluatorData; 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>; type ApplyFunctionProps = (file: ChallengeFile) => Promise<ChallengeFile>;
@@ -67,6 +74,8 @@ const applyFunction =
const composeFunctions = (...fns: ApplyFunctionProps[]) => const composeFunctions = (...fns: ApplyFunctionProps[]) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g)); 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 { function buildSourceMap(challengeFiles: ChallengeFiles): Source | undefined {
// TODO: rename sources.index to sources.contents. // TODO: rename sources.index to sources.contents.
const source: Source | undefined = challengeFiles?.reduce( const source: Source | undefined = challengeFiles?.reduce(
@@ -77,7 +86,11 @@ function buildSourceMap(challengeFiles: ChallengeFiles): Source | undefined {
sources.editableContents += challengeFile.editableContents || ''; sources.editableContents += challengeFile.editableContents || '';
return sources; return sources;
}, },
{ index: '', editableContents: '', original: {} } as Source {
index: '',
editableContents: '',
original: {}
} as Source
); );
return source; return source;
} }
@@ -101,7 +114,8 @@ const buildFunctions = {
[challengeTypes.backEndProject]: buildBackendChallenge, [challengeTypes.backEndProject]: buildBackendChallenge,
[challengeTypes.pythonProject]: buildBackendChallenge, [challengeTypes.pythonProject]: buildBackendChallenge,
[challengeTypes.multifileCertProject]: buildDOMChallenge, [challengeTypes.multifileCertProject]: buildDOMChallenge,
[challengeTypes.colab]: buildBackendChallenge [challengeTypes.colab]: buildBackendChallenge,
[challengeTypes.python]: buildPythonChallenge
}; };
export function canBuildChallenge(challengeData: BuildChallengeData): boolean { export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
@@ -192,6 +206,9 @@ type BuildResult = {
sources: Source | undefined; 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( export function buildDOMChallenge(
{ challengeFiles, required = [], template = '' }: BuildChallengeData, { challengeFiles, required = [], template = '' }: BuildChallengeData,
{ usesTestRunner } = { usesTestRunner: false } { usesTestRunner } = { usesTestRunner: false }
@@ -206,24 +223,24 @@ export function buildDOMChallenge(
if (finalFiles) { if (finalFiles) {
return Promise.all(finalFiles) return Promise.all(finalFiles)
.then(checkFilesErrors) .then(checkFilesErrors)
.then(embedFilesInHtml) .then(
.then(([_challengeFiles, _contents]) => { embedFilesInHtml as (
const challengeFiles = _challengeFiles as ChallengeFiles; x: ChallengeFiles
const contents = _contents as string; ) => Promise<[ChallengeFiles, string]>
)
return { .then(([challengeFiles, contents]) => ({
challengeType: // TODO: Stop overwriting challengeType with 'html'. Figure out why it's
challengeTypes.html || challengeTypes.multifileCertProject, // necessary at the moment.
build: concatHtml({ challengeType: challengeTypes.html,
required, build: concatHtml({
template, required,
contents, template,
...(usesTestRunner && { testRunner: frameRunnerSrc }) contents,
}), ...(usesTestRunner && { testRunner: frameRunnerSrc })
sources: buildSourceMap(challengeFiles), }),
loadEnzyme 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( export function updatePreview(
buildData: BuildChallengeData, buildData: BuildChallengeData,
document: Document, document: Document,
proxyLogger: ProxyLogger 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 ( if (
buildData.challengeType === challengeTypes.html || buildData.challengeType === challengeTypes.html ||
buildData.challengeType === challengeTypes.multifileCertProject buildData.challengeType === challengeTypes.multifileCertProject
) { ) {
createMainPreviewFramer( return new Promise<void>(resolve =>
document, createMainPreviewFramer(
proxyLogger, document,
getDocumentTitle(buildData) proxyLogger,
)(buildData); getDocumentTitle(buildData),
resolve
)(buildData)
);
} else { } else {
throw new Error( throw new Error(
`Cannot show preview for challenge type ${buildData.challengeType}` `Cannot show preview for challenge type ${buildData.challengeType}`
@@ -318,7 +372,8 @@ export function challengeHasPreview({ challengeType }: ChallengeMeta): boolean {
return ( return (
challengeType === challengeTypes.html || challengeType === challengeTypes.html ||
challengeType === challengeTypes.modern || 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 { toString, flow } from 'lodash-es';
import i18next, { i18n } from 'i18next'; import i18next, { type i18n } from 'i18next';
import { format } from '../../../utils/format'; import { format } from '../../../utils/format';
import type {
FrameDocument,
PythonDocument
} from '../../../../../tools/client-plugins/browser-scripts';
const utilsFormat: <T>(x: T) => string = format; 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 { export interface Source {
index: string; index: string;
contents?: string; contents?: string;
@@ -24,12 +17,15 @@ export interface Source {
} }
export interface Context { export interface Context {
window: Window; window?: Window &
document: Document; // eslint-disable-next-line @typescript-eslint/naming-convention
typeof globalThis & { i18nContent?: i18n; __pyodide: unknown };
document?: FrameDocument | PythonDocument;
element: HTMLIFrameElement; element: HTMLIFrameElement;
build: string; build: string;
sources: Source; sources: Source;
loadEnzyme?: () => void; loadEnzyme?: () => void;
transformedPython?: string;
} }
export interface TestRunnerConfig { export interface TestRunnerConfig {
@@ -139,24 +135,44 @@ type TestResult =
| { pass: boolean } | { pass: boolean }
| { err: { message: string; stack?: string } }; | { 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 ( export const runTestInTestFrame = async function (
document: Document, document: Document,
test: string, test: string,
timeout: number timeout: number
): Promise<TestResult | undefined> { ): Promise<TestResult | undefined> {
const { contentDocument: frame } = document.getElementById( const contentDocument = getContentDocument(document, testId);
testId if (contentDocument) {
) as HTMLIFrameElement;
if (frame !== null) {
return await Promise.race([ return await Promise.race([
new Promise< new Promise<
{ pass: boolean } | { err: { message: string; stack?: string } } { pass: boolean } | { err: { message: string; stack?: string } }
>((_, reject) => setTimeout(() => reject('timeout'), timeout)), >((_, 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 = const createFrame =
(document: Document, id: string, title?: string) => (document: Document, id: string, title?: string) =>
(frameContext: Context) => { (frameContext: Context) => {
@@ -253,45 +269,56 @@ const updateWindowI18next = () => (frameContext: Context) => {
const initTestFrame = (frameReady?: () => void) => (frameContext: Context) => { const initTestFrame = (frameReady?: () => void) => (frameContext: Context) => {
waitForFrame(frameContext) waitForFrame(frameContext)
.then(async () => { .then(async () => {
const { sources, loadEnzyme } = frameContext; const { sources, loadEnzyme, transformedPython } = frameContext;
// provide the file name and get the original source // provide the file name and get the original source
const getUserInput = (fileName: string) => const getUserInput = (fileName: string) =>
toString(sources[fileName as keyof typeof sources]); toString(sources[fileName as keyof typeof sources]);
await frameContext.document.__initTestFrame({ await frameContext.document?.__initTestFrame({
code: sources, code: sources,
getUserInput, getUserInput,
loadEnzyme loadEnzyme,
transformedPython
}); });
if (frameReady) {
frameReady(); if (frameReady) frameReady();
}
}) })
.catch(handleDocumentNotFound); .catch(handleDocumentNotFound);
return frameContext; return frameContext;
}; };
const initMainFrame = const initMainFrame =
(_: unknown, proxyLogger?: ProxyLogger) => (frameContext: Context) => { (frameReady?: () => void, proxyLogger?: ProxyLogger) =>
(frameContext: Context) => {
waitForFrame(frameContext) waitForFrame(frameContext)
.then(() => { .then(async () => {
// Overwriting the onerror added by createHeader to catch any errors thrown // Overwriting the onerror added by createHeader to catch any errors thrown
// after the frame is ready. It has to be overwritten, as proxyLogger cannot // after the frame is ready. It has to be overwritten, as proxyLogger cannot
// be added as part of createHeader. // be added as part of createHeader.
frameContext.window.onerror = function (msg) { if (frameContext.window) {
if (typeof msg === 'string') { frameContext.window.onerror = function (msg) {
const string = msg.toLowerCase(); if (typeof msg === 'string') {
if (string.includes('script error')) { const string = msg.toLowerCase();
msg = 'Error, open your browser console to learn more.'; if (string.includes('script error')) {
msg = 'Error, open your browser console to learn more.';
}
if (proxyLogger) {
proxyLogger(msg);
}
} }
if (proxyLogger) { // let the error propagate so it appears in the browser console, otherwise
proxyLogger(msg); // 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); .catch(handleDocumentNotFound);
return frameContext; return frameContext;
@@ -305,6 +332,21 @@ function handleDocumentNotFound(err: string) {
const initPreviewFrame = () => (frameContext: Context) => frameContext; 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) => { const waitForFrame = (frameContext: Context) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!frameContext.document) { 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 // it's possible, if the preview is rapidly opened and closed, for the frame
// to be null at this point. // to be null at this point.
if (frame) { if (frame) {
@@ -344,14 +386,15 @@ const writeContentToFrame = (frameContext: Context) => {
export const createMainPreviewFramer = ( export const createMainPreviewFramer = (
document: Document, document: Document,
proxyLogger: ProxyLogger, proxyLogger: ProxyLogger,
frameTitle: string frameTitle: string,
frameReady?: () => void
): ((args: Context) => void) => ): ((args: Context) => void) =>
createFramer( createFramer(
document, document,
mainPreviewId, mainPreviewId,
initMainFrame, initMainFrame,
proxyLogger, proxyLogger,
undefined, frameReady,
frameTitle frameTitle
); );

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,8 @@ export enum SuperBlocks {
ProjectEuler = 'project-euler', ProjectEuler = 'project-euler',
CollegeAlgebraPy = 'college-algebra-with-python', CollegeAlgebraPy = 'college-algebra-with-python',
FoundationalCSharp = 'foundational-c-sharp-with-microsoft', 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.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject, SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp, SuperBlocks.FoundationalCSharp,
SuperBlocks.ExampleCertification SuperBlocks.ExampleCertification,
SuperBlocks.UpcomingPython
] ]
}; };
@@ -97,7 +99,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler, SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject, SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.Chinese]: [ [Languages.Chinese]: [
SuperBlocks.CollegeAlgebraPy, SuperBlocks.CollegeAlgebraPy,
@@ -105,7 +108,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler, SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject, SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.ChineseTraditional]: [ [Languages.ChineseTraditional]: [
SuperBlocks.CollegeAlgebraPy, SuperBlocks.CollegeAlgebraPy,
@@ -113,29 +117,34 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler, SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject, SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.Italian]: [ [Languages.Italian]: [
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject, SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.Portuguese]: [ [Languages.Portuguese]: [
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.Ukrainian]: [ [Languages.Ukrainian]: [
SuperBlocks.CodingInterviewPrep, SuperBlocks.CodingInterviewPrep,
SuperBlocks.ProjectEuler, SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.Japanese]: [ [Languages.Japanese]: [
SuperBlocks.CollegeAlgebraPy, SuperBlocks.CollegeAlgebraPy,
SuperBlocks.ProjectEuler, SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject, SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.German]: [ [Languages.German]: [
SuperBlocks.RespWebDesignNew, SuperBlocks.RespWebDesignNew,
@@ -152,7 +161,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler, SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject, SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.Arabic]: [ [Languages.Arabic]: [
SuperBlocks.DataVis, SuperBlocks.DataVis,
@@ -168,7 +178,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.ProjectEuler, SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew, SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject, SuperBlocks.TheOdinProject,
SuperBlocks.FoundationalCSharp SuperBlocks.FoundationalCSharp,
SuperBlocks.UpcomingPython
], ],
[Languages.Swahili]: [ [Languages.Swahili]: [
SuperBlocks.DataVis, SuperBlocks.DataVis,
@@ -187,7 +198,8 @@ export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
SuperBlocks.RespWebDesign, SuperBlocks.RespWebDesign,
SuperBlocks.FrontEndDevLibs, SuperBlocks.FrontEndDevLibs,
SuperBlocks.JsAlgoDataStructNew, 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" "title": "Data Visualization: Mailing Lists"
} }
] ]
} }

View File

@@ -28,4 +28,4 @@
"title": "Probability Calculator" "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(), challengeOrder: Joi.number(),
removeComments: Joi.bool().required(), removeComments: Joi.bool().required(),
certification: Joi.string().regex(slugRE), certification: Joi.string().regex(slugRE),
challengeType: Joi.number().min(0).max(19).required(), challengeType: Joi.number().min(0).max(20).required(),
checksum: Joi.number(), checksum: Joi.number(),
// TODO: require this only for normal challenges, not certs // TODO: require this only for normal challenges, not certs
dashedName: Joi.string().regex(slugRE), dashedName: Joi.string().regex(slugRE),

View File

@@ -25,7 +25,8 @@ require('@babel/register')({
}); });
const { const {
buildDOMChallenge, buildDOMChallenge,
buildJSChallenge buildJSChallenge,
buildPythonChallenge
} = require('../../client/src/templates/Challenges/utils/build'); } = require('../../client/src/templates/Challenges/utils/build');
const { const {
default: createWorker default: createWorker
@@ -319,12 +320,14 @@ function populateTestsForLang({ lang, challenges, meta }) {
}); });
const { challengeType } = challenge; const { challengeType } = challenge;
// TODO: shouldn't this be a function in challenge-types.js?
if ( if (
challengeType !== challengeTypes.html && challengeType !== challengeTypes.html &&
challengeType !== challengeTypes.js && challengeType !== challengeTypes.js &&
challengeType !== challengeTypes.jsProject && challengeType !== challengeTypes.jsProject &&
challengeType !== challengeTypes.modern && challengeType !== challengeTypes.modern &&
challengeType !== challengeTypes.backend challengeType !== challengeTypes.backend &&
challengeType !== challengeTypes.python
) { ) {
return; return;
} }
@@ -349,14 +352,21 @@ function populateTestsForLang({ lang, challenges, meta }) {
return; 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 = const buildChallenge =
challengeType === challengeTypes.js || {
challengeType === challengeTypes.jsProject [challengeTypes.js]: buildJSChallenge,
? buildJSChallenge [challengeTypes.jsProject]: buildJSChallenge,
: buildDOMChallenge; [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 () { 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. // suppress errors in the console.
const oldConsoleError = console.error; const oldConsoleError = console.error;
console.error = () => {}; console.error = () => {};
@@ -445,7 +455,7 @@ function populateTestsForLang({ lang, challenges, meta }) {
it(`Solution ${ it(`Solution ${
index + 1 index + 1
} must pass the tests`, async function () { } must pass the tests`, async function () {
this.timeout(5000 * tests.length + 2000); this.timeout(timePerTest * tests.length + 2000);
const testRunner = await createTestRunner( const testRunner = await createTestRunner(
challenge, challenge,
solution, solution,
@@ -477,22 +487,27 @@ async function createTestRunner(
solutionFiles solutionFiles
); );
const { build, sources, loadEnzyme } = await buildChallenge( const { build, sources, loadEnzyme, transformedPython } =
{ await buildChallenge(
challengeFiles, {
required, challengeFiles,
template required,
}, template
{ usesTestRunner: true } },
); { usesTestRunner: true }
);
const code = { const code = {
contents: sources.index, contents: sources.index,
editableContents: sources.editableContents editableContents: sources.editableContents
}; };
const evaluator = await (buildChallenge === buildDOMChallenge const runsInBrowser =
? getContextEvaluator(build, sources, code, loadEnzyme) buildChallenge === buildDOMChallenge ||
buildChallenge === buildPythonChallenge;
const evaluator = await (runsInBrowser
? getContextEvaluator(build, sources, code, loadEnzyme, transformedPython)
: getWorkerEvaluator(build, sources, code, removeComments)); : getWorkerEvaluator(build, sources, code, removeComments));
return async ({ text, testString }) => { return async ({ text, testString }) => {
@@ -537,8 +552,20 @@ function replaceChallengeFilesContentsWithSolutions(
}); });
} }
async function getContextEvaluator(build, sources, code, loadEnzyme) { async function getContextEvaluator(
await initializeTestRunner(build, sources, code, loadEnzyme); build,
sources,
code,
loadEnzyme,
transformedPython
) {
await initializeTestRunner(
build,
sources,
code,
loadEnzyme,
transformedPython
);
return { return {
evaluate: async (testString, timeout) => 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.reload();
await page.setContent(build); await page.setContent(build);
await page.evaluate( await page.evaluate(
async (code, sources, loadEnzyme) => { async (code, sources, loadEnzyme, transformedPython) => {
const getUserInput = fileName => sources[fileName]; 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({ await document.__initTestFrame({
code: sources, code: sources,
getUserInput, getUserInput,
loadEnzyme loadEnzyme,
transformedPython
}); });
}, },
code, code,
sources, sources,
loadEnzyme loadEnzyme,
transformedPython
); );
} }

View File

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

View File

@@ -143,7 +143,7 @@ describe('getSuperBlockFromPath', () => {
); );
it('handles all the directories in ./challenges/english', () => { it('handles all the directories in ./challenges/english', () => {
expect.assertions(20); expect.assertions(21);
for (const directory of directories) { for (const directory of directories) {
expect(() => getSuperBlockFromDir(directory)).not.toThrow(); expect(() => getSuperBlockFromDir(directory)).not.toThrow();
@@ -151,7 +151,7 @@ describe('getSuperBlockFromPath', () => {
}); });
it("returns valid superblocks (or 'certifications') for all valid arguments", () => { it("returns valid superblocks (or 'certifications') for all valid arguments", () => {
expect.assertions(20); expect.assertions(21);
const superBlockPaths = directories.filter(x => x !== '00-certifications'); 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'
- 'api-server' - 'api-server'
- 'client' - 'client'
- 'client/plugins/fcc-source-challenges'
- 'client/plugins/gatsby-remark-node-identity'
- 'curriculum' - 'curriculum'
- 'tools/challenge-editor/api' - 'tools/challenge-editor/api'
- 'tools/challenge-editor/client' - 'tools/challenge-editor/client'
- 'tools/challenge-helper-scripts' - 'tools/challenge-helper-scripts'
- 'tools/challenge-parser' - 'tools/challenge-parser'
- 'tools/client-plugins/*'
- 'tools/crowdin' - 'tools/crowdin'
- 'tools/scripts/build' - 'tools/scripts/build'
- 'tools/scripts/seed' - 'tools/scripts/seed'

View File

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

View File

@@ -66,5 +66,13 @@ export const superBlockList = [
{ {
name: 'Project Euler', name: 'Project Euler',
path: '18-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/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0", "@testing-library/user-event": "13.5.0",
"codemirror": "5", "codemirror": "5.65.13",
"react": "16.14.0", "react": "16.14.0",
"react-dom": "16.14.0", "react-dom": "16.14.0",
"react-router-dom": "6.14.2", "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.CollegeAlgebraPy]: '17-college-algebra-with-python',
[SuperBlocks.ProjectEuler]: '18-project-euler', [SuperBlocks.ProjectEuler]: '18-project-euler',
[SuperBlocks.FoundationalCSharp]: '19-foundational-c-sharp-with-microsoft', [SuperBlocks.FoundationalCSharp]: '19-foundational-c-sharp-with-microsoft',
[SuperBlocks.UpcomingPython]: '20-upcoming-python',
[SuperBlocks.ExampleCertification]: '99-example-certification' [SuperBlocks.ExampleCertification]: '99-example-certification'
}; };
return pathMap[superBlock]; return pathMap[superBlock];

View File

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

View File

@@ -1,35 +1,13 @@
import jQuery from 'jquery'; import jQuery from 'jquery';
import * as helpers from '@freecodecamp/curriculum-helpers'; import * as helpers from '@freecodecamp/curriculum-helpers';
declare global { import type { FrameDocument, FrameWindow, InitTestFrameArg } from '.';
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 } }
>;
}
}
window.$ = jQuery; (window as FrameWindow).$ = jQuery;
document.__initTestFrame = initTestFrame; const frameDocument = document as FrameDocument;
interface InitTestFrameArg { frameDocument.__initTestFrame = initTestFrame;
code: {
contents?: string;
editableContents?: string;
original?: { [id: string]: string };
};
getUserInput?: (fileName: string) => string;
loadEnzyme?: () => void;
}
async function initTestFrame(e: InitTestFrameArg = { code: {} }) { async function initTestFrame(e: InitTestFrameArg = { code: {} }) {
const code = (e.code.contents || '').slice(); 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 // __testEditable allows test authors to run tests against a transitory dom
// element built using only the code in the editable region. // element built using only the code in the editable region.
const __testEditable = (cb: () => () => unknown) => { const __testEditable = (cb: () => () => unknown) => {
const div = document.createElement('div'); const div = frameDocument.createElement('div');
div.id = 'editable-only'; div.id = 'editable-only';
div.innerHTML = editableContents; div.innerHTML = editableContents;
document.body.appendChild(div); frameDocument.body.appendChild(div);
const out = cb(); const out = cb();
document.body.removeChild(div); frameDocument.body.removeChild(div);
return out; return out;
}; };
@@ -100,7 +78,7 @@ async function initTestFrame(e: InitTestFrameArg = { code: {} }) {
/* eslint-enable prefer-const */ /* eslint-enable prefer-const */
} }
document.__runTest = async function runTests(testString: string) { frameDocument.__runTest = async function runTests(testString: string) {
// uncomment the following line to inspect // uncomment the following line to inspect
// the frame-runner as it runs tests // the frame-runner as it runs tests
// make sure the dev tools console is open // 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'); } // i.e. function() { assert(true, 'happy coding'); }
const testPromise = new Promise((resolve, reject) => const testPromise = new Promise((resolve, reject) =>
// To avoid race conditions, we have to run the test in a final // To avoid race conditions, we have to run the test in a final
// document ready: // frameDocument ready:
$(() => { $(() => {
try { try {
const test: unknown = eval(testString); 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 chai from 'chai';
import { toString as __toString } from 'lodash-es'; import { toString as __toString } from 'lodash-es';
import * as helpers from '@freecodecamp/curriculum-helpers'; 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 & const ctx: Worker & typeof globalThis = self as unknown as Worker &
typeof globalThis; 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 = {}) => { module.exports = (env = {}) => {
const __DEV__ = env.production !== true; const __DEV__ = env.production !== true;
const staticPath = path.join(__dirname, './static/js'); const staticPath = path.join(__dirname, '../../../client/static/js');
const configPath = path.join(__dirname, '../config/client'); const configPath = path.join(__dirname, '../../../config/client');
return { return {
cache: __DEV__ ? { type: 'filesystem' } : false, cache: __DEV__ ? { type: 'filesystem' } : false,
mode: __DEV__ ? 'development' : 'production', mode: __DEV__ ? 'development' : 'production',
entry: { entry: {
'frame-runner': './src/client/frame-runner.ts', 'frame-runner': './frame-runner.ts',
'sass-compile': './src/client/workers/sass-compile.ts', 'sass-compile': './sass-compile.ts',
'test-evaluator': './src/client/workers/test-evaluator.ts' 'test-evaluator': './test-evaluator.ts',
'python-runner': './python-runner.ts'
}, },
devtool: __DEV__ ? 'inline-source-map' : 'source-map', devtool: __DEV__ ? 'inline-source-map' : 'source-map',
output: { 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'), buffer: require.resolve('buffer'),
util: false, util: false,
stream: false, stream: false,
process: require.resolve('process/browser') process: require.resolve('process/browser.js')
}, },
extensions: ['.js', '.ts'] extensions: ['.js', '.ts']
} }

View File

@@ -4,12 +4,12 @@ exports.onCreateNode = function remarkNodeIdentityOnCreateNode(
) { ) {
if (typeof predicate !== 'function') { if (typeof predicate !== 'function') {
reporter.panic( 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) { if (typeof identity !== 'string' || identity.length === 0) {
reporter.panic( 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' '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", "version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum", "description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
@@ -17,5 +17,5 @@
}, },
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme", "homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>", "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", "version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum", "description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
@@ -17,7 +17,7 @@
}, },
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme", "homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>", "author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none", "main": "gatsby-node.js",
"devDependencies": { "devDependencies": {
"chokidar": "3.5.3", "chokidar": "3.5.3",
"readdirp": "3.6.0" "readdirp": "3.6.0"

View File

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