All Downloads are FREE. Search and download functionalities are using the official Maven repository.

stioner.lib.2024.8.0.source-code.TestTests.kt Maven / Gradle / Ivy

There is a newer version: 2024.9.0
Show newest version
package edu.illinois.cs.cs125.questioner.lib

import edu.illinois.cs.cs125.jeed.core.CheckstyleArguments
import edu.illinois.cs.cs125.jeed.core.CheckstyleFailed
import edu.illinois.cs.cs125.jeed.core.CompilationArguments
import edu.illinois.cs.cs125.jeed.core.CompilationFailed
import edu.illinois.cs.cs125.jeed.core.CompiledSource
import edu.illinois.cs.cs125.jeed.core.ConfiguredSandboxPlugin
import edu.illinois.cs.cs125.jeed.core.JeedClassLoader
import edu.illinois.cs.cs125.jeed.core.KompilationArguments
import edu.illinois.cs.cs125.jeed.core.KtLintArguments
import edu.illinois.cs.cs125.jeed.core.KtLintFailed
import edu.illinois.cs.cs125.jeed.core.LineLimitExceeded
import edu.illinois.cs.cs125.jeed.core.Sandbox
import edu.illinois.cs.cs125.jeed.core.Source
import edu.illinois.cs.cs125.jeed.core.TemplatingFailed
import edu.illinois.cs.cs125.jeed.core.checkstyle
import edu.illinois.cs.cs125.jeed.core.compile
import edu.illinois.cs.cs125.jeed.core.fromJavaSnippet
import edu.illinois.cs.cs125.jeed.core.fromKotlinSnippet
import edu.illinois.cs.cs125.jeed.core.fromTemplates
import edu.illinois.cs.cs125.jeed.core.kompile
import edu.illinois.cs.cs125.jeed.core.ktLint
import edu.illinois.cs.cs125.jeed.core.moshi.CompiledSourceResult
import edu.illinois.cs.cs125.jenisol.core.isPackagePrivate
import edu.illinois.cs.cs125.jenisol.core.isPrivate
import edu.illinois.cs.cs125.jenisol.core.isStatic
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.ClassWriter
import org.objectweb.asm.Opcodes
import java.lang.reflect.InvocationTargetException
import java.time.Instant
import kotlin.math.roundToInt
import kotlin.random.Random

