diff --git a/app/javascript/components/bootcamp/FrontendExercisePage/LHS/injectLoopguards.ts b/app/javascript/components/bootcamp/FrontendExercisePage/LHS/injectLoopguards.ts new file mode 100644 index 0000000000..437e21f7d9 --- /dev/null +++ b/app/javascript/components/bootcamp/FrontendExercisePage/LHS/injectLoopguards.ts @@ -0,0 +1,127 @@ +import * as acorn from 'acorn' +import * as walk from 'acorn-walk' +import * as astring from 'astring' + +export function injectLoopGuards(code: string): string { + const ast = acorn.parse(code, { + ecmaVersion: 2020, + sourceType: 'module', + }) as acorn.Node + + let loopId = 0 + const usedGuards: string[] = [] + + walk.ancestor(ast, { + WhileStatement(node, ancestors: any[]) { + const id = `__loop_guard_${loopId++}` + usedGuards.push(id) + guardLoop(node, ancestors, id) + }, + ForStatement(node, ancestors) { + const id = `__loop_guard_${loopId++}` + usedGuards.push(id) + guardLoop(node, ancestors, id) + }, + DoWhileStatement(node, ancestors) { + const id = `__loop_guard_${loopId++}` + usedGuards.push(id) + guardLoop(node, ancestors, id) + }, + }) + + const guardVars = usedGuards.map((id) => `let ${id} = 0;`).join('\n') + + const finalCode = ` + const __MAX_ITERATIONS = 10000; + ${guardVars} + ${astring.generate(ast)} + ` + + return finalCode +} + +function guardLoop(node: any, ancestors: any[], loopVar: string) { + /** + AST of + let ${loopVar} = 0; + */ + const guard = { + type: 'VariableDeclaration', + kind: 'let', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: loopVar }, + init: { type: 'Literal', value: 0 }, + }, + ], + } + + /* + AST of + if (++${loopVar} > __MAX_ITERATIONS) throw new Error("Infinite loop detected") + */ + const check = { + type: 'IfStatement', + test: { + type: 'BinaryExpression', + operator: '>', + left: { + type: 'UpdateExpression', + operator: '++', + prefix: true, + argument: { type: 'Identifier', name: loopVar }, + }, + right: { type: 'Identifier', name: '__MAX_ITERATIONS' }, + }, + consequent: { + type: 'ThrowStatement', + argument: { + type: 'NewExpression', + callee: { type: 'Identifier', name: 'Error' }, + arguments: [{ type: 'Literal', value: 'Infinite loop detected' }], + }, + }, + } + + // we check if the loop actually has a block body + // and if it doesn't, we wrap it in a block statement + // otherwise after the injection the code would be invalid JS. + + // code below turns this: + + // while (true) doSomething(); + // into this: + // while (true) { + // if (++__loop_guard_0 > __MAX_ITERATIONS) throw new Error(...); + // doSomething(); + // } + if (node.body.type !== 'BlockStatement') { + node.body = { + type: 'BlockStatement', + body: [check, node.body], + } + } else { + node.body.body.unshift(check) + } + + // this is needed to make sure loop-guard variable is declared in the parent scope and before the loop + // to avoid any reference errors + const parentBody = findNearestBody(ancestors) + if (parentBody) { + const index = parentBody.indexOf(node) + if (index !== -1) { + parentBody.splice(index, 0, guard) + } + } +} + +function findNearestBody(ancestors: any[]): any[] | null { + for (let i = ancestors.length - 1; i >= 0; i--) { + const parent = ancestors[i] + if (parent.type === 'BlockStatement') { + return parent.body + } + } + return null +} diff --git a/app/javascript/components/bootcamp/JikiscriptExercisePage/exercises/wordle/WordleGame.ts b/app/javascript/components/bootcamp/JikiscriptExercisePage/exercises/wordle/WordleGame.ts index 9815b75306..d4fbb7015c 100644 --- a/app/javascript/components/bootcamp/JikiscriptExercisePage/exercises/wordle/WordleGame.ts +++ b/app/javascript/components/bootcamp/JikiscriptExercisePage/exercises/wordle/WordleGame.ts @@ -32,7 +32,6 @@ function fn(this: WordleExercise) { word: Jiki.JikiObject, states: Jiki.JikiObject ) { - console.log(row, word, states) if (!(row instanceof Jiki.Number)) { return executionCtx.logicError('The first input must be a number') } diff --git a/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/execJS.ts b/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/execJS.ts index 4dd10d3d5c..d2718c20c4 100644 --- a/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/execJS.ts +++ b/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/execJS.ts @@ -1,5 +1,6 @@ import { generateCodeRunString } from '../../utils/generateCodeRunString' import * as acorn from 'acorn' +import { injectLoopGuards } from './injectLoopGuards' const esm = (code: string) => URL.createObjectURL(new Blob([code], { type: 'text/javascript' })) @@ -43,6 +44,8 @@ export async function execJS( } } + // let guardedStudentCode = injectLoopGuards(studentCode) + let code = ` let currentTime = 0 const executionCtx = { diff --git a/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/execTest.ts b/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/execTest.ts index 36b3eecf5d..53a0414e1b 100644 --- a/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/execTest.ts +++ b/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/execTest.ts @@ -69,6 +69,7 @@ export async function execTest( let actual: any let frames: Frame[] = [] let evaluated: any = null + let hasJSError = false switch (language) { case 'javascript': { @@ -85,13 +86,13 @@ export async function execTest( if (result.status === 'error') { if (editorView) { - console.log(result) showError({ error: result.error, ...stateSetters, editorView, }) } + hasJSError = true } // null falls back to [Your function didn't return anything] @@ -134,6 +135,16 @@ export async function execTest( const expects = generateExpects(evaluated, testData, actual, exercise) + if (hasJSError) { + expects.push({ + actual: 'running', + matcher: 'toBe', + errorHtml: 'Your code has an error in it.', + expected: true, + pass: false, + }) + } + return { expects, slug: testData.slug, diff --git a/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/injectLoopGuards.ts b/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/injectLoopGuards.ts new file mode 100644 index 0000000000..437e21f7d9 --- /dev/null +++ b/app/javascript/components/bootcamp/JikiscriptExercisePage/test-runner/generateAndRunTestSuite/injectLoopGuards.ts @@ -0,0 +1,127 @@ +import * as acorn from 'acorn' +import * as walk from 'acorn-walk' +import * as astring from 'astring' + +export function injectLoopGuards(code: string): string { + const ast = acorn.parse(code, { + ecmaVersion: 2020, + sourceType: 'module', + }) as acorn.Node + + let loopId = 0 + const usedGuards: string[] = [] + + walk.ancestor(ast, { + WhileStatement(node, ancestors: any[]) { + const id = `__loop_guard_${loopId++}` + usedGuards.push(id) + guardLoop(node, ancestors, id) + }, + ForStatement(node, ancestors) { + const id = `__loop_guard_${loopId++}` + usedGuards.push(id) + guardLoop(node, ancestors, id) + }, + DoWhileStatement(node, ancestors) { + const id = `__loop_guard_${loopId++}` + usedGuards.push(id) + guardLoop(node, ancestors, id) + }, + }) + + const guardVars = usedGuards.map((id) => `let ${id} = 0;`).join('\n') + + const finalCode = ` + const __MAX_ITERATIONS = 10000; + ${guardVars} + ${astring.generate(ast)} + ` + + return finalCode +} + +function guardLoop(node: any, ancestors: any[], loopVar: string) { + /** + AST of + let ${loopVar} = 0; + */ + const guard = { + type: 'VariableDeclaration', + kind: 'let', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: loopVar }, + init: { type: 'Literal', value: 0 }, + }, + ], + } + + /* + AST of + if (++${loopVar} > __MAX_ITERATIONS) throw new Error("Infinite loop detected") + */ + const check = { + type: 'IfStatement', + test: { + type: 'BinaryExpression', + operator: '>', + left: { + type: 'UpdateExpression', + operator: '++', + prefix: true, + argument: { type: 'Identifier', name: loopVar }, + }, + right: { type: 'Identifier', name: '__MAX_ITERATIONS' }, + }, + consequent: { + type: 'ThrowStatement', + argument: { + type: 'NewExpression', + callee: { type: 'Identifier', name: 'Error' }, + arguments: [{ type: 'Literal', value: 'Infinite loop detected' }], + }, + }, + } + + // we check if the loop actually has a block body + // and if it doesn't, we wrap it in a block statement + // otherwise after the injection the code would be invalid JS. + + // code below turns this: + + // while (true) doSomething(); + // into this: + // while (true) { + // if (++__loop_guard_0 > __MAX_ITERATIONS) throw new Error(...); + // doSomething(); + // } + if (node.body.type !== 'BlockStatement') { + node.body = { + type: 'BlockStatement', + body: [check, node.body], + } + } else { + node.body.body.unshift(check) + } + + // this is needed to make sure loop-guard variable is declared in the parent scope and before the loop + // to avoid any reference errors + const parentBody = findNearestBody(ancestors) + if (parentBody) { + const index = parentBody.indexOf(node) + if (index !== -1) { + parentBody.splice(index, 0, guard) + } + } +} + +function findNearestBody(ancestors: any[]): any[] | null { + for (let i = ancestors.length - 1; i >= 0; i--) { + const parent = ancestors[i] + if (parent.type === 'BlockStatement') { + return parent.body + } + } + return null +} diff --git a/package.json b/package.json index 3e9a822cb4..d2f531ea48 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "ace-builds": "^1.4.12", "acorn": "^8.14.1", "actioncable": "^5.2.4-3", + "astring": "^1.9.0", "autoprefixer": "latest", "browserslist-to-esbuild": "^1.1.1", "canvas-confetti": "^1.9.3", diff --git a/yarn.lock b/yarn.lock index ec643ccc40..b60a6c14d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3033,6 +3033,11 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +astring@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/astring/-/astring-1.9.0.tgz#cc73e6062a7eb03e7d19c22d8b0b3451fd9bfeef" + integrity sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg== + async@3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9"