Skip to content
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

Merged
merged 17 commits into from
Sep 26, 2024
Merged

feat: smoke tests trait #1141

merged 17 commits into from
Sep 26, 2024

Conversation

0marperez
Copy link
Contributor

@0marperez 0marperez commented Aug 23, 2024

Issue #

Description of changes

Adds support for smoke tests trait using an integration.

Companion PR: awslabs/aws-sdk-kotlin#1388

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

This comment has been minimized.

This comment has been minimized.

@0marperez 0marperez marked this pull request as ready for review August 23, 2024 15:57
@0marperez 0marperez requested a review from a team as a code owner August 23, 2024 15:57
{
"id": "756754c3-f6e1-4ff2-ae31-08b3b67b6750",
"type": "feature",
"description": "Add support for smoke tests"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: refer to Smithy smoke tests trait / link to Smithy docs: https://smithy.io/2.0/additional-specs/smoke-tests.html

import software.amazon.smithy.smoketests.traits.SmokeTestsTrait

/**
* Renders smoke test runner for a service if any of the operations has the smoke test trait.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "if any of the operations have the [SmokeTestsTrait]"

* Syntactic sugar for getting a services operations
*/
@SmithyInternalApi
fun Model.operations(service: ShapeId): Set<OperationShape> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Make this an extension property (val) instead of fun

*/
class SmokeTestsIntegration : KotlinIntegration {
override fun enabledForService(model: Model, settings: KotlinSettings): Boolean =
model.operations(settings.service).any { it.hasTrait<SmokeTestsTrait>() } && !smokeTestDenyList.contains(settings.sdkId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simplification for second half of the &&: settings.sdkId !in smokeTestDenyList

Comment on lines 23 to 27
ctx.symbolProvider.toSymbol(ctx.model.expectShape(ctx.settings.service)),
ctx.model.operations(ctx.settings.service).filter { it.hasTrait<SmokeTestsTrait>() },
ctx.model,
ctx.symbolProvider,
ctx.settings.sdkId,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see ctx. repeated a lot, have you considered just passing the entire CodegenContext instead of unwrapping it here?

private fun renderOperation(operation: OperationShape, testCase: SmokeTestCase) {
val operationSymbol = symbolProvider.toSymbol(model.getShape(operation.input.get()).get())

writer.addImport(operationSymbol)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid manually adding imports if possible

Comment on lines 150 to 151
* Some smoke tests model exceptions not found in the model, in that case we default to the generic smoke tests
* failure exception.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these should be considered invalid models and added to the deny list until fixed

/**
* Renders a [Node] into String format for codegen.
*/
fun Node.render(): String = when (this) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming: render usually means you are making a write(...) call, which does not happen here

class SmokeTestsRunnerGeneratorTest {
private val moneySign = "$"

private fun codegen(model: Model): String {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming: generateSmokeTests

fun codegenTest() {
val model =
"""
${moneySign}version: "2"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other places we do this use ${'$'} which seems more readable

model.operations(settings.service).any { it.hasTrait<SmokeTestsTrait>() } && !smokeTestDenyList.contains(settings.sdkId)

override fun writeAdditionalFiles(ctx: CodegenContext, delegator: KotlinDelegator) =
delegator.useFileWriter("SmokeTests.kt", "smoketests", "./jvm-src/main/java/") { writer ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correctness: package ideally should be aws.sdk.kotlin.services.$SERVICE.smoketests, but we should not reference aws-sdk-kotlin here. Can this be made configurable through the integration somehow?

This comment has been minimized.

This comment has been minimized.

*/
class FailedResponseTrait(node: ObjectNode) : AnnotationTrait(ID, node) {
companion object {
val ID: ShapeId = ShapeId.from("com.test#failedResponseTrait")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming: namespace under smithy.kotlin.traits like the other traits

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think FailedResponseTrait and SuccessResponseTrait can better go together into a single file SmokeTestTraits.kt

delegator.useFileWriter(
"SmokeTests.kt",
"${ctx.settings.pkg.name}.smoketests",
"./jvm-src/test/java/",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is jvm-src a standard sourceset location?

Copy link
Contributor Author

@0marperez 0marperez Sep 17, 2024

Choose a reason for hiding this comment

The 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 src dir and then the SDK moves that code into the <sdk root dir>/services/<relevant service>/generated-src dir in the SDK. Here's where we move the generated code.

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 src should be commonMain and jvm-src should be jvmMain but it won't affect functionality.

Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main reason is that there's no common code for exitProcess
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.system/exit-process.html

I think I can wrap this in our own KMP function, thoughts?

Comment on lines 39 to 43
private val operations = ctx.model.operations(ctx.settings.service).filter { it.hasTrait<SmokeTestsTrait>() }

// Test config
private val hasSuccessResponseTrait = ctx.model.expectShape<ServiceShape>(ctx.settings.service).hasTrait(SuccessResponseTrait.ID)
private val hasFailedResponseTrait = ctx.model.expectShape<ServiceShape>(ctx.settings.service).hasTrait(FailedResponseTrait.ID)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally I prefer to just grab everything from the ctx instead of storing in local values, but since you've made private val model = ctx.model, you should not use ctx.model anywhere

ctx.model -> model

Comment on lines 44 to 48
init {
check(!(hasSuccessResponseTrait && hasFailedResponseTrait)) {
"A service can't have both the success response trait and the failed response trait."
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also need to check that hasSuccessResponseTrait || hasFailedResponseTrait (a service has exactly one of the traits)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The 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 httpClient, can it be configured through the smoke test vendor params instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 httpClient without high coupling.

If we're just using these traits to configure the httpClient, can it be configured through the smoke test vendor params instead?

We would have to add some custom logic for that to work, so the coupling would still be there.

This comment has been minimized.

This comment has been minimized.

@@ -131,5 +131,6 @@ jobs:
sed -i "s/smithy-kotlin-runtime-version = .*$/smithy-kotlin-runtime-version = \"$SMITHY_KOTLIN_RUNTIME_VERSION\"/" gradle/libs.versions.toml
sed -i "s/smithy-kotlin-codegen-version = .*$/smithy-kotlin-codegen-version = \"$SMITHY_KOTLIN_CODEGEN_VERSION\"/" gradle/libs.versions.toml
./gradlew --parallel publishToMavenLocal
./gradlew build
Copy link
Contributor

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 the test tasks depend on build already?

Copy link
Contributor Author

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 the stageServices task but it doesn't seem to be running when the build task is being called by the test task.

I added a dependency on stageServices to the test tasks and that should allow us to get rid of this ^

This comment has been minimized.

@@ -198,6 +198,8 @@ class KotlinWriter(
)
}

fun emptyLine(): KotlinWriter = this.write("")
Copy link
Contributor

Choose a reason for hiding this comment

The 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 GradleGenerator too.

Comment on lines 7 to 23
/**
* 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")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

@0marperez 0marperez Sep 23, 2024

Choose a reason for hiding this comment

The 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

Comment on lines 253 to 254

fun emptyLine(): GradleWriter = this.write("")
Copy link
Contributor

Choose a reason for hiding this comment

The 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 AbstractCodeWriter.

delegator.useFileWriter(
"SmokeTests.kt",
"${ctx.settings.pkg.name}.smoketests",
"./jvm-src/test/java/",
Copy link
Contributor

Choose a reason for hiding this comment

The 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?


internal fun render() {
writer.declareSection(SmokeTestsRunner) {
writer.write("import kotlin.system.exitProcess")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Generally do not codegen imports directly. Favor #T placeholders and type references, which automatically add imports as appropriate.

Comment on lines 23 to 29
is ObjectNode -> buildString {
append("mapOf(")
stringMap.forEach { (key, value) ->
append("\t${key.dq()} to ${value.format()}")
}
append(")")
}
Copy link
Contributor

Choose a reason for hiding this comment

The 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 (\t) are there either but I believe this could be simplified down to a joinToString call similar to the one above:

is ObjectNode -> stringMap.entries().joinToString(", ", "mapOf(", ")") { (key, value) ->
    "${key.dq()} to ${value.format()}"
}

Comment on lines 9 to 13
/**
* Syntactic sugar for getting a services operations
*/
@SmithyInternalApi
fun Model.operations(service: ShapeId): Set<OperationShape> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why are we marking a Kotlin-specific extension method as "Smithy internal"?

Comment on lines 9 to 13
/**
* Syntactic sugar for getting a services operations
*/
@SmithyInternalApi
fun Model.operations(service: ShapeId): Set<OperationShape> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think this is confusingly named. Model already has a member getOperationShapes which returns all operations in the model, not just the ones found by TopDownIndex. If we decide to keep this we should name it something like topDownOperations.

successCriterion
}

writer.write("val success = e is #L", expected)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Use #T for types

Comment on lines +8 to +24
/**
* Interceptor for smoke test runner clients.
*
* A passing test is not predicated on an SDK being able to parse the server response received, it’s based on the
* response’s HTTP status code.
*/
@InternalApi
public class SmokeTestsInterceptor : HttpInterceptor {
override fun readBeforeDeserialization(context: ProtocolResponseInterceptorContext<Any, HttpRequest, HttpResponse>) {
val status = context.protocolResponse.status.value
when (status) {
in 400..599 -> throw SmokeTestsFailureException()
in 200..299 -> throw SmokeTestsSuccessException()
else -> throw SmokeTestsUnexpectedException()
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: How does this interceptor allow parsing response error codes to determine if a particular errorId was found as expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That logic is here in the smoke test runner generator

This comment has been minimized.

This comment has been minimized.

This comment has been minimized.

Comment on lines 3 to 6
import kotlin.system.exitProcess

public actual fun exitProcess(status: Int): Nothing = exitProcess(status)
public actual fun getEnv(name: String): String? = System.getenv(name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: This seems like platform functionality to me—getting environment variables is already something we do cross-platform and process control seems like a natural addition. Is there any reason we can't reuse PlatformProvider for env vars and add support for exiting the process with a return code?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for calling this out, I almost reinvented the wheel here

Copy link

Affected Artifacts

Changed in size
Artifact Pull Request (bytes) Latest Release (bytes) Delta (bytes) Delta (percentage)
http-client-jvm.jar 310,136 306,053 4,083 1.33%
runtime-core-jvm.jar 797,842 796,911 931 0.12%

@0marperez 0marperez merged commit 872b457 into main Sep 26, 2024
15 checks passed
@0marperez 0marperez deleted the smoke-tests-trait branch September 27, 2024 16:01
0marperez added a commit that referenced this pull request Sep 27, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants