This is an automated email from the ASF dual-hosted git repository. houshengbo pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/incubator-openwhisk.git
The following commit(s) were added to refs/heads/master by this push: new 2ec03a3 Fix the test report issue, so all the test result is posted (#4404) 2ec03a3 is described below commit 2ec03a3557c112d9d12b20870ab39b7bc8053eea Author: Vincent <s...@us.ibm.com> AuthorDate: Thu Mar 28 21:15:54 2019 -0400 Fix the test report issue, so all the test result is posted (#4404) --- Jenkinsfile | 146 ++++----- tests/build.gradle | 17 +- .../scala/invokerShoot/ShootInvokerTests.scala | 340 +++++++++++++++++++++ 3 files changed, 432 insertions(+), 71 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 15ae524..6cf8723 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -25,92 +25,100 @@ timeout(time: 12, unit: 'HOURS') { node("openwhisk") { def hostName = sh(returnStdout: true, script: 'hostname').trim() def domainName = hostName+".apache.org" + def home = sh(returnStdout: true, script: 'echo $HOME').trim() + def jobName = sh(returnStdout: true, script: 'echo $JOB_NAME').trim() + def jobSpace = "${home}/jenkins-slave/workspace/${jobName}" + lock("${hostName}") { - try { - deleteDir() - stage('Checkout') { - checkout scm - } - - stage('Build') { - // Set up a private docker registry service, accessed by all the OpenWhisk VMs. - try { - sh "docker container stop registry && docker container rm -v registry" - } catch (exp) { - println("Unable to stop and remove the container registry.") + sh "mkdir -p ${jobSpace}" + dir("${jobSpace}") { + try { + deleteDir() + stage('Checkout') { + checkout scm } - sh "docker run -d --restart=always --name registry -v \"$HOME\"/certs:/certs \ - -e REGISTRY_HTTP_ADDR=0.0.0.0:${port} -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/${cert} \ - -e REGISTRY_HTTP_TLS_KEY=/certs/${key} -p ${port}:${port} registry:2" - // Build the controller and invoker images. - sh "./gradlew distDocker -PdockerRegistry=${domainName}:${port}" - } + stage('Build') { + // Set up a private docker registry service, accessed by all the OpenWhisk VMs. + try { + sh "docker container stop registry && docker container rm -v registry" + } catch (exp) { + println("Unable to stop and remove the container registry.") + } - stage('Deploy') { - dir("ansible") { - // Copy the jenkins ansible configuration under the directory ansible. This can make sure the SSH is used to - // access the VMs of invokers by the VM of the controller. - sh '[ -f "environments/jenkins/ansible_jenkins.cfg" ] && cp environments/jenkins/ansible_jenkins.cfg ansible.cfg' + sh "docker run -d --restart=always --name registry -v \"$HOME\"/certs:/certs \ + -e REGISTRY_HTTP_ADDR=0.0.0.0:${port} -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/${cert} \ + -e REGISTRY_HTTP_TLS_KEY=/certs/${key} -p ${port}:${port} registry:2" + // Build the controller and invoker images. + sh "./gradlew distDocker -PdockerRegistry=${domainName}:${port}" } - dir("ansible/environments/jenkins") { - sh "cp ${hostName}.j2.ini hosts.j2.ini" - } + stage('Deploy') { + dir("ansible") { + // Copy the jenkins ansible configuration under the directory ansible. This can make sure the SSH is used to + // access the VMs of invokers by the VM of the controller. + sh '[ -f "environments/jenkins/ansible_jenkins.cfg" ] && cp environments/jenkins/ansible_jenkins.cfg ansible.cfg' + } + + dir("ansible/environments/jenkins") { + sh "cp ${hostName}.j2.ini hosts.j2.ini" + } + + dir("ansible/environments/jenkins/group_vars") { + sh "cp ${hostName} all" + } - dir("ansible/environments/jenkins/group_vars") { - sh "cp ${hostName} all" + dir("ansible") { + sh 'ansible-playbook -i environments/jenkins setup.yml' + sh 'ansible-playbook -i environments/jenkins openwhisk.yml -e mode=clean' + sh 'ansible-playbook -i environments/jenkins apigateway.yml -e mode=clean' + sh 'ansible-playbook -i environments/jenkins couchdb.yml -e mode=clean' + sh 'ansible-playbook -i environments/jenkins couchdb.yml' + sh 'ansible-playbook -i environments/jenkins initdb.yml' + sh 'ansible-playbook -i environments/jenkins wipe.yml' + sh 'ansible-playbook -i environments/jenkins apigateway.yml' + sh 'ansible-playbook -i environments/jenkins openwhisk.yml' + sh 'ansible-playbook -i environments/jenkins properties.yml' + sh 'ansible-playbook -i environments/jenkins routemgmt.yml' + sh 'ansible-playbook -i environments/jenkins postdeploy.yml' + } } - dir("ansible") { - sh 'ansible-playbook -i environments/jenkins setup.yml' - sh 'ansible-playbook -i environments/jenkins openwhisk.yml -e mode=clean' - sh 'ansible-playbook -i environments/jenkins apigateway.yml -e mode=clean' - sh 'ansible-playbook -i environments/jenkins couchdb.yml -e mode=clean' - sh 'ansible-playbook -i environments/jenkins couchdb.yml' - sh 'ansible-playbook -i environments/jenkins initdb.yml' - sh 'ansible-playbook -i environments/jenkins wipe.yml' - sh 'ansible-playbook -i environments/jenkins apigateway.yml' - sh 'ansible-playbook -i environments/jenkins openwhisk.yml' - sh 'ansible-playbook -i environments/jenkins properties.yml' - sh 'ansible-playbook -i environments/jenkins routemgmt.yml' - sh 'ansible-playbook -i environments/jenkins postdeploy.yml' + try { + stage('Test') { + sh './gradlew :tests:test -DtestResultsDirName=test-openwhisk' + } + } catch (exp) { + println("Exception:" + exp) } - } - try { - stage('Test') { - sh './gradlew :tests:test' + try { + stage('Shoot one invoker test') { + def folder = "ansible/environments/jenkins/group_vars" + def invoker1_node = sh(returnStdout: true, + script: "grep invoker1_machine ${folder}/${hostName} | cut -d: -f2").trim() + sh "ssh -i ${home}/secret/openwhisk_key openwhisk@${invoker1_node} 'docker stop invoker1'" + sleep time: 1, unit: 'MINUTES' + sh './gradlew :tests:testShootInvoker -DtestResultsDirName=test-shoot-invoker' + sh "ssh -i ${home}/secret/openwhisk_key openwhisk@${invoker1_node} 'docker start invoker1'" + } + } catch (exp) { + println("Exception:" + exp) } - } catch (exp) { - println("Exception:" + exp) - } - try { - stage('Shoot one invoker test') { - def folder = "ansible/environments/jenkins/group_vars" - def invoker1_node = sh(returnStdout: true, - script: "grep invoker1_machine ${folder}/${hostName} | cut -d: -f2").trim() - sh "ssh -i ${home}/secret/openwhisk_key openwhisk@${invoker1_node} 'docker stop invoker1'" - sleep time: 1, unit: 'MINUTES' - sh './gradlew :tests:test -Dtest.single=*WskActionTests*' - sh "ssh -i ${home}/secret/openwhisk_key openwhisk@${invoker1_node} 'docker start invoker1'" + stage('Clean up') { + dir("ansible") { + sh 'ansible-playbook -i environments/jenkins openwhisk.yml -e mode=clean' + sh 'ansible-playbook -i environments/jenkins apigateway.yml -e mode=clean' + sh 'ansible-playbook -i environments/jenkins couchdb.yml -e mode=clean' + } } + } catch (exp) { println("Exception:" + exp) + } finally { + step([$class: 'JUnitResultArchiver', testResults: '**/test*/**/TEST-*.xml']) } - - stage('Clean up') { - dir("ansible") { - sh 'ansible-playbook -i environments/jenkins openwhisk.yml -e mode=clean' - sh 'ansible-playbook -i environments/jenkins apigateway.yml -e mode=clean' - sh 'ansible-playbook -i environments/jenkins couchdb.yml -e mode=clean' - } - } - } catch (exp) { - println("Exception:" + exp) - } finally { - step([$class: 'JUnitResultArchiver', testResults: '**/test-results/**/TEST-*.xml']) } } } diff --git a/tests/build.gradle b/tests/build.gradle index d578c71..2b93585 100644 --- a/tests/build.gradle +++ b/tests/build.gradle @@ -49,6 +49,7 @@ tasks.withType(Test) { def leanExcludes = [ '**/MaxActionDurationTests*', + 'invokerShoot/**' ] def systemIncludes = [ @@ -78,12 +79,14 @@ ext.testSets = [ "**/*CacheConcurrencyTests*", "**/*ControllerApiTests*", "org/apache/openwhisk/testEntities/**", + 'invokerShoot/**' ] ], "REQUIRE_SYSTEM" : [ "includes" : systemIncludes, "excludes": [ - "system/basic/WskMultiRuntimeTests*" + "system/basic/WskMultiRuntimeTests*", + 'invokerShoot/**' ] ], "REQUIRE_MULTI_RUNTIME" : [ @@ -107,7 +110,8 @@ ext.testSets = [ // Tests suits below require Kafka so they are excluded for Lean System tests "excludes" : [ "**/*KafkaConnectorTests*", - "system/basic/WskMultiRuntimeTests*" + "system/basic/WskMultiRuntimeTests*", + "invokerShoot/**" ] ] ] @@ -135,6 +139,14 @@ def logTestSetInfo(){ println "Using testSet $testSetName - ${prettyPrint(toJson(testSets[testSetName]))}" } +test { + exclude 'invokerShoot/**' +} + +task testShootInvoker(type: Test) { + include 'invokerShoot/**' +} + task testLean(type: Test) { doFirst { logTestSetInfo() @@ -161,6 +173,7 @@ task testUnit(type: Test) { "**/*RemoveLogsTests*", "**/*ReplicatorTest*", "**/*CouchDBArtifactStoreTests*", + "invokerShoot/**" ] exclude couchDbExcludes diff --git a/tests/src/test/scala/invokerShoot/ShootInvokerTests.scala b/tests/src/test/scala/invokerShoot/ShootInvokerTests.scala new file mode 100644 index 0000000..e40bccc --- /dev/null +++ b/tests/src/test/scala/invokerShoot/ShootInvokerTests.scala @@ -0,0 +1,340 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package invokerShoot + +import java.io.File +import java.nio.charset.StandardCharsets + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import common._ +import common.rest.WskRestOperations +import org.apache.openwhisk.core.entity.WhiskAction +import org.apache.commons.io.FileUtils +import spray.json._ +import spray.json.DefaultJsonProtocol._ + +@RunWith(classOf[JUnitRunner]) +class ShootInvokerTests extends TestHelpers with WskTestHelpers with JsHelpers with WskActorSystem { + + implicit val wskprops = WskProps() + // wsk must have type WskOperations so that tests using CLI (class Wsk) + // instead of REST (WskRestOperations) still work. + val wsk: WskOperations = new WskRestOperations + + val testString = "this is a test" + val testResult = JsObject("count" -> testString.split(" ").length.toJson) + val guestNamespace = wskprops.namespace + + behavior of "Whisk actions" + + it should "create an action with an empty file" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "empty" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("empty.js"))) + } + } + + it should "invoke an action returning a promise" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "hello promise" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("helloPromise.js"))) + } + + val run = wsk.action.invoke(name) + withActivation(wsk.activation, run) { activation => + activation.response.status shouldBe "success" + activation.response.result shouldBe Some(JsObject("done" -> true.toJson)) + activation.logs.get.mkString(" ") shouldBe empty + } + } + + it should "invoke an action with a space in the name" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "hello Async" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("helloAsync.js"))) + } + + val run = wsk.action.invoke(name, Map("payload" -> testString.toJson)) + withActivation(wsk.activation, run) { activation => + activation.response.status shouldBe "success" + activation.response.result shouldBe Some(testResult) + activation.logs.get.mkString(" ") should include(testString) + } + } + + it should "invoke an action that throws an uncaught exception and returns correct status code" in withAssetCleaner( + wskprops) { (wp, assetHelper) => + val name = "throwExceptionAction" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("runexception.js"))) + } + + withActivation(wsk.activation, wsk.action.invoke(name)) { activation => + val response = activation.response + activation.response.status shouldBe "action developer error" + activation.response.result shouldBe Some( + JsObject("error" -> "An error has occurred: Extraordinary exception".toJson)) + } + } + + it should "pass parameters bound on creation-time to the action" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "printParams" + val params = Map("param1" -> "test1", "param2" -> "test2") + + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create( + name, + Some(TestUtils.getTestActionFilename("printParams.js")), + parameters = params.mapValues(_.toJson)) + } + + val invokeParams = Map("payload" -> testString) + val run = wsk.action.invoke(name, invokeParams.mapValues(_.toJson)) + withActivation(wsk.activation, run) { activation => + val logs = activation.logs.get.mkString(" ") + + (params ++ invokeParams).foreach { + case (key, value) => + logs should include(s"params.$key: $value") + } + } + } + + it should "copy an action and invoke it successfully" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "copied" + val packageName = "samples" + val actionName = "wordcount" + val fullQualifiedName = s"/$guestNamespace/$packageName/$actionName" + + assetHelper.withCleaner(wsk.pkg, packageName) { (pkg, _) => + pkg.create(packageName, shared = Some(true)) + } + + assetHelper.withCleaner(wsk.action, fullQualifiedName) { + val file = Some(TestUtils.getTestActionFilename("wc.js")) + (action, _) => + action.create(fullQualifiedName, file) + } + + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(fullQualifiedName), Some("copy")) + } + + val run = wsk.action.invoke(name, Map("payload" -> testString.toJson)) + withActivation(wsk.activation, run) { activation => + activation.response.status shouldBe "success" + activation.response.result shouldBe Some(testResult) + activation.logs.get.mkString(" ") should include(testString) + } + } + + it should "copy an action and ensure exec, parameters, and annotations copied" in withAssetCleaner(wskprops) { + (wp, assetHelper) => + val origActionName = "origAction" + val copiedActionName = "copiedAction" + val params = Map("a" -> "A".toJson) + val annots = Map("b" -> "B".toJson) + + assetHelper.withCleaner(wsk.action, origActionName) { + val file = Some(TestUtils.getTestActionFilename("wc.js")) + (action, _) => + action.create(origActionName, file, parameters = params, annotations = annots) + } + + assetHelper.withCleaner(wsk.action, copiedActionName) { (action, _) => + action.create(copiedActionName, Some(origActionName), Some("copy")) + } + + val copiedAction = wsk.parseJsonString(wsk.action.get(copiedActionName).stdout) + val origAction = wsk.parseJsonString(wsk.action.get(copiedActionName).stdout) + + copiedAction.fields("annotations") shouldBe origAction.fields("annotations") + copiedAction.fields("parameters") shouldBe origAction.fields("parameters") + copiedAction.fields("exec") shouldBe origAction.fields("exec") + copiedAction.fields("version") shouldBe JsString("0.0.1") + } + + it should "add new parameters and annotations while copying an action" in withAssetCleaner(wskprops) { + (wp, assetHelper) => + val origName = "origAction" + val copiedName = "copiedAction" + val origParams = Map("origParam1" -> "origParamValue1".toJson, "origParam2" -> 999.toJson) + val copiedParams = Map("copiedParam1" -> "copiedParamValue1".toJson, "copiedParam2" -> 123.toJson) + val origAnnots = Map("origAnnot1" -> "origAnnotValue1".toJson, "origAnnot2" -> true.toJson) + val copiedAnnots = Map("copiedAnnot1" -> "copiedAnnotValue1".toJson, "copiedAnnot2" -> false.toJson) + val resParams = Seq( + JsObject("key" -> JsString("copiedParam1"), "value" -> JsString("copiedParamValue1")), + JsObject("key" -> JsString("copiedParam2"), "value" -> JsNumber(123)), + JsObject("key" -> JsString("origParam1"), "value" -> JsString("origParamValue1")), + JsObject("key" -> JsString("origParam2"), "value" -> JsNumber(999))) + val resAnnots = Seq( + JsObject("key" -> JsString("origAnnot1"), "value" -> JsString("origAnnotValue1")), + JsObject("key" -> JsString("copiedAnnot2"), "value" -> JsBoolean(false)), + JsObject("key" -> JsString("copiedAnnot1"), "value" -> JsString("copiedAnnotValue1")), + JsObject("key" -> JsString("origAnnot2"), "value" -> JsBoolean(true)), + JsObject("key" -> JsString("exec"), "value" -> JsString("nodejs:6")), + JsObject("key" -> WhiskAction.provideApiKeyAnnotationName.toJson, "value" -> JsBoolean(false))) + + assetHelper.withCleaner(wsk.action, origName) { + val file = Some(TestUtils.getTestActionFilename("echo.js")) + (action, _) => + action.create(origName, file, parameters = origParams, annotations = origAnnots) + } + + assetHelper.withCleaner(wsk.action, copiedName) { (action, _) => + println("created copied ") + action.create(copiedName, Some(origName), Some("copy"), parameters = copiedParams, annotations = copiedAnnots) + } + + val copiedAction = wsk.parseJsonString(wsk.action.get(copiedName).stdout) + + // CLI does not guarantee order of annotations and parameters so do a diff to compare the values + copiedAction.fields("parameters").convertTo[Seq[JsObject]] diff resParams shouldBe List.empty + copiedAction.fields("annotations").convertTo[Seq[JsObject]] diff resAnnots shouldBe List.empty + } + + it should "recreate and invoke a new action with different code" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "recreatedAction" + assetHelper.withCleaner(wsk.action, name, false) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("wc.js"))) + } + + val run1 = wsk.action.invoke(name, Map("payload" -> testString.toJson)) + withActivation(wsk.activation, run1) { activation => + activation.response.status shouldBe "success" + activation.logs.get.mkString(" ") should include(s"The message '$testString' has") + } + + wsk.action.delete(name) + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("hello.js"))) + } + + val run2 = wsk.action.invoke(name, Map("payload" -> testString.toJson)) + withActivation(wsk.activation, run2) { activation => + activation.response.status shouldBe "success" + activation.logs.get.mkString(" ") should include(s"hello, $testString") + } + } + + it should "fail to invoke an action with an empty file" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "empty" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("empty.js"))) + } + val run = wsk.action.invoke(name) + withActivation(wsk.activation, run) { activation => + activation.response.status shouldBe "action developer error" + activation.response.result shouldBe Some(JsObject("error" -> "Missing main/no code to execute.".toJson)) + } + } + + it should "blocking invoke of nested blocking actions" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "nestedBlockingAction" + val child = "wc" + + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create( + name, + Some(TestUtils.getTestActionFilename("wcbin.js")), + annotations = Map(WhiskAction.provideApiKeyAnnotationName -> JsBoolean(true))) + } + assetHelper.withCleaner(wsk.action, child) { (action, _) => + action.create(child, Some(TestUtils.getTestActionFilename("wc.js"))) + } + + val run = wsk.action.invoke(name, Map("payload" -> testString.toJson), blocking = true) + val activation = wsk.parseJsonString(run.stdout).convertTo[ActivationResult] + + withClue(s"check failed for activation: $activation") { + val wordCount = testString.split(" ").length + activation.response.result.get shouldBe JsObject("binaryCount" -> s"${wordCount.toBinaryString} (base 2)".toJson) + } + } + + it should "blocking invoke an asynchronous action" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "helloAsync" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("helloAsync.js"))) + } + + val run = wsk.action.invoke(name, Map("payload" -> testString.toJson), blocking = true) + val activation = wsk.parseJsonString(run.stdout).convertTo[ActivationResult] + + withClue(s"check failed for activation: $activation") { + activation.response.status shouldBe "success" + activation.response.result shouldBe Some(testResult) + activation.logs shouldBe Some(List.empty) + } + } + + it should "not be able to use 'ping' in an action" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "ping" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("ping.js"))) + } + + val run = wsk.action.invoke(name, Map("payload" -> "google.com".toJson)) + withActivation(wsk.activation, run) { activation => + val result = activation.response.result.get + result.getFields("stdout", "code") match { + case Seq(JsString(stdout), JsNumber(code)) => + stdout should not include "bytes from" + code.intValue() should not be 0 + case _ => fail(s"fields 'stdout' or 'code' where not of the expected format, was $result") + } + } + } + + it should "support UTF-8 as input and output format" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "utf8Test" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + action.create(name, Some(TestUtils.getTestActionFilename("hello.js"))) + } + + val utf8 = "«ταБЬℓσö»: 1<2 & 4+1>³, now 20%€§$ off!" + val run = wsk.action.invoke(name, Map("payload" -> utf8.toJson)) + withActivation(wsk.activation, run) { activation => + activation.response.status shouldBe "success" + activation.logs.get.mkString(" ") should include(s"hello, $utf8") + } + } + + it should "invoke action with large code" in withAssetCleaner(wskprops) { (wp, assetHelper) => + val name = "big-hello" + assetHelper.withCleaner(wsk.action, name) { (action, _) => + val filePath = TestUtils.getTestActionFilename("hello.js") + val code = FileUtils.readFileToString(new File(filePath), StandardCharsets.UTF_8) + val largeCode = code + " " * (WhiskProperties.getMaxActionSizeMB * FileUtils.ONE_MB).toInt + val tmpFile = File.createTempFile("whisk", ".js") + FileUtils.write(tmpFile, largeCode, StandardCharsets.UTF_8) + val result = action.create(name, Some(tmpFile.getAbsolutePath)) + tmpFile.delete() + result + } + + val hello = "hello" + val run = wsk.action.invoke(name, Map("payload" -> hello.toJson)) + withActivation(wsk.activation, run) { activation => + activation.response.status shouldBe "success" + activation.logs.get.mkString(" ") should include(s"hello, $hello") + } + } + +}