From 324b64e9f9b9669ab652828cfc9ed9c48bde6320 Mon Sep 17 00:00:00 2001 From: Josias Date: Thu, 19 Dec 2024 16:26:16 +0100 Subject: [PATCH] warn against using backticks in sprite keys (#2715) * add babel transform to warn against including backticks in strings * add function to perform syntax checks * perform syntax checks every two seconds * fix typescript error * add test for infinite loop detection * add cross-env for passing environment variables to test runner * reset and bubble-up errors if there are new errors during check * ignore firmware.elf/uf2 --- .gitignore | 4 +- .../big-interactive-pages/editor.tsx | 17 ++++++- src/lib/custom-babel-transforms.ts | 15 +++++++ src/lib/engine/error.test.ts | 44 +++++++++++++++++++ src/lib/engine/index.ts | 42 +++++++++++------- 5 files changed, 105 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index f3b0646c3a..f2eebb3d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ yarn-error.log .env dist/ games/metadata.json -public/*.json \ No newline at end of file +public/*.json +firmware/spade/firmware.elf +firmware/spade/firmware.uf2 \ No newline at end of file diff --git a/src/components/big-interactive-pages/editor.tsx b/src/components/big-interactive-pages/editor.tsx index 3fbe7d66e3..a2d8f4e9c2 100644 --- a/src/components/big-interactive-pages/editor.tsx +++ b/src/components/big-interactive-pages/editor.tsx @@ -16,7 +16,7 @@ import { import { useEffect, useRef, useState} from "preact/hooks"; import { codeMirror, errorLog, isNewSaveStrat, muted, PersistenceState, RoomState, screenRef, cleanupRef } from "../../lib/state"; import EditorModal from "../popups-etc/editor-modal"; -import { runGame } from "../../lib/engine"; +import { runGame, _performSyntaxCheck } from "../../lib/engine"; import DraftWarningModal from "../popups-etc/draft-warning"; import { debounce } from "throttle-debounce"; import Help from "../popups-etc/help"; @@ -36,6 +36,15 @@ import { PersistenceStateKind } from "../../lib/state"; let screenShakeSignal: Signal | null = null; +const performSyntaxCheck = () => { + const code = codeMirror.value?.state.doc.toString() ?? ""; + const res = _performSyntaxCheck(code); + if (res.error) { + errorLog.value = []; + errorLog.value = [res.error]; + } +} + export const onRun = async () => { foldAllTemplateLiterals(); if (!screenRef.value) return; @@ -262,6 +271,12 @@ export default function Editor({ persistenceState, cookies, roomState }: EditorP const [sessionId] = useState(nanoid()); + useEffect(() => { + setInterval(() => { + performSyntaxCheck(); + }, 2000); + }, []); + useEffect(() => { const channel = new BroadcastChannel('session_channel'); channel.onmessage = (event) => { diff --git a/src/lib/custom-babel-transforms.ts b/src/lib/custom-babel-transforms.ts index de79d4f06e..ca97488827 100644 --- a/src/lib/custom-babel-transforms.ts +++ b/src/lib/custom-babel-transforms.ts @@ -121,4 +121,19 @@ export function BuildDuplicateFunctionDetector(engineApiKeys: string[]) { } } +} + +export function dissallowBackticksInDoubleQuotes() { + return { + visitor: { + StringLiteral(path: any) { + const { value, extra } = path.node; + if (value.includes('`')) { + const loc = path.node.loc.start; + const quoteType = extra.raw[0]; + throw path.buildCodeFrameError(`Backtick found within ${quoteType === '"' ? 'double' : 'single'}-quoted string at (${loc.line}:${loc.column})`); + } + } + } + } } \ No newline at end of file diff --git a/src/lib/engine/error.test.ts b/src/lib/engine/error.test.ts index d290b5ef2d..bed5c2517e 100644 --- a/src/lib/engine/error.test.ts +++ b/src/lib/engine/error.test.ts @@ -1,8 +1,52 @@ import { expect, test } from "vitest" +import TransformDetectInfiniteLoop, { BuildDuplicateFunctionDetector, dissallowBackticksInDoubleQuotes} from "../custom-babel-transforms"; +import * as Babel from "@babel/standalone"; +import { baseEngine } from "../../../engine/src/base"; import {normalizeGameError} from "./error" // SyntaxErrors (not in evals) seem to go through babel (i.e. have an error.code), so they are not tested here +export function transformAndThrowErrors(code: string, engineAPIKeys: string[], runCb: (code: any) => any) { + try { + const transformedCode = Babel.transform(code, { + plugins: [TransformDetectInfiniteLoop, BuildDuplicateFunctionDetector(engineAPIKeys), dissallowBackticksInDoubleQuotes], + retainLines: true + }); + runCb(transformedCode); + return null; + } catch (error: any) { + return normalizeGameError({ kind: "runtime", error }); + } +} + +test('detect infinite while loops', () => { + const code = 'while (true) {}' + const engine = baseEngine(); + const res = transformAndThrowErrors(code, Object.keys(engine.api), (transformedCode) => { + const fn = new Function(transformedCode.code); + fn(); + }); + + console.log(res?.description); + const expectedError = `RangeError: Potential infinite loop + at eval (/Volumes/Code/code/hackclub/sprig/src/lib/engine/error.test.ts:1:152)`; + expect(res?.description).toBe(expectedError); +}); + +test('detect infinite for loop', () => { + const code = 'for (;;) {}' + const engine = baseEngine(); + const res = transformAndThrowErrors(code, Object.keys(engine.api), (transformedCode) => { + const fn = new Function(transformedCode.code); + fn(); + }); + + console.log(res?.description); + const expectedError = `RangeError: Potential infinite loop + at eval (/Volumes/Code/code/hackclub/sprig/src/lib/engine/error.test.ts:1:148)`; + expect(res?.description).toBe(expectedError); +}); + test('calls a mistyped console.log function (line 2)', () => { const payload = {kind: "runtime", error: new TypeError("console.llog is not a function")} payload.error.stack = `TypeError: console.llog is not a function diff --git a/src/lib/engine/index.ts b/src/lib/engine/index.ts index 7d6b630ba1..2ebe17f0be 100644 --- a/src/lib/engine/index.ts +++ b/src/lib/engine/index.ts @@ -2,10 +2,10 @@ import { playTune } from './tune' import { normalizeGameError } from './error' import { bitmaps, NormalizedError } from '../state' import type { PlayTuneRes } from '../../../engine/src/api' -import { textToTune } from '../../../engine/src/base' +import { baseEngine, textToTune } from '../../../engine/src/base' import { webEngine } from '../../../engine/src/web' import * as Babel from "@babel/standalone" -import TransformDetectInfiniteLoop, { BuildDuplicateFunctionDetector } from '../custom-babel-transforms' +import TransformDetectInfiniteLoop, { BuildDuplicateFunctionDetector, dissallowBackticksInDoubleQuotes } from '../custom-babel-transforms' import {logInfo} from "../../components/popups-etc/help"; interface RunResult { @@ -37,6 +37,26 @@ function parseErrorStack(err?: Error): [number | null, number | null] { return [null, null]; } +export function transformAndThrowErrors(code: string, engineAPIKeys: string[], runCb: (code: any) => any) { + try { + const transformedCode = Babel.transform(code, { + plugins: [TransformDetectInfiniteLoop, BuildDuplicateFunctionDetector(engineAPIKeys), dissallowBackticksInDoubleQuotes], + retainLines: true + }); + runCb(transformedCode); + return null; + } catch (error: any) { + return normalizeGameError({ kind: "runtime", error }); + } +} + +export function _performSyntaxCheck(code: string): { error: NormalizedError | null, cleanup: () => void } { + const game = baseEngine(); + + const engineAPIKeys = Object.keys(game.api); + return { error: transformAndThrowErrors(code, engineAPIKeys, () => {}), cleanup: () => void 0 }; +} + export function runGame(code: string, canvas: HTMLCanvasElement, onPageError: (error: NormalizedError) => void): RunResult | undefined { const game = webEngine(canvas) const tunes: PlayTuneRes[] = [] @@ -112,19 +132,11 @@ export function runGame(code: string, canvas: HTMLCanvasElement, onPageError: (e } const engineAPIKeys = Object.keys(api); - try { - const transformResult = Babel.transform(code, { - plugins: [TransformDetectInfiniteLoop, BuildDuplicateFunctionDetector(engineAPIKeys)], - retainLines: true - }); - logInfo.value = []; - const fn = new Function(...engineAPIKeys, transformResult.code!); - fn(...Object.values(api)); - return { error: null, cleanup }; - } catch (error: any) { - onPageError(normalizeGameError({ kind: "runtime", error })); - return { error: normalizeGameError({ kind: "runtime", error }), cleanup }; - } + return { error: transformAndThrowErrors(code, engineAPIKeys, (transformedCode) => { + logInfo.value = []; + const fn = new Function(...engineAPIKeys, transformedCode.code!) + fn(...Object.values(api)) + }), cleanup }; } export function runGameHeadless(code: string): void {