suspend fun Question.testTests(
    contents: String,
    language: Language,
    passedSettings: Question.TestTestingSettings? = null,
    limits: Question.TestTestingLimits = testTestingLimits!!
): TestTestResults {
    try {
        testingLimiter.acquire()

        val settings = Question.TestTestingSettings.DEFAULTS merge passedSettings

        check(published.type != Question.Type.SNIPPET) { "Test testing not supported for snippets" }
        check(settings.limit!! >= 2) { "Limit must be at least 2" }

        warm()

        val testKlass = "Test${published.klass}"
        val results = TestTestResults(language)

        // checkInitialTestTestingSubmission
        if (!(checkInitialTestTestingSubmission(contents, language, results))) {
            return results
        }

        val compilationClassLoader = when (language) {
            Language.java -> InvertingClassLoader(setOf(testKlass), compiledSolutionForTesting.classloader)
            Language.kotlin -> InvertingClassLoader(
                setOf(testKlass, "${testKlass}Kt"),
                compiledSolutionForTesting.classloader
            )
        }
        val compiledSubmission = try {
            when (language) {
                Language.java -> compileTestSuites(contents, compilationClassLoader, results)
                Language.kotlin -> kompileTestSuites(contents, compilationClassLoader, results)
            }
        } catch (e: TemplatingFailed) {
            return results
        } catch (e: CompilationFailed) {
            return results
        } catch (e: CheckstyleFailed) {
            return results
        } catch (e: KtLintFailed) {
            return results
        }

        // checkCompiledTestSuite
        val klassName = checkCompiledTestSuite(compiledSubmission, results) ?: return results
        val testingIncorrect = testTestingIncorrect
        check(!testingIncorrect.isNullOrEmpty()) {
            "Value should not be null or empty"
        }

        val incorrectLimit = (settings.limit - 1).coerceAtMost(testingIncorrect.size)
        val testingMutations = when (settings.selectionStrategy!!) {
            Question.TestTestingSettings.SelectionStrategy.EASIEST -> testingIncorrect.take(incorrectLimit)
            Question.TestTestingSettings.SelectionStrategy.HARDEST -> testingIncorrect.takeLast(incorrectLimit)
            Question.TestTestingSettings.SelectionStrategy.EVENLY_SPACED -> {
                linspace(testingIncorrect.size - 1, incorrectLimit).map { testingIncorrect[it] }
            }
        }

        val testingLoaders = testingMutations.map { it.compiled(this) }.toMutableList()
        val random = if (settings.seed != null) {
            Random(settings.seed)
        } else {
            Random
        }
        testingLoaders.add(random.nextInt(testingLoaders.size + 1), compiledSolutionForTesting)

        val baseTimeout = (questionerTestTestTimeoutMS.toDouble() * control.timeoutMultiplier!!).toLong()

        val executionArguments = Sandbox.ExecutionArguments(
            timeout = baseTimeout * questionerWallClockTimeoutMultiplier,
            cpuTimeoutNS = baseTimeout * 1000L * 1000L,
            maxOutputLines = limits.outputLimit,
            permissions = Question.SAFE_PERMISSIONS,
            returnTimeout = Question.DEFAULT_RETURN_TIMEOUT,
            pollIntervalMS = (baseTimeout / 2).coerceAtLeast(1)
        )

        val lineCountLimit = when (language) {
            Language.java -> limits.executionCountLimit.java
            Language.kotlin -> limits.executionCountLimit.kotlin!!
        }
        val allocationLimit = when (language) {
            Language.java -> limits.allocationLimit.java
            Language.kotlin -> limits.allocationLimit.kotlin!!
        }.coerceAtLeast(MIN_ALLOCATION_LIMIT_BYTES)

        val plugins = listOf(
            ConfiguredSandboxPlugin(
                ResourceMonitoring,
                ResourceMonitoringArguments(
                    submissionLineLimit = lineCountLimit,
                    allocatedMemoryLimit = allocationLimit,
                    individualAllocationLimit = MAX_INDIVIDUAL_ALLOCATION_BYTES
                )
            ),
        )

        val testingClass = compiledSubmission.classLoader.loadClass(klassName)
        val staticTestingMethod = testingClass.getTestingMethod()!!.isStatic()
        if (!staticTestingMethod) {
            check(testingClass.declaredConstructors.find { it.parameters.isEmpty() } != null) {
                "Non-static testing method needs an empty constructor"
            }
        }

        var correct = 0
        var incorrect = 0
        var identifiedSolution: Boolean? = null

        val testTestingStarted = Instant.now()
        val output = mutableListOf()
        for (testingLoader in testingLoaders) {
            val isSolution = testingLoader == compiledSolutionForTesting

            val testingSuiteLoader = CopyableClassLoader.copy(compiledSubmission.classLoader, testingLoader.classloader)
            val taskResults = Sandbox.execute(
                testingSuiteLoader,
                executionArguments,
                configuredPlugins = plugins
            ) { (classLoader, _) ->
                return@execute try {
                    classLoader.loadClass(klassName).getTestingMethod()!!.also { method ->
                        method.isAccessible = true
                        if (staticTestingMethod) {
                            method.invoke(null)
                        } else {
                            method.invoke(
                                classLoader.loadClass(klassName).declaredConstructors.find { it.parameters.isEmpty() }!!
                                    .newInstance()
                            )
                        }
                    }
                } catch (e: InvocationTargetException) {
                    throw e.cause ?: e
                }
            }

            val timeout = taskResults.timeout
            val threw = taskResults.threw

            @Suppress("DEPRECATION", "removal")
            if (!taskResults.timeout && threw is ThreadDeath) {
                throw CachePoisonedException("ThreadDeath")
            }
            results.timeout = timeout

            val resourceUsage = taskResults.pluginResult(ResourceMonitoring)
            val submissionExecutionCount = resourceUsage.submissionLines
            results.lineCountTimeout = submissionExecutionCount > lineCountLimit

            if (results.timeout) {
                return results
            }

            if (results.lineCountTimeout) {
                results.failedSteps.add(TestTestResults.Step.checkExecutedSubmission)
                results.failed.checkExecutedSubmission =
                    "Executed too many lines: Already executed $lineCountLimit ${"line".pluralize(lineCountLimit.toInt())}, " +
                        "greater than the limit of $lineCountLimit"
                return results
            }

            when (threw) {
                is ClassNotFoundException -> results.failed.checkExecutedSubmission =
                    "Class design error:\n  Could not find class ${published.klass}"

                is NoClassDefFoundError -> results.failed.checkExecutedSubmission =
                    "Class design error:\n  Attempted to use unavailable class ${threw.message}"

                is OutOfMemoryError -> results.failed.checkExecutedSubmission =
                    "Allocated too much memory: ${threw.message}, already used ${resourceUsage.allocatedMemory} bytes.\nIf you are printing for debug purposes, consider less verbose output."

                is LineLimitExceeded -> {
                    results.failed.checkExecutedSubmission =
                        "Executed too many lines: Already executed $lineCountLimit ${"line".pluralize(lineCountLimit.toInt())}, " +
                            "greater than the limit of $lineCountLimit"
                }
            }

            if (results.failed.checkExecutedSubmission != null) {
                results.failedSteps.add(TestTestResults.Step.checkExecutedSubmission)
                return results
            }

            val isCorrect = if (isSolution) {
                taskResults.threw == null
            } else {
                taskResults.threw != null
            }
            if (isSolution) {
                identifiedSolution = isCorrect
            }
            if (isCorrect) {
                correct++
            } else {
                incorrect++
            }
            output += taskResults.stdout.trim() + if (taskResults.truncatedLines > 0) {
                "\n(${taskResults.truncatedLines} lines truncated)\n"
            } else {
                "\n"
            }
            if (incorrect > 0 && settings.shortCircuit!!) {
                break
            }
        }

        if (identifiedSolution != null) {
            identifiedSolution = identifiedSolution == true && correct > 1
        }

        results.addTestTestingResults(
            TestTestResults.TestTestingResults(
                correct,
                incorrect,
                identifiedSolution,
                testingLoaders.size,
                Instant.now().toEpochMilli() - testTestingStarted.toEpochMilli(),
                output
            )
        )
        return results
    } finally {
        testingLimiter.release()
    }
}

