diff --git a/src/lib/extensions.js b/src/lib/extensions.js
index edc39ea2f..c73d9509e 100644
--- a/src/lib/extensions.js
+++ b/src/lib/extensions.js
@@ -118,7 +118,16 @@ export default [
banner: "pooiod/Dictation.svg",
creator: "pooiod7",
},
- {
+ {
+ name: "Test Runner",
+ description: "Test that blocks behave as expected. Provides good error messages and error traceback.",
+ code: "GermanCodeEngineer/TestRunner.js",
+ banner: "GermanCodeEngineer/TestRunnerBanner.svg",
+ creator: "GermanCodeEngineer",
+ isGitHub: true,
+ tags: ["utility", "tests", "testing", "errors", "debugging", "asserting", "assertions", "development", "control"],
+ },
+ {
name: "Doodle Recognition",
description: "A implementation of Google's Quick Draw image vision model",
code: "TheShovel/doodlerec.js",
@@ -126,7 +135,7 @@ export default [
creator: "TheShovel",
isGitHub: true,
},
- {
+ {
name: "Stylus",
description: "Get the pressure and tilt of a stylus or graphic tablet",
code: "sourdoggy/stylus extension.js",
diff --git a/static/extensions/GermanCodeEngineer/TestRunner.js b/static/extensions/GermanCodeEngineer/TestRunner.js
new file mode 100644
index 000000000..9a3cfd8d0
--- /dev/null
+++ b/static/extensions/GermanCodeEngineer/TestRunner.js
@@ -0,0 +1,816 @@
+// Name: Test Runner
+// ID: gceTestRunner
+// Description: A testing framework for PenguinMod: Test that blocks behave as expected. Provides good error messages and error traceback.
+// By: GermanCodeEngineer
+// License: MIT
+// Made for PenguinMod
+
+(/** @param {ScratchObject} Scratch */ (Scratch) => {
+"use strict"
+
+/**
+ * Allow importing this file in a non-Scratch testing environment.
+ * When the extension is imported in PenguinMod this is always true
+ */
+const isRuntimeEnv = !Scratch.extensions.isTestingEnv
+if (isRuntimeEnv && !Scratch.extensions.unsandboxed) {
+ throw new Error("Test Runner Extension must run unsandboxed.")
+}
+
+const {BlockType, ArgumentType, Cast} = Scratch
+
+/**
+ * @param {string} s
+ * @returns {string}
+ */
+function quote(s) {
+ s = Cast.toString(s)
+ s = s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
+ return `'${s}'`
+}
+
+class TestError extends Error {
+ /**
+ * @param {string} message
+ * @param {{cause?: *, actualMessage?: ?string, scopePrefix?: ?string}} [options]
+ */
+ constructor (message, options = {}) {
+ super(message, options)
+ this.name = "TestError"
+ // Full message like a normal error has
+ this.fullMessage = message
+ // The actual error message (excludes prefixes)
+ this.actualMessage = options.actualMessage || null
+ // The scope prefixes (e.g., 'test scope "scope name":')
+ this.scopePrefix = options.scopePrefix || null
+ }
+
+ /**
+ * @param {*} error
+ * @returns {string}
+ */
+ static getActualErrorMessage(error) {
+ if (error instanceof TestError && error.actualMessage) {
+ return error.actualMessage
+ }
+ if (error instanceof Error) {
+ return error.message
+ }
+ return String(error)
+ }
+
+ /**
+ * @param {*} error
+ * @param {string} substring
+ * @returns {boolean}
+ */
+ static errorContainsMsg(error, substring) {
+ if (!substring) return true
+ const message = TestError.getActualErrorMessage(error)
+ return message.toLowerCase().includes(substring.toLowerCase())
+ }
+
+ /**
+ * @param {?string} fallback
+ * @param {*} cause
+ * @returns {?string}
+ */
+ static preserveActualMessage(fallback, cause) {
+ if (fallback !== null && fallback !== undefined) {
+ return fallback
+ }
+ if (cause instanceof TestError && cause.actualMessage) {
+ return cause.actualMessage
+ }
+ return null
+ }
+}
+
+
+class TypeChecker {
+ // All custom types (using `customId`) in PM (you can access most from a reporter)
+ // (PenguinMod-Vm, PenguinMod-ExtensionsGallery, SharkPools-Extensions) (as of 14.04.2026)
+ // agBuffer (AndrewGaming587)
+ // agBufferPointer (AndrewGaming587)
+ // canvasData (RedMan13)
+ // ddeDateFormat (ddededodediamante)
+ // ddeDateFormatV2 (ddededodediamante)
+ // divEffect (Div)
+ // divIterator (Div)
+ // dogeiscutObject (DogeisCut)
+ // dogeiscutRegularExpression (DogeisCut)
+ // dogeiscutSet (DogeisCut)
+ // externaltimer (steve0greatness)
+ // jwArray (jwklong)
+ // jwColor (jwklong)
+ // jwDate (jwklong)
+ // jwLambda (jwklong)
+ // jwNum (jwklong)
+ // jwTarget (jwklong)
+ // jwVector (jwklong)
+ // jwXML (jwklong)
+ // paintUtilsColour (Fruits555000)
+
+ static is_agBuffer = TypeChecker._createVMTypeCheck("agBuffer")
+ static is_agBufferPointer = TypeChecker._createVMTypeCheck("agBuffer", "PointerType")
+
+ /**
+ * @param {*} value
+ * @returns {boolean}
+ */
+ static is_canvasData(value) {
+ TypeChecker._assertRuntimeEnv()
+ if (!runtime._extensionVariables) return false
+ const type = runtime._extensionVariables.canvas
+ if (!type) return false
+ return value instanceof type
+ }
+
+ /**
+ * @param {*} value
+ * @returns {boolean}
+ */
+ static is_ddeDateFormat(value) {
+ TypeChecker._assertRuntimeEnv()
+ if (runtime.ext_ddeDateFormat) {
+ try {
+ const dateType = Object.getPrototypeOf(runtime.ext_ddeDateFormat.currentDate()).constructor
+ if (value instanceof dateType) return true
+ } catch {}
+ }
+ }
+
+ /**
+ * @param {*} value
+ * @returns {boolean}
+ */
+ static is_ddeDateFormatV2(value) {
+ TypeChecker._assertRuntimeEnv()
+ if (runtime.ext_ddeDateFormatV2) {
+ try {
+ const dateType = Object.getPrototypeOf(runtime.ext_ddeDateFormatV2.currentDate()).constructor
+ if (value instanceof dateType) return true
+ } catch {}
+ }
+ return false
+ }
+
+ static is_divEffect = TypeChecker._createVMTypeCheck("divAlgEffects", "Effect")
+ static is_divIterator = TypeChecker._createVMTypeCheck("divIterator")
+ static is_dogeiscutObject = TypeChecker._createVMTypeCheck("dogeiscutObject", null, "Object extension was not loaded properly.")
+ static is_dogeiscutRegularExpression = TypeChecker._createVMTypeCheck("dogeiscutRegularExpression")
+ static is_dogeiscutSet = TypeChecker._createVMTypeCheck("dogeiscutSet")
+
+ /**
+ * @param {*} value
+ * @returns {boolean}
+ */
+ static is_externaltimer(value) {
+ TypeChecker._assertRuntimeEnv()
+ if (!runtime._extensionVariables) return false
+ const type = runtime._extensionVariables.externaltimer
+ if (!type) return false
+ return value instanceof type
+ }
+
+ static is_jwArray = TypeChecker._createVMTypeCheck("jwArray", null, "Array extension was not loaded properly.")
+ static is_jwColor = TypeChecker._createVMTypeCheck("jwColor")
+ static is_jwDate = TypeChecker._createVMTypeCheck("jwDate")
+ static is_jwLambda = TypeChecker._createVMTypeCheck("jwLambda")
+ static is_jwNum = TypeChecker._createVMTypeCheck("jwNum")
+ static is_jwTarget = TypeChecker._createVMTypeCheck("jwTargets")
+ static is_jwVector = TypeChecker._createVMTypeCheck("jwVector")
+ static is_jwXML = TypeChecker._createVMTypeCheck("jwXML")
+
+ /**
+ * @param {*} value
+ * @returns {boolean}
+ */
+ static is_paintUtilsColour(value) {
+ TypeChecker._assertRuntimeEnv()
+ try {
+ const proto = Object.getPrototypeOf(runtime.ext_fruitsPaintUtils.getColour({COLOUR_NAME: "orange"})).constructor
+ return value instanceof proto
+ } catch {
+ return false
+ }
+ }
+
+
+
+ static _assertRuntimeEnv() {
+ if (!isRuntimeEnv) {
+ throw new Error("Type checking for extension types is not available in a non-runtime environment.")
+ }
+ }
+
+ /**
+ * @param {string} typeId
+ * @param {?string} [overrideTypeProperty]
+ * @param {?string} [errMsg] - optional error message if type missing
+ * @returns {(value: *) => boolean}
+ */
+ static _createVMTypeCheck(typeId, overrideTypeProperty = null, typeMissingErrorMsg = null) {
+ return function isType(value) {
+ if (!isRuntimeEnv) return false
+ const typeInfo = Scratch.vm[typeId]
+ if (!typeInfo) {
+ if (typeMissingErrorMsg) throw new Error(typeMissingErrorMsg)
+ return false
+ }
+
+ let typeClass
+ try {
+ typeClass = overrideTypeProperty ? typeInfo[overrideTypeProperty] : typeInfo.Type
+ } catch {
+ if (typeMissingErrorMsg) throw new Error(typeMissingErrorMsg)
+ return false
+ }
+ return value instanceof typeClass
+ }
+ }
+
+ /**
+ * @param {*} value
+ * @returns {string}
+ */
+ static stringTypeof(value) {
+ // Common/Safe JS data types
+ if (value === undefined) return "JavaScript Undefined"
+ if (value === null) return "JavaScript Null"
+ if (typeof value === "boolean") return "Boolean"
+ if (typeof value === "number") return "Number"
+ if (typeof value === "string") return "String"
+
+ // Custom Extension Types
+ if (TypeChecker.is_agBuffer(value)) return "Buffer (AndrewGaming587)"
+ if (TypeChecker.is_agBufferPointer(value)) return "Buffer Pointer (AndrewGaming587)"
+ if (TypeChecker.is_ddeDateFormat(value)) return "Date (Old Version) (ddededodediamante)"
+ if (TypeChecker.is_ddeDateFormatV2(value)) return "Date (ddededodediamante)"
+ if (TypeChecker.is_divEffect(value)) return "Effect (Div)"
+ if (TypeChecker.is_divIterator(value)) return "Iterator (Div)"
+ if (TypeChecker.is_dogeiscutObject(value)) return "Object (DogeisCut)"
+ if (TypeChecker.is_dogeiscutRegularExpression(value)) return "Regular Expression (DogeisCut)"
+ if (TypeChecker.is_dogeiscutSet(value)) return "Set (DogeisCut)"
+ if (TypeChecker.is_externaltimer(value)) return "External Timer (steve0greatness)"
+ if (TypeChecker.is_jwArray(value)) return "Array (jwklong)"
+ if (TypeChecker.is_jwColor(value)) return "Color (jwklong)"
+ if (TypeChecker.is_jwDate(value)) return "Date (jwklong)"
+ if (TypeChecker.is_jwLambda(value)) return "Lambda (jwklong)"
+ if (TypeChecker.is_jwNum(value)) return "Number (jwklong)"
+ if (TypeChecker.is_jwTarget(value)) return "Target (jwklong)"
+ if (TypeChecker.is_jwVector(value)) return "Vector (jwklong)"
+ if (TypeChecker.is_jwXML(value)) return "XML (jwklong)"
+ if (TypeChecker.is_canvasData(value)) return "Canvas (RedMan13)"
+ if (TypeChecker.is_paintUtilsColour(value)) return "Paint Utils Colour (Fruits555000)"
+
+ // Rare/Overlapping JS data types
+ if (typeof value === "bigint") return "JavaScript BigInt"
+ if (typeof value === "symbol") return "JavaScript Symbol"
+ if (typeof value === "function") return "JavaScript Function"
+ if (typeof value === "object") return "JavaScript Object (generic)"
+
+ return "Unknown (rare)"
+ }
+}
+
+
+class TestRunner {
+ constructor () {
+ this._testScopes = []
+ this.quote = quote
+ this.TypeChecker = TypeChecker
+ this.TestError = TestError
+ }
+
+ /** @returns {Object} */
+ getInfo () {
+ const commonArguments = {
+ boolean: {
+ type: ArgumentType.BOOLEAN,
+ },
+ errorMessage: {
+ type: ArgumentType.STRING,
+ defaultValue: "test failed"
+ },
+ allowAnything: {
+ type: ArgumentType.STRING,
+ exemptFromNormalization: true,
+ },
+ }
+
+ const info = {
+ id: 'gceTestRunner',
+ name: 'Test Runner',
+ color1: '#4a9e6b',
+ color2: '#3d8a5e',
+ color3: '#2e7050',
+ menuIconURI: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICB2aWV3Qm94PSIwIDAgMjAgMjAiCiAgdmVyc2lvbj0iMS4xIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPGNpcmNsZQogICAgY3g9IjEwIgogICAgY3k9IjEwIgogICAgcj0iOSIKICAgIHN0eWxlPSJmaWxsOiM0YTllNmI7IHN0cm9rZTojMmU3MDUwOyBzdHJva2Utd2lkdGg6MnB4OyBmaWxsLW9wYWNpdHk6MTsgc3Ryb2tlLW9wYWNpdHk6MTsgcGFpbnQtb3JkZXI6c3Ryb2tlIiAvPgogIDxwYXRoCiAgICBkPSJNIDcuNSAyLjc1IEggMTIuNSBMIDEyIDMuNzUgViA3Ljc1IEwgMTUuNSAxNS43NSBIIDQuNSBMIDggNy43NSBWIDMuNzUgTCA3LjUgMi43NSBaIE0gOSAzLjI1IEggMTEuMzc1IEwgMTEgMy44NzUgViA4LjM3NSBMIDE0IDE0Ljg3NSBIIDYgTCA5IDguMzc1IFYgMy44NzUgTCA4LjYyNSAzLjI1IFoiCiAgICBzdHlsZT0iZmlsbDojZmZmZmZmOyBmaWxsLXJ1bGU6ZXZlbm9kZCIgLz4KPC9zdmc+Cg==",
+ blocks: [
+ {
+ opcode: 'testScope',
+ blockType: BlockType.CONDITIONAL,
+ text: 'test scope named [NAME]',
+ tooltip: 'Runs the enclosed blocks and properly reports any errors with a scopes traceback path.',
+ arguments: {
+ NAME: { type: ArgumentType.STRING, defaultValue: 'test for my custom block' }
+ }
+ },
+ '---',
+ {
+ opcode: 'assert',
+ blockType: BlockType.COMMAND,
+ text: 'assert [CONDITION]',
+ tooltip: 'Fails when the condition is false.',
+ arguments: {
+ CONDITION: commonArguments.boolean,
+ }
+ },
+ {
+ opcode: 'assertNot',
+ blockType: BlockType.COMMAND,
+ text: 'assert not [CONDITION]',
+ tooltip: 'Fails when the condition is true.',
+ arguments: {
+ CONDITION: commonArguments.boolean,
+ }
+ },
+ {
+ opcode: 'assertMsg',
+ blockType: BlockType.COMMAND,
+ text: 'assert [CONDITION] message [MSG]',
+ tooltip: 'Fails when the condition is false and adds the message in the thrown error.',
+ arguments: {
+ CONDITION: commonArguments.boolean,
+ MSG: commonArguments.errorMessage,
+ }
+ },
+ {
+ opcode: 'assertNotMsg',
+ blockType: BlockType.COMMAND,
+ text: 'assert not [CONDITION] message [MSG]',
+ tooltip: 'Fails when CONDITION is true and adds the message in the thrown error',
+ arguments: {
+ CONDITION: commonArguments.boolean,
+ MSG: commonArguments.errorMessage
+ }
+ },
+ "---",
+ {
+ opcode: 'assertStrictEqual',
+ blockType: BlockType.COMMAND,
+ text: 'assert typed equality [A] = [B]',
+ tooltip: 'Compares A and B as raw values without converting to strings (strict typed check).',
+ arguments: {
+ A: commonArguments.allowAnything,
+ B: commonArguments.allowAnything,
+ }
+ },
+ {
+ opcode: 'assertStrictNotEqual',
+ blockType: BlockType.COMMAND,
+ text: 'assert typed inequality [A] != [B]',
+ tooltip: 'Compares A and B as raw values without converting to strings (strict typed check).',
+ arguments: {
+ A: commonArguments.allowAnything,
+ B: commonArguments.allowAnything,
+ }
+ },
+ {
+ opcode: 'assertUnstrictEqual',
+ blockType: BlockType.COMMAND,
+ text: 'assert string equality [A] = [B]',
+ tooltip: 'Converts both inputs to strings first, then checks for equal text.',
+ arguments: {
+ A: commonArguments.allowAnything,
+ B: commonArguments.allowAnything,
+ }
+ },
+ {
+ opcode: 'assertUnstrictNotEqual',
+ blockType: BlockType.COMMAND,
+ text: 'assert string inequality [A] != [B]',
+ tooltip: 'Converts both inputs to strings first, then checks they differ as text.',
+ arguments: {
+ A: commonArguments.allowAnything,
+ B: commonArguments.allowAnything,
+ }
+ },
+ {
+ opcode: 'assertTextInValue',
+ blockType: BlockType.COMMAND,
+ text: 'assert text [TEXT] in value [VALUE]',
+ tooltip: 'Converts both inputs to strings and asserts value contains text.',
+ arguments: {
+ TEXT: { type: ArgumentType.STRING, defaultValue: 'sit amet' },
+ VALUE: { type: ArgumentType.STRING, defaultValue: 'Lorem ipsum dolor sit amet, consetetur' },
+ }
+ },
+ {
+ opcode: 'assertTextNotInValue',
+ blockType: BlockType.COMMAND,
+ text: 'assert text [TEXT] not in value [VALUE]',
+ tooltip: 'Converts both inputs to strings and asserts value does not contain text.',
+ arguments: {
+ TEXT: { type: ArgumentType.STRING, defaultValue: 'hello' },
+ VALUE: { type: ArgumentType.STRING, defaultValue: 'hello world' }
+ }
+ },
+ {
+ opcode: 'assertType',
+ blockType: BlockType.COMMAND,
+ text: 'assert type of [VALUE] is [EXPECTED]',
+ tooltip: 'Checks the value against types from common extensions and defaults to JavaScript base types.',
+ arguments: {
+ VALUE: commonArguments.allowAnything,
+ EXPECTED: { type: ArgumentType.STRING, menu: 'expectedType' }
+ }
+ },
+ {
+ opcode: 'assertCustomIdType',
+ blockType: BlockType.COMMAND,
+ text: 'assert custom id of [VALUE] is [EXPECTED]',
+ tooltip: 'Checks the `customID` property of a PM custom type. This also supports custom types from uncommon or new extensions.',
+ arguments: {
+ VALUE: commonArguments.allowAnything,
+ EXPECTED: { type: ArgumentType.STRING, defaultValue: 'jwArray' }
+ }
+ },
+ "---",
+ {
+ opcode: 'assertThrows',
+ blockType: BlockType.CONDITIONAL,
+ branchCount: 1,
+ text: 'assert throws error',
+ tooltip: 'Runs enclosed blocks and fails unless an error is thrown.',
+ },
+ {
+ opcode: 'assertThrowsContains',
+ blockType: BlockType.CONDITIONAL,
+ branchCount: 1,
+ text: 'assert throws error containing [MSG]',
+ tooltip: 'Runs enclosed blocks and fails unless an error is thrown and it\'s message contains the text.',
+ arguments: {
+ MSG: commonArguments.errorMessage,
+ }
+ },
+ {
+ opcode: 'assertDoesNotThrow',
+ blockType: BlockType.CONDITIONAL,
+ branchCount: 1,
+ text: 'assert does not throw error',
+ tooltip: 'Runs enclosed blocks and fails if any error is thrown.',
+ },
+ "---",
+ {
+ opcode: 'failTest',
+ blockType: BlockType.COMMAND,
+ text: 'fail test with message [MSG]',
+ tooltip: 'Throws a custom error to indicate a test failed. You can also use the "throw" block from controls of course.',
+ arguments: {
+ MSG: commonArguments.errorMessage,
+ }
+ },
+ ],
+ menus: {
+ expectedType: {
+ acceptReporters: true,
+ acceptReporters: true,
+ items: [
+ "Boolean",
+ "Number",
+ "String",
+
+ "Buffer (AndrewGaming587)",
+ "Buffer Pointer (AndrewGaming587)",
+ "Date (Old Version) (ddededodediamante)",
+ "Date (ddededodediamante)",
+ "Effect (Div)",
+ "Iterator (Div)",
+ "Object (DogeisCut)",
+ "Regular Expression (DogeisCut)",
+ "Set (DogeisCut)",
+ "External Timer (steve0greatness)",
+ "Array (jwklong)",
+ "Color (jwklong)",
+ "Date (jwklong)",
+ "Lambda (jwklong)",
+ "Number (jwklong)",
+ "Target (jwklong)",
+ "Vector (jwklong)",
+ "XML (jwklong)",
+ "Canvas (RedMan13)",
+ "Paint Utils Colour (Fruits555000)",
+
+ "JavaScript Undefined",
+ "JavaScript Null",
+ "JavaScript BigInt",
+ "JavaScript Symbol",
+ "JavaScript Function",
+ "JavaScript Object (generic)",
+ "Unknown (rare)"
+ ]
+ }
+ }
+ }
+ return info
+ }
+
+ /** @returns {Object} */
+ getCompileInfo() {
+ const EXTENSION_PREFIX = "runtime.ext_gceTestRunner"
+
+ /**
+ * @param {string} kind
+ * @param {Array} inputs
+ * @returns {(generator: *, block: *) => Object}
+ */
+ const createIRGenerator = (kind, inputs) => ((generator, block) => {
+ const result = { kind }
+ inputs.forEach(inputName => {
+ result[inputName] = inputName === "SUBSTACK"
+ ? generator.descendSubstack(block, inputName)
+ : generator.descendInputOfBlock(block, inputName)
+ })
+ return result
+ })
+
+ /**
+ * @param {*} compiler
+ * @param {*} substack
+ * @param {*} imports
+ * @returns {void}
+ */
+ const addSubstackCode = (compiler, substack, imports) => {
+ compiler.descendStack(substack, new imports.Frame(false, undefined, true))
+ }
+
+ const irInfo = {
+ testScope: createIRGenerator("stack", ["NAME", "SUBSTACK"]),
+ assertThrows: createIRGenerator("stack", ["SUBSTACK"]),
+ assertThrowsContains: createIRGenerator("stack", ["MSG", "SUBSTACK"]),
+ assertDoesNotThrow: createIRGenerator("stack", ["SUBSTACK"]),
+ }
+
+ const jsInfo = {
+ testScope: (node, compiler, imports) => {
+ const nameLocal = compiler.localVariables.next()
+ const errLocal = compiler.localVariables.next()
+ compiler.source += `const ${nameLocal} = ${compiler.descendInput(node.NAME).asString()};\n`
+ compiler.source += `${EXTENSION_PREFIX}._testScopes.push(${nameLocal})\n`
+ compiler.source += `try {\ntry {\n`
+ addSubstackCode(compiler, node.SUBSTACK, imports)
+ compiler.source += `} catch (${errLocal}) {\n`
+ compiler.source += ` throw ${EXTENSION_PREFIX}._wrapError(\`test scope \${${EXTENSION_PREFIX}.quote(${nameLocal})}:\`, ${errLocal});\n`
+ compiler.source += `}} finally {\n${EXTENSION_PREFIX}._testScopes.pop();\n}\n`
+ },
+ assertThrows: (node, compiler, imports) => {
+ const errLocal = compiler.localVariables.next()
+ const catchLocal = compiler.localVariables.next()
+ compiler.source += `let ${errLocal};\n`
+ compiler.source += `try {\n`
+ addSubstackCode(compiler, node.SUBSTACK, imports)
+ compiler.source += `} catch (${catchLocal}) { ${errLocal} = ${catchLocal}; }\n`
+ compiler.source += `if (!${errLocal}) throw new ${EXTENSION_PREFIX}.TestError("Expected exception but none was thrown");\n`
+ },
+ assertThrowsContains: (node, compiler, imports) => {
+ const errLocal = compiler.localVariables.next()
+ const catchLocal = compiler.localVariables.next()
+ const expectedLocal = compiler.localVariables.next()
+ compiler.source += `let ${errLocal};\n`
+ compiler.source += `try {\n`
+ addSubstackCode(compiler, node.SUBSTACK, imports)
+ compiler.source += `} catch (${catchLocal}) { ${errLocal} = ${catchLocal}; }\n`
+ compiler.source += `const ${expectedLocal} = ${compiler.descendInput(node.MSG).asString()};\n`
+ compiler.source += `if (!${errLocal}) throw new ${EXTENSION_PREFIX}.TestError("Expected exception but none was thrown");\n`
+ compiler.source += `if (!${EXTENSION_PREFIX}.TestError.errorContainsMsg(${errLocal}, ${expectedLocal})) `+
+ `throw ${EXTENSION_PREFIX}._errorWithCause(\`Expected exception containing \${${EXTENSION_PREFIX}.quote(${expectedLocal})} but got \${${EXTENSION_PREFIX}.quote(${EXTENSION_PREFIX}.TestError.getActualErrorMessage(${errLocal}))}\`, ${errLocal});\n`
+ },
+ assertDoesNotThrow: (node, compiler, imports) => {
+ const errLocal = compiler.localVariables.next()
+ const catchLocal = compiler.localVariables.next()
+ compiler.source += `let ${errLocal};\n`
+ compiler.source += `try {\n`
+ addSubstackCode(compiler, node.SUBSTACK, imports)
+ compiler.source += `} catch (${catchLocal}) { ${errLocal} = ${catchLocal}; }\n`
+ compiler.source += `if (${errLocal}) throw ${EXTENSION_PREFIX}._errorWithCause(\`Unexpected exception: \${${EXTENSION_PREFIX}._errorMessage(${errLocal})}\`, ${errLocal});\n`
+ },
+ failTest: (node, compiler) => {
+ compiler.source += `throw new ${EXTENSION_PREFIX}.TestError(\`Test failed: \${${compiler.descendInput(node.MSG).asString()}}\`);\n`
+ },
+ }
+
+ return { ir: irInfo, js: jsInfo }
+ }
+
+ // Compiled-only blocks
+ testScope = this._isACompiledBlock
+ assertThrows = this._isACompiledBlock
+ assertThrowsContains = this._isACompiledBlock
+ assertDoesNotThrow = this._isACompiledBlock
+
+ _isACompiledBlock() {
+ throw new TestError(
+ "This block only works in compiled mode. " +
+ "Make sure the Test Runner extension is registered with compiled block support."
+ )
+ }
+
+ /** @param {Object} args */
+ assert ({CONDITION}) {
+ CONDITION = Cast.toBoolean(CONDITION)
+ if (!CONDITION) throw new TestError("Assertion failed: condition was false")
+ }
+
+ /** @param {Object} args */
+ assertNot ({CONDITION}) {
+ CONDITION = Cast.toBoolean(CONDITION)
+ if (CONDITION) throw new TestError("Assertion failed: condition was true")
+ }
+
+ /** @param {Object} args */
+ assertMsg ({CONDITION, MSG}) {
+ CONDITION = Cast.toBoolean(CONDITION)
+ MSG = Cast.toString(MSG)
+ if (!CONDITION) throw new TestError(`Assertion failed: condition was false: ${MSG}`)
+ }
+
+ /** @param {Object} args */
+ assertNotMsg ({CONDITION, MSG}) {
+ CONDITION = Cast.toBoolean(CONDITION)
+ MSG = Cast.toString(MSG)
+ if (CONDITION) throw new TestError(`Assertion failed: condition was true: ${MSG}`)
+ }
+
+ /** @param {Object} args */
+ assertStrictEqual ({A, B}) {
+ if (A !== B) throw new TestError(`Assertion failed: got ${this._valueWithType(A)}, expected ${this._valueWithType(B)}`)
+ }
+
+ /** @param {Object} args */
+ assertStrictNotEqual ({A, B}) {
+ if (A === B) throw new TestError(`Assertion failed: values unexpectedly equal: ${this._valueWithType(A)} and ${this._valueWithType(B)}`)
+ }
+
+ /** @param {Object} args */
+ assertUnstrictEqual ({A, B}) {
+ const aStr = Cast.toString(A)
+ const bStr = Cast.toString(B)
+ if (aStr !== bStr) throw new TestError(`Assertion failed: got ${quote(aStr)}, expected ${quote(bStr)}`)
+ }
+
+ /** @param {Object} args */
+ assertUnstrictNotEqual ({A, B}) {
+ const aStr = Cast.toString(A)
+ const bStr = Cast.toString(B)
+ if (aStr === bStr) throw new TestError(`Assertion failed: values unexpectedly equal: ${quote(aStr)}`)
+ }
+
+ /** @param {Object} args */
+ assertTextInValue ({TEXT, VALUE}) {
+ const textStr = Cast.toString(TEXT)
+ const valueStr = Cast.toString(VALUE)
+ if (!valueStr.includes(textStr)) throw new TestError(`Assertion failed: text ${quote(textStr)} not found in value ${quote(valueStr)}`)
+ }
+
+ /** @param {Object} args */
+ assertTextNotInValue ({TEXT, VALUE}) {
+ const textStr = Cast.toString(TEXT)
+ const valueStr = Cast.toString(VALUE)
+ if (valueStr.includes(textStr)) throw new TestError(`Assertion failed: text ${quote(textStr)} unexpectedly found in value ${quote(valueStr)}`)
+ }
+
+ /** @param {Object} args */
+ assertType ({VALUE, EXPECTED}) {
+ const expectedType = Cast.toString(EXPECTED)
+ const actualType = this.TypeChecker.stringTypeof(VALUE)
+ if (actualType !== expectedType) {
+ throw new TestError(
+ `Assertion failed: expected type ${quote(expectedType)} but got ${quote(actualType)} for value ${quote(VALUE)}`
+ )
+ }
+ }
+
+ /** @param {Object} args */
+ assertCustomIdType ({VALUE, EXPECTED}) {
+ const expectedType = Cast.toString(EXPECTED)
+ let customId
+ if (typeof VALUE === "object") {
+ if (VALUE && typeof VALUE.customId === "string") {
+ customId = VALUE.customId
+ } else {
+ customId = ""
+ }
+ } else {
+ customId = ""
+ }
+ if (customId !== expectedType) {
+ throw new TestError(
+ `Assertion failed: expected custom id ${quote(expectedType)} but got ${quote(customId)} for value ${quote(VALUE)}`
+ )
+ }
+ }
+
+ /** @param {Object} args */
+ failTest ({MSG}) {
+ throw new TestError(`Test failed: ${Cast.toString(MSG)}`)
+ }
+
+
+
+ /**
+ * @param {*} error
+ * @returns {string}
+ */
+ _errorMessage (error) {
+ return this._formatErrorLines(error).join("\n")
+ }
+
+ /**
+ * @param {*} value
+ * @returns {string}
+ */
+ _typeLabel (value) {
+ if (value === null) return "null"
+ if (value === undefined) return "undefined"
+ const baseType = typeof value
+ const ctorName = value && value.constructor && value.constructor.name
+ return ctorName ? `${baseType} (${ctorName})` : baseType
+ }
+
+ /**
+ * @param {*} value
+ * @returns {string}
+ */
+ _valueWithType (value) {
+ return `${quote(value)} [${this._typeLabel(value)}]`
+ }
+
+ /**
+ * @param {string} message
+ * @param {*} cause
+ * @returns {TestError}
+ */
+ _wrapError (message, cause) {
+ const combinedMessage = [
+ message,
+ ...this._formatErrorLines(cause)
+ ].join("\n")
+ const innerActualMessage = TestError.preserveActualMessage(null, cause)
+ return this._errorWithCause(combinedMessage, cause, message, innerActualMessage)
+ }
+
+ /**
+ * @param {string} message
+ * @param {*} cause
+ * @param {?string} [scopePrefix]
+ * @param {?string} [actualMessage]
+ * @returns {TestError}
+ */
+ _errorWithCause (message, cause, scopePrefix = null, actualMessage = null) {
+ return new TestError(message, {
+ cause,
+ scopePrefix,
+ actualMessage: TestError.preserveActualMessage(actualMessage, cause)
+ })
+ }
+
+ /**
+ * @param {*} error
+ * @returns {Array}
+ */
+ _formatErrorLines (error) {
+ if (!(error instanceof Error)) return [String(error)]
+ return String(error.message).split("\n")
+ }
+}
+
+const testRunnerInstance = new TestRunner()
+const runtime = Scratch.vm.runtime
+
+if (isRuntimeEnv) {
+ const oldConvertBlock = runtime._convertBlockForScratchBlocks.bind(runtime)
+ if (!oldConvertBlock.tooltipImplementationAdded) {
+ /**
+ * @param {Object} blockInfo
+ * @param {Object} categoryInfo
+ * @returns {Object}
+ */
+ runtime._convertBlockForScratchBlocks = function (blockInfo, categoryInfo) {
+ const result = oldConvertBlock(blockInfo, categoryInfo)
+ if (blockInfo.tooltip) {
+ result.json.tooltip = blockInfo.tooltip
+ }
+ return result
+ }
+ runtime._convertBlockForScratchBlocks.tooltipImplementationAdded = true
+ }
+}
+
+Scratch.extensions.register(testRunnerInstance)
+if (isRuntimeEnv) {
+ Scratch.vm.runtime.registerCompiledExtensionBlocks(
+ "gceTestRunner", testRunnerInstance.getCompileInfo(),
+ )
+}
+})(Scratch)
diff --git a/static/images/GermanCodeEngineer/TestRunnerBanner.svg b/static/images/GermanCodeEngineer/TestRunnerBanner.svg
new file mode 100644
index 000000000..ae81c1258
--- /dev/null
+++ b/static/images/GermanCodeEngineer/TestRunnerBanner.svg
@@ -0,0 +1,16 @@
+