-
Notifications
You must be signed in to change notification settings - Fork 26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: smoke tests trait #1141
feat: smoke tests trait #1141
Changes from 9 commits
0ffb5a5
aef03ac
3f74489
fc97ab9
f152351
f051f67
4e58bbd
39f66de
faa8652
524c8ca
bcafd53
5a91c98
915e845
1055f1b
bd0072c
c93d8a6
4a10ec7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"id": "756754c3-f6e1-4ff2-ae31-08b3b67b6750", | ||
"type": "feature", | ||
"description": "Add support for [smoke tests](https://smithy.io/2.0/additional-specs/smoke-tests.html)" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -198,6 +198,8 @@ class KotlinWriter( | |
) | ||
} | ||
|
||
fun emptyLine(): KotlinWriter = this.write("") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: This method doesn't create an empty line necessarily—it merely guarantees a new line. For instance, the following code would result in no empty lines: writer.writeInline("foo")
writer.emptyLine() Applies in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Also There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll get rid of |
||
|
||
/** | ||
* Clean/escape any content from the doc that would invalidate the Kotlin output. | ||
*/ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package software.amazon.smithy.kotlin.codegen.model.traits | ||
|
||
import software.amazon.smithy.model.node.ObjectNode | ||
import software.amazon.smithy.model.shapes.ShapeId | ||
import software.amazon.smithy.model.traits.AnnotationTrait | ||
|
||
/** | ||
* Indicates the annotated service should always return a failed response. | ||
*/ | ||
class FailedResponseTrait(node: ObjectNode) : AnnotationTrait(ID, node) { | ||
companion object { | ||
val ID: ShapeId = ShapeId.from("smithy.kotlin.traits#failedResponseTrait") | ||
} | ||
} | ||
|
||
/** | ||
* Indicates the annotated service should always return a success response. | ||
*/ | ||
class SuccessResponseTrait(node: ObjectNode) : AnnotationTrait(ID, node) { | ||
companion object { | ||
val ID: ShapeId = ShapeId.from("smithy.kotlin.traits#successResponseTrait") | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: What attaches these traits to shapes? I can see where we check for them but not where we create/apply them... More broadly, why do we even need these traits? I was under the impression that individual smoke test cases identified whether they should fail/succeed based on an Expectation union. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The traits are applied here to either of those models for testing purposes |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -250,4 +250,6 @@ class GradleWriter(parent: GradleWriter? = null) : AbstractCodeWriter<GradleWrit | |
expressionStart = parent?.expressionStart ?: '#' | ||
putFormatter('W', InlineCodeWriterFormatter(::GradleWriter)) | ||
} | ||
|
||
fun emptyLine(): GradleWriter = this.write("") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: If we need this functionality in multiple code writers and its implementation is identical then it seems like we could instead write a single extension method for |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package software.amazon.smithy.kotlin.codegen.rendering.smoketests | ||
|
||
import software.amazon.smithy.kotlin.codegen.KotlinSettings | ||
import software.amazon.smithy.kotlin.codegen.core.CodegenContext | ||
import software.amazon.smithy.kotlin.codegen.core.KotlinDelegator | ||
import software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration | ||
import software.amazon.smithy.kotlin.codegen.model.hasTrait | ||
import software.amazon.smithy.kotlin.codegen.utils.operations | ||
import software.amazon.smithy.model.Model | ||
import software.amazon.smithy.smoketests.traits.SmokeTestsTrait | ||
|
||
/** | ||
* Renders smoke test runner for a service if any of the operations have the [SmokeTestsTrait]. | ||
*/ | ||
class SmokeTestsIntegration : KotlinIntegration { | ||
override fun enabledForService(model: Model, settings: KotlinSettings): Boolean = | ||
model.operations(settings.service).any { it.hasTrait<SmokeTestsTrait>() } | ||
|
||
override fun writeAdditionalFiles(ctx: CodegenContext, delegator: KotlinDelegator) = | ||
delegator.useFileWriter( | ||
"SmokeTests.kt", | ||
"${ctx.settings.pkg.name}.smoketests", | ||
"./jvm-src/test/java/", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, I was trying to follow the convention we have currently without affecting the SDK. I believe we currently code-generate services into a I don't know if we have anywhere else that does codegen like this that is intended as JVM only. If we follow source-set convention I think There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is generating JVM-only code necessary? Can't we generate common code to run the tests? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Main reason is that there's no common code for I think I can wrap this in our own KMP function, thoughts? |
||
) { writer -> | ||
SmokeTestsRunnerGenerator( | ||
writer, | ||
ctx, | ||
).render() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,216 @@ | ||
package software.amazon.smithy.kotlin.codegen.rendering.smoketests | ||
|
||
import software.amazon.smithy.codegen.core.Symbol | ||
import software.amazon.smithy.kotlin.codegen.core.* | ||
import software.amazon.smithy.kotlin.codegen.integration.SectionId | ||
import software.amazon.smithy.kotlin.codegen.model.expectShape | ||
import software.amazon.smithy.kotlin.codegen.model.getTrait | ||
import software.amazon.smithy.kotlin.codegen.model.hasTrait | ||
import software.amazon.smithy.kotlin.codegen.model.traits.FailedResponseTrait | ||
import software.amazon.smithy.kotlin.codegen.model.traits.SuccessResponseTrait | ||
import software.amazon.smithy.kotlin.codegen.rendering.util.format | ||
import software.amazon.smithy.kotlin.codegen.utils.dq | ||
import software.amazon.smithy.kotlin.codegen.utils.operations | ||
import software.amazon.smithy.kotlin.codegen.utils.toCamelCase | ||
import software.amazon.smithy.model.shapes.OperationShape | ||
import software.amazon.smithy.model.shapes.ServiceShape | ||
import software.amazon.smithy.smoketests.traits.SmokeTestCase | ||
import software.amazon.smithy.smoketests.traits.SmokeTestsTrait | ||
import kotlin.jvm.optionals.getOrNull | ||
|
||
object SmokeTestsRunner : SectionId | ||
|
||
// Env var constants | ||
const val SKIP_TAGS = "AWS_SMOKE_TEST_SKIP_TAGS" | ||
const val SERVICE_FILTER = "AWS_SMOKE_TEST_SERVICE_IDS" | ||
|
||
/** | ||
* Renders smoke tests runner for a service | ||
*/ | ||
class SmokeTestsRunnerGenerator( | ||
private val writer: KotlinWriter, | ||
ctx: CodegenContext, | ||
) { | ||
// Generator config | ||
private val model = ctx.model | ||
private val sdkId = ctx.settings.sdkId | ||
private val symbolProvider = ctx.symbolProvider | ||
private val service = symbolProvider.toSymbol(model.expectShape(ctx.settings.service)) | ||
private val operations = ctx.model.operations(ctx.settings.service).filter { it.hasTrait<SmokeTestsTrait>() } | ||
|
||
// Test config | ||
private val hasSuccessResponseTrait = model.expectShape<ServiceShape>(ctx.settings.service).hasTrait(SuccessResponseTrait.ID) | ||
private val hasFailedResponseTrait = model.expectShape<ServiceShape>(ctx.settings.service).hasTrait(FailedResponseTrait.ID) | ||
init { | ||
check(!(hasSuccessResponseTrait && hasFailedResponseTrait)) { | ||
"A service can't have both the success response trait and the failed response trait." | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we also need to check that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No because the trait is only used for the E2E tests. During code generation for actual real life services neither of the traits is necessary There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am concerned about the tight coupling between this smoke tests generator and the tests for it. The generator here should not know / have to care about test-specific traits. If we're just using these traits to configure the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I don't like it either. I can't think of any other reasonable way to replace the
We would have to add some custom logic for that to work, so the coupling would still be there. |
||
|
||
internal fun render() { | ||
writer.declareSection(SmokeTestsRunner) { | ||
writer.write("import kotlin.system.exitProcess") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Generally do not codegen imports directly. Favor |
||
writer.emptyLine() | ||
writer.write("private var exitCode = 0") | ||
writer.write("private val regionOverride = System.getenv(#S)", "AWS_SMOKE_TEST_REGION") | ||
writer.write("private val skipTags = System.getenv(#S)?.let { it.split(\",\").map { it.trim() }.toSet() } ?: emptySet()", SKIP_TAGS) | ||
writer.write("private val serviceFilter = System.getenv(#S)?.let { it.split(\",\").map { it.trim() }.toSet() }", SERVICE_FILTER) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Replace There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Why not default There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's important to know if the user didn't set this because this is an include filter. It the default is an empty set then no smoke tests will run. We could change the default to an empty set and then check if it's that instead of null |
||
writer.emptyLine() | ||
writer.withBlock("public suspend fun main() {", "}") { | ||
renderFunctionCalls() | ||
write("exitProcess(exitCode)") | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: What's the reason to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is part of the design document, we're supposed to return a non-zero exit code when any of the tests don't pass. Just returning the code doesn't set the exit code AFAIK. |
||
writer.emptyLine() | ||
renderFunctions() | ||
} | ||
} | ||
|
||
private fun renderFunctionCalls() { | ||
operations.forEach { operation -> | ||
operation.getTrait<SmokeTestsTrait>()?.testCases?.forEach { testCase -> | ||
writer.write("${testCase.functionName}()") | ||
} | ||
} | ||
} | ||
|
||
private fun renderFunctions() { | ||
operations.forEach { operation -> | ||
operation.getTrait<SmokeTestsTrait>()?.testCases?.forEach { testCase -> | ||
renderFunction(operation, testCase) | ||
writer.emptyLine() | ||
} | ||
} | ||
} | ||
|
||
private fun renderFunction(operation: OperationShape, testCase: SmokeTestCase) { | ||
writer.withBlock("private suspend fun ${testCase.functionName}() {", "}") { | ||
write("val tags = setOf<String>(${testCase.tags.joinToString(",") { it.dq()} })") | ||
writer.withBlock("if ((serviceFilter != null && #S !in serviceFilter) || tags.any { it in skipTags }) {", "}", sdkId) { | ||
printTestResult( | ||
sdkId.filter { !it.isWhitespace() }, | ||
testCase.id, | ||
testCase.expectation.isFailure, | ||
writer, | ||
"ok", | ||
"# skip", | ||
) | ||
writer.write("return") | ||
} | ||
emptyLine() | ||
withInlineBlock("try {", "} ") { | ||
renderClient(testCase) | ||
renderOperation(operation, testCase) | ||
} | ||
withBlock("catch (e: Exception) {", "}") { | ||
renderCatchBlock(testCase) | ||
} | ||
} | ||
} | ||
|
||
private fun renderClient(testCase: SmokeTestCase) { | ||
writer.withInlineBlock("#L {", "}", service) { | ||
// Client config | ||
if (testCase.vendorParams.isPresent) { | ||
testCase.vendorParams.get().members.forEach { vendorParam -> | ||
if (vendorParam.key.value == "region") { | ||
write("region = regionOverride ?: #L", vendorParam.value.format()) | ||
} else { | ||
write("#L = #L", vendorParam.key.value.toCamelCase(), vendorParam.value.format()) | ||
} | ||
} | ||
} else { | ||
write("region = regionOverride") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens when the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In that case |
||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: |
||
val expectingSpecificError = testCase.expectation.failure.getOrNull()?.errorId?.getOrNull() != null | ||
if (!expectingSpecificError) { | ||
write("interceptors.add(#T())", RuntimeTypes.HttpClient.Interceptors.SmokeTestsInterceptor) | ||
} | ||
|
||
// Test config | ||
if (hasSuccessResponseTrait) { | ||
write("httpClient = #T()", RuntimeTypes.HttpTest.TestEngine) | ||
} | ||
if (hasFailedResponseTrait) { | ||
withBlock("httpClient = #T(", ")", RuntimeTypes.HttpTest.TestEngine) { | ||
withBlock("roundTripImpl = { _, request ->", "}") { | ||
write( | ||
"val resp = #T(#T.BadRequest, #T.Empty, #T.Empty)", | ||
RuntimeTypes.Http.Response.HttpResponse, | ||
RuntimeTypes.Http.StatusCode, | ||
RuntimeTypes.Http.Headers, | ||
RuntimeTypes.Http.HttpBody, | ||
) | ||
write("val now = #T.now()", RuntimeTypes.Core.Instant) | ||
write("#T(request, resp, now, now)", RuntimeTypes.Http.HttpCall) | ||
} | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correctness: Why are we using a test client here? If we don't call the actual service with a real client, what are we testing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This logic is supposed to be used during testing only. The test client is only used if the service has those custom traits that were created ( |
||
} | ||
} | ||
|
||
private fun renderOperation(operation: OperationShape, testCase: SmokeTestCase) { | ||
val operationSymbol = symbolProvider.toSymbol(model.getShape(operation.input.get()).get()) | ||
|
||
writer.withBlock(".use { client ->", "}") { | ||
withBlock("client.#L(", ")", operation.defaultName()) { | ||
withBlock("#L {", "}", operationSymbol) { | ||
testCase.params.get().members.forEach { member -> | ||
write("#L = #L", member.key.value.toCamelCase(), member.value.format()) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
private fun renderCatchBlock(testCase: SmokeTestCase) { | ||
val successCriterion = RuntimeTypes.HttpClient.Interceptors.SmokeTestsSuccessException | ||
val failureCriterion = getFailureCriterion(testCase) | ||
val expected = if (testCase.expectation.isFailure) { | ||
failureCriterion | ||
} else { | ||
successCriterion | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: val expected = if (testCase.expectation.isFailure) {
getFailureCriterion(testCase)
} else {
RuntimeTypes.HttpClient.Interceptors.SmokeTestsSuccessException
} |
||
|
||
writer.write("val success = e is #L", expected) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Use |
||
writer.write("val status = if (success) \"ok\" else \"not ok\"") | ||
printTestResult( | ||
sdkId.filter { !it.isWhitespace() }, | ||
testCase.id, | ||
testCase.expectation.isFailure, | ||
writer, | ||
) | ||
writer.write("if (!success) exitCode = 1") | ||
} | ||
|
||
/** | ||
* Tries to get the specific exception required in the failure criterion of a test. | ||
* If no specific exception is required we default to the generic smoke tests failure exception. | ||
*/ | ||
private fun getFailureCriterion(testCase: SmokeTestCase): Symbol = | ||
testCase.expectation.failure.getOrNull()?.errorId?.getOrNull()?.let { | ||
symbolProvider.toSymbol(model.getShape(it).get()) | ||
} ?: RuntimeTypes.HttpClient.Interceptors.SmokeTestsFailureException | ||
|
||
/** | ||
* Renders print statement for smoke test result in accordance to design doc & test anything protocol (TAP) | ||
*/ | ||
private fun printTestResult( | ||
service: String, | ||
testCase: String, | ||
errorExpected: Boolean, | ||
writer: KotlinWriter, | ||
statusOverride: String? = null, | ||
directive: String? = "", | ||
) { | ||
val expectation = if (errorExpected) "error expected from service" else "no error expected from service" | ||
val status = statusOverride ?: "\$status" | ||
val testResult = "$status $service $testCase - $expectation $directive" | ||
writer.write("println(#S)", testResult) | ||
} | ||
} | ||
|
||
/** | ||
* Derives a function name for a [SmokeTestCase] | ||
*/ | ||
private val SmokeTestCase.functionName: String | ||
get() = this.id.toCamelCase() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
package software.amazon.smithy.kotlin.codegen.rendering.util | ||
|
||
import software.amazon.smithy.kotlin.codegen.utils.dq | ||
import software.amazon.smithy.model.node.ArrayNode | ||
import software.amazon.smithy.model.node.BooleanNode | ||
import software.amazon.smithy.model.node.Node | ||
import software.amazon.smithy.model.node.NullNode | ||
import software.amazon.smithy.model.node.NumberNode | ||
import software.amazon.smithy.model.node.ObjectNode | ||
import software.amazon.smithy.model.node.StringNode | ||
|
||
/** | ||
* Formats a [Node] into a String for codegen. | ||
*/ | ||
fun Node.format(): String = when (this) { | ||
is NullNode -> "null" | ||
is StringNode -> value.dq() | ||
is BooleanNode -> value.toString() | ||
is NumberNode -> value.toString() | ||
is ArrayNode -> elements.joinToString(",", "listOf(", ")") { element -> | ||
element.format() | ||
} | ||
is ObjectNode -> buildString { | ||
append("mapOf(") | ||
stringMap.forEach { (key, value) -> | ||
append("\t${key.dq()} to ${value.format()}") | ||
} | ||
append(")") | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correctness: This doesn't seem to add commas between subsequent key-value pairs. I'm not sure why the tabs ( is ObjectNode -> stringMap.entries().joinToString(", ", "mapOf(", ")") { (key, value) ->
"${key.dq()} to ${value.format()}"
} |
||
else -> throw Exception("Unexpected node type: $this") | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the reason for adding
./gradlew build
here? Don't thetest
tasks depend onbuild
already?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think so, the
build
task depends on thestageServices
task but it doesn't seem to be running when thebuild
task is being called by thetest
task.I added a dependency on
stageServices
to thetest
tasks and that should allow us to get rid of this ^