fun Question.templateTestSuites(
    contents: String,
    language: Language
): Pair {
    val template = when (published.type) {
        Question.Type.KLASS -> null
        Question.Type.METHOD -> {
            when (language) {
                Language.java -> {
                    """public class Test${published.klass} extends ${published.klass} {
  {{{ contents }}}
}
"""
                }

                Language.kotlin -> {
                    """class Test${published.klass} : ${published.klass}() {
  {{{ contents }}}
}"""
                }
            }
        }

        Question.Type.SNIPPET -> error("Testing not supported for snippets")
    }

    val fileName = "Test${published.klass}.${language.extension()}"
    return if (template == null) {
        Pair(Source(mapOf(fileName to contents)), null)
    } else {
        Pair(
            Source.fromTemplates(
                mapOf(fileName to contents.trimEnd()),
                mapOf("$fileName.hbs" to template)
            ), template
        )
    }
}

@Suppress("ThrowsCount")
suspend fun Question.compileTestSuites(
    contents: String,
    parentClassLoader: ClassLoader,
    testResults: TestTestResults
): CompiledSource {
    return try {
        val (source, template) = templateTestSuites(contents, Language.java)
        if (template != null) {
            testResults.completedSteps.add(TestTestResults.Step.templateSubmission)
        }
        val compiledSource = source.compile(
            CompilationArguments(
                parentClassLoader = parentClassLoader,
                parentFileManager = compiledSolution.fileManager,
                parameters = true
            )
        ).also {
            testResults.complete.compileSubmission = CompiledSourceResult(it)
            testResults.completedSteps.add(TestTestResults.Step.compileSubmission)
        }
        testResults.addCheckstyleResults(source.checkstyle(CheckstyleArguments(failOnError = false)))
        compiledSource
    } catch (e: TemplatingFailed) {
        testResults.failed.templateSubmission = e
        testResults.failedSteps.add(TestTestResults.Step.templateSubmission)
        throw e
    } catch (e: CheckstyleFailed) {
        testResults.failed.checkstyle = e
        testResults.failedSteps.add(TestTestResults.Step.checkstyle)
        throw e
    } catch (e: CompilationFailed) {
        testResults.failed.compileSubmission = e
        testResults.failedSteps.add(TestTestResults.Step.compileSubmission)
        throw e
    }
}

@Suppress("ThrowsCount")
suspend fun Question.kompileTestSuites(
    contents: String,
    parentClassLoader: ClassLoader,
    testResults: TestTestResults
): CompiledSource {
    return try {
        val (source, template) = templateTestSuites(contents, Language.kotlin)
        if (template != null) {
            testResults.completedSteps.add(TestTestResults.Step.templateSubmission)
        }
        val compiledSource = source.kompile(
            KompilationArguments(
                parentClassLoader = parentClassLoader,
                parentFileManager = compiledSolution.fileManager,
                parameters = true
            )
        ).also {
            testResults.complete.compileSubmission = CompiledSourceResult(it)
            testResults.completedSteps.add(TestTestResults.Step.compileSubmission)
        }
        testResults.addKtlintResults(source.ktLint(KtLintArguments(failOnError = false)))
        compiledSource
    } catch (e: TemplatingFailed) {
        testResults.failed.templateSubmission = e
        testResults.failedSteps.add(TestTestResults.Step.templateSubmission)
        throw e
    } catch (e: KtLintFailed) {
        testResults.failed.ktlint = e
        testResults.failedSteps.add(TestTestResults.Step.ktlint)
        throw e
    } catch (e: CompilationFailed) {
        testResults.failed.compileSubmission = e
        testResults.failedSteps.add(TestTestResults.Step.compileSubmission)
        throw e
    }
}

private fun Class<*>.getTestingMethod() = declaredMethods.find { testingMethod ->
    testingMethod.name == "test" && testingMethod.parameters.isEmpty() && !testingMethod.isPrivate()
}

fun Question.checkCompiledTestSuite(
    compiledTestSuite: CompiledSource,
    testResults: TestTestResults
): String? = compiledTestSuite.classLoader.definedClasses.topLevelClasses().let { klasses ->
    val testKlass = "Test${published.klass}"

    when {
        klasses.size != 1 -> {
            testResults.failed.checkCompiledSubmission =
                "Test suite should define a single public class with an empty or omitted constructor"
            testResults.failedSteps.add(TestTestResults.Step.checkCompiledSubmission)
            return null
        }
    }
    var klass = klasses.first()
    if (compiledTestSuite.source.type == Source.SourceType.KOTLIN &&
        (solution.skipReceiver || solution.fauxStatic) &&
        klass == "${testKlass}Kt"
    ) {
        klass = "${testKlass}Kt"
    } else {
        if (klass != testKlass) {
            testResults.failed.checkCompiledSubmission =
                "Test suite defines incorrect class: ${klasses.first()} != $testKlass"
            testResults.failedSteps.add(TestTestResults.Step.checkCompiledSubmission)
            return null
        }
    }
    compiledTestSuite.classLoader.loadClass(klass).also { testingKlass ->
        testingKlass.getTestingMethod() ?: run {
            testResults.failed.checkCompiledSubmission =
                "Test suite does not define a non-private static void testing method named test accepting no arguments"
            testResults.failedSteps.add(TestTestResults.Step.checkCompiledSubmission)
            return null
        }
        val fields = testingKlass.declaredFields.toSet().filter { field ->
            field.name != "${"$"}assertionsDisabled" && !(compiledTestSuite.source.type == Source.SourceType.KOTLIN && field.name == "Companion")
        }
        if (fields.isNotEmpty()) {
            testResults.failed.checkCompiledSubmission =
                "Testing class may not declare fields"
            testResults.failedSteps.add(TestTestResults.Step.checkCompiledSubmission)
            return null
        }
    }
    return klass
}

class CopyableClassLoader(override val bytecodeForClasses: Map, parent: ClassLoader) :
    ClassLoader(parent), Sandbox.SandboxableClassLoader {
    override val classLoader: ClassLoader = this

    override fun findClass(name: String): Class<*> {
        return if (name in bytecodeForClasses) {
            return defineClass(name, bytecodeForClasses[name]!!, 0, bytecodeForClasses[name]!!.size)
        } else {
            super.findClass(name)
        }
    }

    companion object {
        fun copy(classLoader: JeedClassLoader, parent: ClassLoader) =
            CopyableClassLoader(classLoader.bytecodeForClasses, parent)
    }
}

fun Question.fixTestingMethods(classLoader: JeedClassLoader): ClassLoader {
    val methodsToOpen = classLoader.loadClass(published.klass).declaredMethods
        .filter { method -> method.isPackagePrivate() }
        .map { method -> method.name }
    val classReader = ClassReader(classLoader.bytecodeForClasses[published.klass])
    val classWriter = ClassWriter(classReader, 0)
    val openingVisitor = object : ClassVisitor(Opcodes.ASM8, classWriter) {
        override fun visitMethod(
            access: Int,
            name: String,
            descriptor: String?,
            signature: String?,
            exceptions: Array?
        ) = when (name) {
            in methodsToOpen -> super.visitMethod(
                access or Opcodes.ACC_PUBLIC,
                name,
                descriptor,
                signature,
                exceptions
            )

            else -> super.visitMethod(access, name, descriptor, signature, exceptions)
        }
    }
    classReader.accept(openingVisitor, 0)
    return CopyableClassLoader(mapOf(published.klass to classWriter.toByteArray()), classLoader.parent)
}

@Suppress("SpellCheckingInspection")
fun linspace(stop: Int, num: Int): List {
    check(num <= stop + 1) { "Bad num value" }
    val step = stop.toDouble() / (num - 1)
    return (0 until num).map { (it * step).roundToInt() }.distinct().also {
        check(it.contains(stop)) { "$stop $num: $it does not contain $stop" }
        check(it.size == num) { "$stop $num: $it does not have size $num" }
    }
}

fun Question.checkInitialTestTestingSubmission(
    contents: String,
    language: Language,
    testResults: TestTestResults
): Boolean {
    val snippetProperties = try {
        when (language) {
            Language.java -> Source.fromJavaSnippet(contents)
            Language.kotlin -> Source.fromKotlinSnippet(contents)
        }.snippetProperties
    } catch (e: Exception) {
        testResults.completedSteps.add(TestTestResults.Step.checkInitialSubmission)
        // If the code doesn't parse as a snippet, fall back to compiler error messages which are usually more useful
        return true
    }
    when (published.type) {
        Question.Type.SNIPPET -> error("Snippets not supported for test testing")

        Question.Type.METHOD -> {
            if (snippetProperties.importCount > 0) {
                testResults.failed.checkInitialSubmission = "import statements are not allowed for this problem"
            } else if (snippetProperties.classCount > 0) {
                testResults.failed.checkInitialSubmission = "Class declarations are not allowed for this problem"
            } else if (snippetProperties.looseCount > 0) {
                testResults.failed.checkInitialSubmission =
                    "Submission should be a single testing method with no code outside"
            }
        }

        Question.Type.KLASS -> {
            if (language == Language.java) {
                if (snippetProperties.methodCount > 0) {
                    testResults.failed.checkInitialSubmission =
                        "Top-level method declarations are not allowed for this problem"
                }
            } else if (language == Language.kotlin) {
                if (snippetProperties.classCount > 0 && snippetProperties.methodCount > 0) {
                    testResults.failed.checkInitialSubmission =
                        "Can't mix top-level classes and methods for this problem"
                }
            }
            if (snippetProperties.looseCount > 0) {
                testResults.failed.checkInitialSubmission =
                    "Submission should be a single testing class with no code outside"
            } else if (snippetProperties.classCount > 1) {
                testResults.failed.checkInitialSubmission = "Submission should define a single class"
            }
        }
    }
    return if (testResults.failed.checkInitialSubmission != null) {
        testResults.failedSteps.add(TestTestResults.Step.checkInitialSubmission)
        false
    } else {
        testResults.completedSteps.add(TestTestResults.Step.checkInitialSubmission)
        true
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy