mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat: python in the browser (#50913)
Co-authored-by: Beau Carnes <1513130+beaucarnes@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0caaf7ec66
commit
69d6ee32bf
@@ -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
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
9
client/src/pages/learn/upcoming-python/index.md
Normal file
9
client/src/pages/learn/upcoming-python/index.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Upcoming Python Certification
|
||||
superBlock: upcoming-python
|
||||
certification: upcoming-python
|
||||
---
|
||||
|
||||
## Upcoming Python Certification
|
||||
|
||||
Learn the basics of Python.
|
||||
@@ -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!
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -187,7 +187,9 @@ const modeMap = {
|
||||
css: 'css',
|
||||
html: 'html',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript'
|
||||
jsx: 'javascript',
|
||||
py: 'python',
|
||||
python: 'python'
|
||||
};
|
||||
|
||||
let monacoThemesDefined = false;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
120
client/src/templates/Challenges/rechallenge/transform-python.js
Normal file
120
client/src/templates/Challenges/rechallenge/transform-python.js
Normal 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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -232,4 +232,4 @@
|
||||
"title": "Data Visualization: Mailing Lists"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,4 @@
|
||||
"title": "Probability Calculator"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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
798
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
33
tools/client-plugins/browser-scripts/index.d.ts
vendored
Normal file
33
tools/client-plugins/browser-scripts/index.d.ts
vendored
Normal 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 $;
|
||||
};
|
||||
60
tools/client-plugins/browser-scripts/package.json
Normal file
60
tools/client-plugins/browser-scripts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
280
tools/client-plugins/browser-scripts/python-runner.ts
Normal file
280
tools/client-plugins/browser-scripts/python-runner.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
12
tools/client-plugins/browser-scripts/tsconfig.json
Normal file
12
tools/client-plugins/browser-scripts/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "CommonJS",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
22
tools/client-plugins/browser-scripts/utils/format.js
Normal file
22
tools/client-plugins/browser-scripts/utils/format.js
Normal 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);
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user