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

com.lesfurets.jenkins.unit.PipelineTestHelper.groovy Maven / Gradle / Ivy

package com.lesfurets.jenkins.unit

import static com.lesfurets.jenkins.unit.MethodSignature.method

import java.lang.reflect.Method
import java.nio.charset.Charset
import java.nio.file.Paths
import java.util.function.Consumer
import java.util.function.Function

import org.apache.commons.io.IOUtils
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ImportCustomizer
import org.codehaus.groovy.runtime.InvokerHelper
import org.codehaus.groovy.runtime.MetaClassHelper

import com.lesfurets.jenkins.unit.global.lib.LibraryAnnotationTransformer
import com.lesfurets.jenkins.unit.global.lib.LibraryConfiguration
import com.lesfurets.jenkins.unit.global.lib.LibraryLoader

class PipelineTestHelper {

    protected static Method SCRIPT_SET_BINDING = Script.getMethod('setBinding', Binding.class)

    /**
     * Search paths for scripts
     */
    String[] scriptRoots

    /**
     * Base path for script roots.
     * Usually the path to the project.
     */
    String baseScriptRoot

    /**
     * Extension for script files.
     * Ex. jenkins
     */
    String scriptExtension

    /**
     * Base class for instantiated scripts
     */
    Class scriptBaseClass = MockPipelineScript.class

    /**
     * Classloader to instantiate scripts
     */
    ClassLoader baseClassloader

    /**
     * Default imports for scripts loaded by this helper
     */
    Map imports = [ 'Library' : 'com.lesfurets.jenkins.unit.global.lib.Library']

    /**
     * Global Shared Libraries to be loaded with scripts if necessary
     * @see LibraryLoader
     */
    Map libraries = [:]

    /**
     * Stack of method calls of scripts loaded by this helper
     */
    List callStack = []

    /**
     * Internal script engine
     */
    protected GroovyScriptEngine gse

    /**
     * Loader for shared global libraries
     */
    protected LibraryLoader libLoader

    /**
     * Method interceptor for method 'load' to load scripts via encapsulated GroovyScriptEngine
     */
    protected loadInterceptor = { args ->
        String name = args
        // The script is loaded by its normal name :
        def relativize = Paths.get(baseScriptRoot).relativize(Paths.get(name)).normalize()
        if (relativize.toFile().exists()) {
            name = relativize.toString()
        } else {
            // The script is loaded from its full name :
            scriptRoots.eachWithIndex { it, i ->
                def resolved = Paths.get(baseScriptRoot, it).resolve(name).normalize()
                if (resolved.toFile().exists()) {
                    name = resolved.toString()
                }
            }
        }
        return this.loadScript(name, delegate.binding)
    }

    /**
     * Method interceptor for method 'parallel'
     */
    protected parallelInterceptor = { Map m ->
        // If you have many steps in parallel and one of the step in Jenkins fails, the other tasks keep runnning in Jenkins.
        // Since here the parallel steps are executed sequentially, we are hiding the error to let other steps run
        // and we make the job failing at the end.
        List exceptions = []
        m.entrySet().stream()
                        .filter { Map.Entry entry -> entry.key != 'failFast' }
                        .forEachOrdered { Map.Entry entry ->
            String parallelName = entry.key
            Closure closure = entry.value
            def result = null
            try {
                result = callClosure(closure)
            } catch (e) {
                delegate.binding.currentBuild.result = 'FAILURE'
                exceptions.add("$parallelName - ${e.getMessage()}")
            }
            return result
        }
        if (exceptions) {
            throw new RuntimeException(exceptions.join(','))
        }
    }

    /**
     * Method interceptor for any method called in executing script.
     * Calls are logged on the call stack.
     */
    public methodInterceptor = { String name, Object[] args ->
        // register method call to stack
        int depth = Thread.currentThread().stackTrace.findAll { it.className == delegate.class.name }.size()
        this.registerMethodCall(delegate, depth, name, args)
        // check if it is to be intercepted
        def intercepted = this.getAllowedMethodEntry(name, args)
        if (intercepted != null && intercepted.value) {
            intercepted.value.delegate = delegate
            return callClosure(intercepted.value, args)
        }
        // if not search for the method declaration
        MetaMethod m = delegate.metaClass.getMetaMethod(name, args)
        // ...and call it. If we cannot find it, delegate call to methodMissing
        def result = (m ? this.callMethod(m, delegate, args) : delegate.metaClass.invokeMissingMethod(delegate, name, args))
        return result
    }

    /**
     * Call given method on delegate object with args parameters
     *
     * @param method method to call
     * @param delegate object of the method call
     * @param args method call parameters
     * @return return value of the object
     */
    protected Object callMethod(MetaMethod method, Object delegate, Object[] args) {
        return method.doMethodInvoke(delegate, args)
    }

    def getMethodInterceptor() {
        return methodInterceptor
    }

    /**
     * Method for calling custom allowed methods
     */
    def methodMissingInterceptor = { String name, args ->
        if (this.isMethodAllowed(name, args)) {
            def result = null
            if (args != null) {
                for (argument in args) {
                    result = this.callIfClosure(argument, result)
                    if (argument instanceof Map) {
                        argument.each { k, v ->
                            result = this.callIfClosure(k, result)
                            result = this.callIfClosure(v, result)
                        }
                    }
                }
            }
            return result
        } else {
            throw new MissingMethodException(name, delegate.class, args)
        }
    }

    def getMethodMissingInterceptor() {
        return methodMissingInterceptor
    }

    def callIfClosure(Object closure, Object currentResult) {
        if (closure instanceof Closure) {
            currentResult = callClosure(closure)
        }
        return currentResult
    }

    /**
     * Method interceptor for 'libraryResource' in Shared libraries
     * The resource from shared library should have been added to the url classloader in advance
     */
    def libraryResourceInterceptor = { m ->
        def stream = gse.groovyClassLoader.getResourceAsStream(m as String)
        if (stream) {
            def string = IOUtils.toString(stream, Charset.forName("UTF-8"))
            IOUtils.closeQuietly(stream)
            return string
        } else {
            throw new GroovyRuntimeException("Library Resource not found with path $m")
        }
    }

    /**
     * List of allowed methods with default interceptors.
     * Complete this list in need with {@link #registerAllowedMethod}
     */
    protected Map allowedMethodCallbacks = [
            (method("load", String.class))                : loadInterceptor,
            (method("parallel", Map.class))               : parallelInterceptor,
            (method("libraryResource", String.class))     : libraryResourceInterceptor,
    ]

    PipelineTestHelper() {
    }

    PipelineTestHelper(String[] scriptRoots,
                       String scriptExtension,
                       Class scriptBaseClass,
                       Map imports,
                       ClassLoader baseClassloader, String baseScriptRoot) {
        this.scriptRoots = scriptRoots
        this.scriptExtension = scriptExtension
        this.scriptBaseClass = scriptBaseClass
        this.imports = imports
        this.baseClassloader = baseClassloader
        this.baseScriptRoot = baseScriptRoot
    }

    PipelineTestHelper init() {
        CompilerConfiguration configuration = new CompilerConfiguration()
        GroovyClassLoader cLoader = new InterceptingGCL(this, baseClassloader, configuration)

        libLoader = new LibraryLoader(cLoader, libraries)
        LibraryAnnotationTransformer libraryTransformer = new LibraryAnnotationTransformer(libLoader)
        configuration.addCompilationCustomizers(libraryTransformer)

        ImportCustomizer importCustomizer = new ImportCustomizer()
        imports.each { k, v -> importCustomizer.addImport(k, v) }
        configuration.addCompilationCustomizers(importCustomizer)

        configuration.setDefaultScriptExtension(scriptExtension)
        configuration.setScriptBaseClass(scriptBaseClass.getName())

        gse = new GroovyScriptEngine(scriptRoots, cLoader)
        gse.setConfig(configuration)
        return this
    }

    /**
     *
     * @return true if internal GroovyScriptEngine is set
     */
    protected boolean isInitialized() {
        return gse != null
    }

    /**
     * Register method call to call stack
     * @param target target object
     * @param stackDepth depth in stack
     * @param name method name
     * @param args method arguments
     */
    protected void registerMethodCall(Object target, int stackDepth, String name, Object... args) {
        MethodCall call = new MethodCall()
        call.target = target
        call.methodName = name
        call.args = args
        call.stackDepth = stackDepth
        callStack.add(call)
    }

    /**
     * Search for the allowed method entry 
     *     A null Closure will mean that the method is allowed but not intercepted.
     * @param name method name
     * @param args parameter objects
     * @return Map.Entry corresponding to the method 
     */
    protected Map.Entry getAllowedMethodEntry(String name, Object... args) {
        Class[] paramTypes = MetaClassHelper.castArgumentsToClassArray(args)
        MethodSignature signature = method(name, paramTypes)
        return allowedMethodCallbacks.find { k, v -> k == signature }
    }

    /**
     *
     * @param name method name
     * @param args parameter objects
     * @return true if method is allowed in this helper
     */
    protected boolean isMethodAllowed(String name, args) {
        return getAllowedMethodEntry(name, args) != null
    }

    /**
     * Load script with name with empty binding
     * @param name path of the script
     * @return the return value of the script
     */
    Object loadScript(String name) {
        return this.loadScript(name, new Binding())
    }

    /**
     * Load and run script with given binding context
     * @param scriptName path of the script
     * @param binding
     * @return the return value of the script
     */
    Object loadScript(String scriptName, Binding binding) {
        Objects.requireNonNull(binding, "Binding cannot be null.")
        Objects.requireNonNull(gse, "GroovyScriptEngine is not initialized: Initialize the helper by calling init().")
        Class scriptClass = gse.loadScriptByName(scriptName)
        setGlobalVars(binding)
        Script script = InvokerHelper.createScript(scriptClass, binding)
        script.metaClass.invokeMethod = getMethodInterceptor()
        script.metaClass.static.invokeMethod = getMethodInterceptor()
        script.metaClass.methodMissing = getMethodMissingInterceptor()
        return runScript(script)
    }

    /**
     * Run the script
     * @param script
     * @return the return value of the script
     */
    protected Object runScript(Script script) {
        return script.run()
    }

    /**
     * Sets global variables defined in loaded libraries on the binding
     * @param binding
     */
    protected void setGlobalVars(Binding binding) {
        libLoader.libRecords.values().stream()
                        .flatMap { it.definedGlobalVars.entrySet().stream() }
                        .forEach { e ->
            if (e.value instanceof Script) {
                Script script = Script.cast(e.value)
                // invoke setBinding from method to avoid interception
                SCRIPT_SET_BINDING.invoke(script, binding)
                script.metaClass.getMethods().findAll { it.name == 'call' }.forEach { m ->
                    this.registerAllowedMethod(method(e.value.class.name, m.getNativeParameterTypes()),
                                    { args -> m.doMethodInvoke(e.value, args) })
                }
            }
            binding.setVariable(e.key, e.value)
        }
    }

    /**
     * @param name method name
     * @param args parameter types
     * @param closure method implementation, can be null
     */
    void registerAllowedMethod(String name, List args = [], Closure closure) {
        allowedMethodCallbacks.put(method(name, args.toArray(new Class[args?.size()])), closure)
    }

    /**
     * Register a callback implementation for a method
     * Calls from the loaded scripts to allowed methods will call the given implementation
     * Null callbacks will only log the call and do nothing
     * @param methodSignature method signature
     * @param closure method implementation, can be null
     */
    void registerAllowedMethod(MethodSignature methodSignature, Closure closure) {
        allowedMethodCallbacks.put(methodSignature, closure)
    }

    /**
     *
     * @param methodSignature
     * @param callback
     */
    void registerAllowedMethod(MethodSignature methodSignature, Function callback) {
        this.registerAllowedMethod(methodSignature,
                        callback != null ? { params -> return callback.apply(params)} : null)
    }

    /**
     *
     * @param methodSignature
     * @param callback
     */
    void registerAllowedMethod(MethodSignature methodSignature, Consumer callback) {
        this.registerAllowedMethod(methodSignature,
                        callback != null ? { params -> return callback.accept(params)} : null)
    }

    /**
     * Register library description
     * See {@link LibraryConfiguration} for its description
     * @param libraryDescription to add
     */
    void registerSharedLibrary(LibraryConfiguration libraryDescription) {
        Objects.requireNonNull(libraryDescription)
        Objects.requireNonNull(libraryDescription.name)
        this.libraries.put(libraryDescription.name, libraryDescription)
    }

    /**
     * Clear call stack
     */
    void clearCallStack() {
        callStack.clear()
    }

    /**
     * Count the number of calls to the method with name
     * @param name method name
     * @return call number
     */
    long methodCallCount(String name) {
        callStack.stream().filter { call ->
            call.methodName == name
        }.count()
    }

    /**
     * Call closure by handling spreading of parameter default values
     *
     * @param closure to call
     * @param args array of arguments passed to this closure call. Is null by default.
     * @return result of the closure call
     */
    Object callClosure(Closure closure, Object[] args = null) {
        // When we use a library method, we should not spread the argument because we define a closure with a single
        // argument. The arguments will be spread in this closure (See PipelineTestHelper#setGlobalVars)
        // For other cases, we spread it before calling
        // Note : InvokerHelper.invokeClosure(intercepted.value, args) is similar to closure.call(*args)
        if (!args) {
            return closure.call()
        } else if (args.size() > closure.maximumNumberOfParameters) {
            return closure.call(args)
        } else {
            return closure.call(*args)
        }
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy