From 0b391885e6180e1806e9b8a9e66bbae96f743b88 Mon Sep 17 00:00:00 2001 From: Allain Magyar Date: Mon, 24 Jun 2024 15:37:07 -0300 Subject: [PATCH] feat: enhancements Signed-off-by: Allain Magyar --- .github/workflows/release.yml | 20 ++ build.gradle.kts | 29 +- .../automation/cucumber/common/AnsiEscapes.kt | 47 +++ .../automation/cucumber/common/Format.kt | 36 ++ .../automation/cucumber/common/Formats.kt | 69 ++++ .../cucumber/common/UTF8OutputStreamWriter.kt | 7 + .../cucumber/common/UTF8PrintWriter.kt | 72 ++++ .../cucumber/plugins/SerenityStepListener.kt | 68 ++++ .../plugins/SerenityWithCucumberFormatter.kt | 324 ++++++++++++++++++ .../CustomGsonObjectMapperFactory.kt | 10 +- .../serenity/ensure/LastResponseComparable.kt | 34 +- .../serenity/ensure/LastResponseEnsure.kt | 18 +- .../ensure/LastResponseInteraction.kt | 93 +++-- .../serenity/ensure/SerenityRestJson.kt | 43 ++- .../objectfactory/AtalaObjectFactory.kt | 5 +- .../net.thucydides.model.steps.StepListener | 1 + .../iohk/atala/automation/WithMockServer.kt | 104 ++++-- .../cucumber/plugins/SerenityPretty.kt | 71 ++++ .../atala/automation/extensions/PostTest.kt | 2 +- .../automation/extensions/ResponseTest.kt | 8 +- .../automation/serenity/ensure/EnsureTest.kt | 116 ++++++- .../objectfactory/AtalaObjectFactoryTest.kt | 2 +- .../serenity/questions/HttpRequestTest.kt | 2 +- src/test/resources/feature.feature | 17 + src/test/resources/serenity.properties | 2 + 25 files changed, 1107 insertions(+), 93 deletions(-) create mode 100644 src/main/kotlin/io/iohk/atala/automation/cucumber/common/AnsiEscapes.kt create mode 100644 src/main/kotlin/io/iohk/atala/automation/cucumber/common/Format.kt create mode 100644 src/main/kotlin/io/iohk/atala/automation/cucumber/common/Formats.kt create mode 100644 src/main/kotlin/io/iohk/atala/automation/cucumber/common/UTF8OutputStreamWriter.kt create mode 100644 src/main/kotlin/io/iohk/atala/automation/cucumber/common/UTF8PrintWriter.kt create mode 100644 src/main/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityStepListener.kt create mode 100644 src/main/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityWithCucumberFormatter.kt create mode 100644 src/main/resources/META-INF/services/net.thucydides.model.steps.StepListener create mode 100644 src/test/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityPretty.kt create mode 100644 src/test/resources/feature.feature create mode 100644 src/test/resources/serenity.properties diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a5c48d..2931a89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,3 +50,23 @@ jobs: run: | npm install npx semantic-release + + - name: Dokka + run: ./gradlew dokkaHtml + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./docs + + release-page: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: release-atala-automation + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/build.gradle.kts b/build.gradle.kts index 82b077f..071e157 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { `maven-publish` id("io.gitlab.arturbosch.detekt") version "1.23.0" id("io.github.gradle-nexus.publish-plugin") version "2.0.0-rc-1" + id("org.jetbrains.dokka") version "1.9.20" signing } @@ -16,26 +17,28 @@ repositories { } dependencies { + val serenityVersion = "4.1.20" + api("javax.inject:javax.inject:1") api("junit:junit:4.13.2") - api("net.serenity-bdd:serenity-core:4.0.0") - api("net.serenity-bdd:serenity-ensure:4.0.0") - api("net.serenity-bdd:serenity-cucumber:4.0.0") - api("net.serenity-bdd:serenity-screenplay:4.0.0") - api("net.serenity-bdd:serenity-screenplay-rest:4.0.0") + api("net.serenity-bdd:serenity-core:$serenityVersion") + api("net.serenity-bdd:serenity-ensure:$serenityVersion") + api("net.serenity-bdd:serenity-cucumber:$serenityVersion") + api("net.serenity-bdd:serenity-screenplay:$serenityVersion") + api("net.serenity-bdd:serenity-screenplay-rest:$serenityVersion") - api("ch.qos.logback:logback-classic:1.4.8") + api("ch.qos.logback:logback-classic:1.5.6") api("org.slf4j:slf4j-api:2.0.7") api("io.ktor:ktor-client-core-jvm:2.3.1") - api("com.jayway.jsonpath:json-path:2.8.0") + api("com.jayway.jsonpath:json-path:2.9.0") api("org.awaitility:awaitility:4.2.0") api("org.jetbrains.kotlin:kotlin-reflect:1.8.22") api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") - testImplementation("com.github.tomakehurst:wiremock-jre8-standalone:2.35.0") + testImplementation("org.wiremock:wiremock-standalone:3.7.0") } kotlin { @@ -108,6 +111,12 @@ nexusPublishing { signing { val base64EncodedAsciiArmoredSigningKey: String = System.getenv("BASE64_ARMORED_GPG_SIGNING_KEY_MAVEN") ?: "" val signingKeyPassword: String = System.getenv("SIGNING_KEY_PASSWORD") ?: "" - useInMemoryPgpKeys(String(Base64.getDecoder().decode(base64EncodedAsciiArmoredSigningKey.toByteArray())), signingKeyPassword) - sign(publishing.publications) + if (base64EncodedAsciiArmoredSigningKey.isNotEmpty() && signingKeyPassword.isNotEmpty()) { + useInMemoryPgpKeys(String(Base64.getDecoder().decode(base64EncodedAsciiArmoredSigningKey.toByteArray())), signingKeyPassword) + sign(publishing.publications) + } +} + +tasks.dokkaHtml.configure { + outputDirectory.set(projectDir.resolve("docs")) } diff --git a/src/main/kotlin/io/iohk/atala/automation/cucumber/common/AnsiEscapes.kt b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/AnsiEscapes.kt new file mode 100644 index 0000000..7f4916d --- /dev/null +++ b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/AnsiEscapes.kt @@ -0,0 +1,47 @@ +package io.iohk.atala.automation.cucumber.common + +class AnsiEscapes private constructor(private val value: String) { + override fun toString(): String { + val sb = java.lang.StringBuilder() + appendTo(sb) + return sb.toString() + } + + fun appendTo(a: java.lang.StringBuilder) { + a.append(ESC).append(BRACKET).append( + value + ) + } + + companion object { + val RESET = color(0) + val BLACK = color(30) + val BRIGHT_BLACK = color(90) + + val RED = color(31) + val BRIGHT_RED = color(91) + + val GREEN = color(32) + val BRIGHT_GREEN = color(92) + + val YELLOW = color(33) + val BLUE = color(34) + val MAGENTA = color(35) + val CYAN = color(36) + val WHITE = color(37) + val DEFAULT = color(9) + val GREY = color(90) + + val INTENSITY_BOLD = color(1) + val UNDERLINE = color(4) + private const val ESC = 27.toChar() + private const val BRACKET = '[' + private fun color(code: Int): AnsiEscapes { + return AnsiEscapes(code.toString() + "m") + } + + fun up(count: Int): AnsiEscapes { + return AnsiEscapes(count.toString() + "A") + } + } +} diff --git a/src/main/kotlin/io/iohk/atala/automation/cucumber/common/Format.kt b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/Format.kt new file mode 100644 index 0000000..970cfc2 --- /dev/null +++ b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/Format.kt @@ -0,0 +1,36 @@ +package io.iohk.atala.automation.cucumber.common + +interface Format { + fun text(text: String): String + + class Color internal constructor(private vararg val escapes: AnsiEscapes) : Format { + + override fun text(text: String): String { + val sb = java.lang.StringBuilder() + for (escape in escapes) { + escape.appendTo(sb) + } + sb.append(text) + if (escapes.isNotEmpty()) { + AnsiEscapes.RESET.appendTo(sb) + } + return sb.toString() + } + } + + class Monochrome internal constructor() : Format { + override fun text(text: String): String { + return text + } + } + + companion object { + fun color(vararg escapes: AnsiEscapes): Format { + return Color(*escapes) + } + + fun monochrome(): Format { + return Monochrome() + } + } +} diff --git a/src/main/kotlin/io/iohk/atala/automation/cucumber/common/Formats.kt b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/Formats.kt new file mode 100644 index 0000000..dd4e7a3 --- /dev/null +++ b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/Formats.kt @@ -0,0 +1,69 @@ +package io.iohk.atala.automation.cucumber.common + + +interface Formats { + operator fun get(key: String): Format + fun up(n: Int): String + + companion object { + @JvmStatic + fun monochrome(): Formats { + return Monochrome() + } + + @JvmStatic + fun ansi(): Formats { + return Ansi() + } + } + + class Monochrome internal constructor() : Formats { + override fun get(key: String): Format { + return Format.monochrome() + } + + override fun up(n: Int): String { + return "" + } + } + + class Ansi internal constructor() : Formats { + override fun get(key: String): Format { + val format: Format = formats[key] ?: throw NullPointerException("No format for key $key") + return format + } + + override fun up(n: Int): String { + return AnsiEscapes.up(n).toString() + } + + companion object { + private val formats: Map = object : java.util.HashMap() { + init { + // Never used, but avoids NPE in formatters. + put("undefined", Format.color(AnsiEscapes.YELLOW)) + put("undefined_arg", Format.color(AnsiEscapes.YELLOW, AnsiEscapes.INTENSITY_BOLD)) + put("unused", Format.color(AnsiEscapes.YELLOW)) + put("unused_arg", Format.color(AnsiEscapes.YELLOW, AnsiEscapes.INTENSITY_BOLD)) + put("pending", Format.color(AnsiEscapes.YELLOW)) + put("pending_arg", Format.color(AnsiEscapes.YELLOW, AnsiEscapes.INTENSITY_BOLD)) + put("executing", Format.color(AnsiEscapes.GREY)) + put("executing_arg", Format.color(AnsiEscapes.GREY, AnsiEscapes.INTENSITY_BOLD)) + put("failed", Format.color(AnsiEscapes.RED)) + put("failed_arg", Format.color(AnsiEscapes.RED, AnsiEscapes.INTENSITY_BOLD)) + put("ambiguous", Format.color(AnsiEscapes.RED)) + put("ambiguous_arg", Format.color(AnsiEscapes.RED, AnsiEscapes.INTENSITY_BOLD)) + put("passed", Format.color(AnsiEscapes.GREEN)) + put("passed_arg", Format.color(AnsiEscapes.BRIGHT_GREEN, AnsiEscapes.INTENSITY_BOLD)) + put("outline", Format.color(AnsiEscapes.CYAN)) + put("outline_arg", Format.color(AnsiEscapes.CYAN, AnsiEscapes.INTENSITY_BOLD)) + put("skipped", Format.color(AnsiEscapes.CYAN)) + put("skipped_arg", Format.color(AnsiEscapes.CYAN, AnsiEscapes.INTENSITY_BOLD)) + put("comment", Format.color(AnsiEscapes.GREY)) + put("tag", Format.color(AnsiEscapes.CYAN)) + put("output", Format.color(AnsiEscapes.BLUE)) + } + } + } + } +} diff --git a/src/main/kotlin/io/iohk/atala/automation/cucumber/common/UTF8OutputStreamWriter.kt b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/UTF8OutputStreamWriter.kt new file mode 100644 index 0000000..14b66e9 --- /dev/null +++ b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/UTF8OutputStreamWriter.kt @@ -0,0 +1,7 @@ +package io.iohk.atala.automation.cucumber.common + +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets + +class UTF8OutputStreamWriter(out: OutputStream) : OutputStreamWriter(out, StandardCharsets.UTF_8) diff --git a/src/main/kotlin/io/iohk/atala/automation/cucumber/common/UTF8PrintWriter.kt b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/UTF8PrintWriter.kt new file mode 100644 index 0000000..1c44905 --- /dev/null +++ b/src/main/kotlin/io/iohk/atala/automation/cucumber/common/UTF8PrintWriter.kt @@ -0,0 +1,72 @@ +package io.iohk.atala.automation.cucumber.common + +import java.io.Closeable +import java.io.Flushable +import java.io.IOException +import java.io.OutputStream +import java.io.OutputStreamWriter + +/** + * A "good enough" PrintWriter implementation that writes UTF-8 and rethrows all + * exceptions as runtime exceptions. + */ +class UTF8PrintWriter(out: OutputStream) : Appendable, Closeable, Flushable { + private val out: OutputStreamWriter = UTF8OutputStreamWriter(out) + + fun println() { + try { + out.write(System.lineSeparator()) + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + fun println(s: String) { + try { + out.write(s) + out.write(System.lineSeparator()) + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + override fun flush() { + try { + out.flush() + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + override fun close() { + try { + out.close() + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + override fun append(csq: CharSequence): Appendable { + try { + return out.append(csq) + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + override fun append(csq: CharSequence, start: Int, end: Int): Appendable { + try { + return out.append(csq, start, end) + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + override fun append(c: Char): Appendable { + try { + return out.append(c) + } catch (e: IOException) { + throw RuntimeException(e) + } + } +} diff --git a/src/main/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityStepListener.kt b/src/main/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityStepListener.kt new file mode 100644 index 0000000..41486d3 --- /dev/null +++ b/src/main/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityStepListener.kt @@ -0,0 +1,68 @@ +package io.iohk.atala.automation.cucumber.plugins + +import net.thucydides.model.domain.Story +import net.thucydides.model.domain.TestOutcome +import net.thucydides.model.domain.TestResult +import net.thucydides.model.screenshots.ScreenshotAndHtmlSource +import net.thucydides.model.steps.ExecutedStepDescription +import net.thucydides.model.steps.StepFailure +import net.thucydides.model.steps.StepListener +import java.time.ZonedDateTime + +class SerenityStepListener: StepListener { + data class Entry( + val keyword: String, + val stepText: String, + val arguments: List, + val isSubStep: Boolean + ) + companion object { + val stepList = mutableListOf() + } + + override fun stepStarted(description: ExecutedStepDescription, startTime: ZonedDateTime) { + val split = description.title.split(" ") + val keyword = split[0] + " " + val stepText = split.subList(1, split.size).joinToString(" ") + val isSubStep = description.stepClass != null + val arguments = description.arguments + stepList.add(Entry(keyword, stepText, arguments, isSubStep)) + } + + override fun testSuiteStarted(storyClass: Class<*>?) {} + override fun testSuiteStarted(story: Story) {} + override fun testSuiteFinished() {} + override fun testStarted(description: String) {} + override fun testStarted(description: String, id: String) {} + override fun testStarted(description: String, id: String, startTime: ZonedDateTime) {} + override fun testFinished(result: TestOutcome) {} + override fun testFinished(result: TestOutcome, isInDataDrivenTest: Boolean, finishTime: ZonedDateTime) {} + override fun testRetried() {} + override fun stepStarted(description: ExecutedStepDescription) {} + override fun skippedStepStarted(description: ExecutedStepDescription) {} + override fun stepFailed(failure: StepFailure) {} + override fun stepFailed(failure: StepFailure?, screenshotList: MutableList?, isInDataDrivenTest: Boolean) {} + override fun stepFailed(failure: StepFailure?, screenshotList: MutableList?, isInDataDrivenTest: Boolean, zonedDateTime: ZonedDateTime?) {} + + override fun lastStepFailed(failure: StepFailure) {} + override fun stepIgnored() {} + override fun stepPending() {} + override fun stepPending(message: String) {} + override fun stepFinished() {} + override fun stepFinished(screenshotList: List, time: ZonedDateTime) {} + override fun testFailed(testOutcome: TestOutcome, cause: Throwable) {} + override fun testIgnored() {} + override fun testSkipped() {} + override fun testPending() {} + override fun testIsManual() {} + override fun notifyScreenChange() {} + override fun useExamplesFrom(table: net.thucydides.model.domain.DataTable?) {} + override fun addNewExamplesFrom(table: net.thucydides.model.domain.DataTable?) {} + override fun exampleStarted(data: Map) {} + override fun exampleFinished() {} + override fun assumptionViolated(message: String) {} + override fun testRunFinished() {} + override fun takeScreenshots(screenshots: List) {} + override fun takeScreenshots(testResult: TestResult, screenshots: List) {} +} + diff --git a/src/main/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityWithCucumberFormatter.kt b/src/main/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityWithCucumberFormatter.kt new file mode 100644 index 0000000..add89a7 --- /dev/null +++ b/src/main/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityWithCucumberFormatter.kt @@ -0,0 +1,324 @@ +package io.iohk.atala.automation.cucumber.plugins + +import io.cucumber.core.exception.CucumberException +import io.cucumber.core.exception.ExceptionUtils +import io.cucumber.core.gherkin.DataTableArgument +import io.cucumber.datatable.DataTable +import io.cucumber.datatable.DataTableFormatter +import io.cucumber.plugin.ColorAware +import io.cucumber.plugin.ConcurrentEventListener +import io.cucumber.plugin.event.Argument +import io.cucumber.plugin.event.EmbedEvent +import io.cucumber.plugin.event.EventPublisher +import io.cucumber.plugin.event.PickleStepTestStep +import io.cucumber.plugin.event.Result +import io.cucumber.plugin.event.Step +import io.cucumber.plugin.event.TestCase +import io.cucumber.plugin.event.TestCaseStarted +import io.cucumber.plugin.event.TestRunFinished +import io.cucumber.plugin.event.TestStep +import io.cucumber.plugin.event.TestStepFinished +import io.cucumber.plugin.event.WriteEvent +import io.iohk.atala.automation.cucumber.common.Format +import io.iohk.atala.automation.cucumber.common.Formats.Companion.ansi +import io.iohk.atala.automation.cucumber.common.Formats.Companion.monochrome +import io.iohk.atala.automation.cucumber.common.UTF8PrintWriter +import java.io.BufferedReader +import java.io.File +import java.io.IOException +import java.io.OutputStream +import java.io.StringReader +import java.net.URI +import java.net.URISyntaxException +import java.util.Locale +import java.util.UUID +import kotlin.math.max + +/** + * Adapted from PrettyFormatter to add messages from SerenityListener. + * + * @see + * + * PrettyFormatter + * + */ + +class SerenityWithCucumberFormatter(out: OutputStream?) : ConcurrentEventListener, ColorAware { + private val commentStartIndex: MutableMap = HashMap() + + private val out = UTF8PrintWriter(out!!) + private var formats = ansi() + + override fun setEventPublisher(publisher: EventPublisher) { + publisher.registerHandlerFor(TestCaseStarted::class.java) { event: TestCaseStarted -> + this.handleTestCaseStarted( + event + ) + } + publisher.registerHandlerFor(TestStepFinished::class.java) { event: TestStepFinished -> + this.handleTestStepFinished( + event + ) + } + publisher.registerHandlerFor(WriteEvent::class.java) { event: WriteEvent -> this.handleWrite(event) } + publisher.registerHandlerFor(EmbedEvent::class.java) { event: EmbedEvent -> this.handleEmbed(event) } + publisher.registerHandlerFor(TestRunFinished::class.java) { event: TestRunFinished -> + this.handleTestRunFinished( + event + ) + } + } + + private fun handleTestCaseStarted(event: TestCaseStarted) { + out.println() + preCalculateLocationIndent(event) + printTags(event) + printScenarioDefinition(event) + out.flush() + } + + private fun handleTestStepFinished(event: TestStepFinished) { + printStep(event) + printError(event) + out.flush() + } + + private fun handleWrite(event: WriteEvent) { + out.println() + printText(event) + out.println() + out.flush() + } + + private fun handleEmbed(event: EmbedEvent) { + out.println() + printEmbedding(event) + out.println() + out.flush() + } + + private fun handleTestRunFinished(event: TestRunFinished) { + printError(event) + out.close() + } + + private fun preCalculateLocationIndent(event: TestCaseStarted) { + val testCase = event.testCase + val longestStep = testCase.testSteps.stream() + .filter { obj: TestStep? -> PickleStepTestStep::class.java.isInstance(obj) } + .map { obj: TestStep? -> PickleStepTestStep::class.java.cast(obj) } + .map { obj: PickleStepTestStep -> obj.step } + .map { step: Step -> formatPlainStep(step.keyword, step.text).length } + .max(Comparator.naturalOrder()) + .orElse(0) + + val scenarioLength = formatScenarioDefinition(testCase).length + commentStartIndex[testCase.id] = (max(longestStep.toDouble(), scenarioLength.toDouble()) + 1).toInt() + } + + private fun printTags(event: TestCaseStarted) { + val tags = event.testCase.tags + if (tags.isNotEmpty()) { + out.println(SCENARIO_INDENT + java.lang.String.join(" ", tags)) + } + } + + private fun printScenarioDefinition(event: TestCaseStarted) { + val testCase = event.testCase + val definitionText = formatScenarioDefinition(testCase) + val path = relativize(testCase.uri).schemeSpecificPart + val locationIndent = calculateLocationIndent(event.testCase, SCENARIO_INDENT + definitionText) + out.println( + SCENARIO_INDENT + definitionText + locationIndent + + formatLocation(path + ":" + testCase.location.line) + ) + } + + + private fun printStep(event: TestStepFinished) { + if (event.testStep is PickleStepTestStep) { + val testStep = event.testStep as PickleStepTestStep + val keyword = testStep.step.keyword + val stepText = testStep.step.text + val status = event.result.status.name.lowercase(Locale.ROOT) + val formattedStepText = formatStepText( + keyword, stepText, formats[status], + formats[status + "_arg"], testStep.definitionArgument + ) + val locationComment = formatLocationComment(event, testStep, keyword, stepText) + out.println(STEP_INDENT + formattedStepText + locationComment) + val stepArgument = testStep.step.argument + if (DataTableArgument::class.java.isInstance(stepArgument)) { + val tableFormatter = DataTableFormatter + .builder() + .prefixRow(STEP_SCENARIO_INDENT) + .escapeDelimiters(false) + .build() + val dataTableArgument = stepArgument as DataTableArgument + try { + tableFormatter.formatTo(DataTable.create(dataTableArgument.cells()), out) + } catch (e: IOException) { + throw CucumberException(e) + } + } + } + + SerenityStepListener.stepList.forEach { + if (it.isSubStep) { + printSubStep(event, it.keyword, it.stepText) + } + } + SerenityStepListener.stepList.clear() + } + + private fun printSubStep(event: TestStepFinished, actor: String, stepText: String) { + if (event.testStep is PickleStepTestStep) { + val status = event.result.status.name.lowercase(Locale.ROOT) + val formattedStepText = formatStepText( + actor, stepText, formats[status], formats[status + "_arg"], emptyList() + ) + out.println(SUB_STEP_INDENT + formattedStepText) + } + } + + private fun formatLocationComment( + event: TestStepFinished, testStep: PickleStepTestStep, keyword: String, stepText: String + ): String { + val codeLocation = testStep.codeLocation ?: return "" + val locationIndent = calculateLocationIndent(event.testCase, formatPlainStep(keyword, stepText)) + return locationIndent + formatLocation(codeLocation) + } + + private fun printError(event: TestStepFinished) { + val result = event.result + printError(result) + } + + private fun printError(event: TestRunFinished) { + val result = event.result + printError(result) + } + + private fun printError(result: Result) { + val error = result.error + if (error != null) { + val name = result.status.name.lowercase(Locale.ROOT) + val format = formats[name] + val text = ExceptionUtils.printStackTrace(error) + out.println(" " + format.text(text)) + } + } + + private fun printText(event: WriteEvent) { + // Prevent interleaving when multiple threads write to System.out + val builder = StringBuilder() + try { + BufferedReader(StringReader(event.text)).use { lines -> + var line: String? + while ((lines.readLine().also { line = it }) != null) { + builder.append(STEP_SCENARIO_INDENT) + .append(line) // Add system line separator - \n won't do it! + .append(System.lineSeparator()) + } + } + } catch (e: IOException) { + throw CucumberException(e) + } + out.append(builder) + } + + private fun printEmbedding(event: EmbedEvent) { + val line = ("Embedding " + event.getName() + " [" + event.mediaType + " " + event.data.size + + " bytes]") + out.println(STEP_SCENARIO_INDENT + line) + } + + private fun formatPlainStep(keyword: String, stepText: String): String { + return STEP_INDENT + keyword + stepText + } + + private fun formatScenarioDefinition(testCase: TestCase): String { + return testCase.keyword + ": " + testCase.name + } + + private fun calculateLocationIndent(testStep: TestCase, prefix: String): String { + val commentStartAt = commentStartIndex.getOrDefault(testStep.id, 0) + val padding = commentStartAt - prefix.length + + if (padding < 0) { + return " " + } + val builder = StringBuilder(padding) + for (i in 0 until padding) { + builder.append(" ") + } + return builder.toString() + } + + private fun formatLocation(location: String): String { + return formats["comment"].text("# $location") + } + + private fun formatStepText( + keyword: String?, stepText: String, textFormat: Format, argFormat: Format, arguments: List + ): String { + var beginIndex = 0 + val result = StringBuilder(textFormat.text(keyword!!)) + for (argument in arguments) { + // can be null if the argument is missing. + if (argument.value != null) { + val argumentOffset = argument.start + // a nested argument starts before the enclosing argument ends; + // ignore it when formatting + if (argumentOffset < beginIndex) { + continue + } + val text = stepText.substring(beginIndex, argumentOffset) + result.append(textFormat.text(text)) + } + // val can be null if the argument isn't there, for example + // @And("(it )?has something") + if (argument.value != null) { + val text = stepText.substring(argument.start, argument.end) + result.append(argFormat.text(text)) + // set beginIndex to end of argument + beginIndex = argument.end + } + } + if (beginIndex != stepText.length) { + val text = stepText.substring(beginIndex) + result.append(textFormat.text(text)) + } + return result.toString() + } + + override fun setMonochrome(monochrome: Boolean) { + formats = if (monochrome) monochrome() else ansi() + } + + companion object { + private const val SCENARIO_INDENT = "" + private const val STEP_INDENT = " " + private const val SUB_STEP_INDENT = " " + private const val STEP_SCENARIO_INDENT = " " + const val PLUGIN: String = "io.iohk.atala.automation.cucumber.plugins.SerenityWithCucumberFormatter" + + fun relativize(uri: URI): URI { + if ("file" != uri.scheme) { + return uri + } + if (!uri.isAbsolute) { + return uri + } + + try { + val root = File("").toURI() + val relative = root.relativize(uri) + // Scheme is lost by relativize + return URI("file", relative.schemeSpecificPart, relative.fragment) + } catch (e: URISyntaxException) { + throw IllegalArgumentException(e.message, e) + } + } + } +} diff --git a/src/main/kotlin/io/iohk/atala/automation/restassured/CustomGsonObjectMapperFactory.kt b/src/main/kotlin/io/iohk/atala/automation/restassured/CustomGsonObjectMapperFactory.kt index 6afa15e..65a4690 100644 --- a/src/main/kotlin/io/iohk/atala/automation/restassured/CustomGsonObjectMapperFactory.kt +++ b/src/main/kotlin/io/iohk/atala/automation/restassured/CustomGsonObjectMapperFactory.kt @@ -15,10 +15,14 @@ import java.time.OffsetDateTime import java.time.format.DateTimeParseException class CustomGsonObjectMapperFactory: GsonObjectMapperFactory { + companion object { + fun builder(): GsonBuilder { + return GsonBuilder() + .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeTypeAdapter()) + } + } override fun create(cls: Type?, charset: String?): Gson { - return GsonBuilder() - .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeTypeAdapter()) - .create() + return builder().create() } class OffsetDateTimeTypeAdapter : JsonDeserializer, JsonSerializer { diff --git a/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseComparable.kt b/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseComparable.kt index 5355d62..ba869ac 100644 --- a/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseComparable.kt +++ b/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseComparable.kt @@ -1,28 +1,46 @@ package io.iohk.atala.automation.serenity.ensure import net.serenitybdd.screenplay.Actor +import net.serenitybdd.screenplay.Interaction import net.serenitybdd.screenplay.ensure.BlackBox import net.serenitybdd.screenplay.ensure.CommonPreconditions +import net.serenitybdd.screenplay.ensure.Expectation import net.serenitybdd.screenplay.ensure.KnowableValue import net.serenitybdd.screenplay.ensure.expectThatActualIs +import org.assertj.core.api.Assertions.assertThat /** * Expose comparison methods for [LastResponseEnsure]. * - * @param jsonPath xpath to get the field + * @param rootPath path to read from response + * @param bodyPath extra path to parse body */ -class LastResponseComparable>( - private val jsonPath: String -) { - private val isEqualComparator = - expectThatActualIs("equal to", fun(actor: Actor?, actual: KnowableValue>?, expected: A): Boolean { +class LastResponseComparable(rootPath: String, bodyPath: String = "") { + val rootPath: String + val bodyPath: String + + init { + check(rootPath, bodyPath) + this.rootPath = rootPath + this.bodyPath = bodyPath + } + + private fun check(rootPath: String, bodyPath: String) { + assertThat(rootPath).withFailMessage("Root path [$rootPath] should start with '$'").startsWith("$") + assertThat(bodyPath).withFailMessage("Body path [$bodyPath] should not start with '.'").doesNotStartWith(".") + assertThat(bodyPath).withFailMessage("Body path [$bodyPath] should not start with '$'").doesNotStartWith("$") + } + + fun isEqualComparator(): Expectation?, A> { + return expectThatActualIs("equal to", fun(actor: Actor?, actual: KnowableValue?, expected: A): Boolean { CommonPreconditions.ensureActualAndExpectedNotNull(actual, expected) val resolvedValue = actual!!(actor!!) BlackBox.logAssertion(resolvedValue, expected) return resolvedValue == expected }) + } - fun isEqualTo(expected: A): LastResponseInteraction { - return LastResponseInteraction(jsonPath, expected, isEqualComparator) + inline fun isEqualTo(expected: A): LastResponseInteraction { + return LastResponseInteraction.perform(rootPath, bodyPath, expected, isEqualComparator()) } } diff --git a/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseEnsure.kt b/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseEnsure.kt index 7af4cc1..7cb5b4e 100644 --- a/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseEnsure.kt +++ b/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseEnsure.kt @@ -1,5 +1,7 @@ package io.iohk.atala.automation.serenity.ensure +import kotlin.reflect.KClass + /** * Exposes the methods for [Ensure.thatTheLastResponse]. */ @@ -15,7 +17,7 @@ class LastResponseEnsure { * * @see LastResponseComparable */ - fun statusCode() = LastResponseComparable("statusCode") + fun statusCode() = LastResponseComparable("$.statusCode") /** * Usage example: @@ -27,5 +29,17 @@ class LastResponseEnsure { * * @see LastResponseComparable */ - fun contentType() = LastResponseComparable("contentType") + fun contentType() = LastResponseComparable("$.contentType") + + /** + * Usage example: + * ``` + * actor.attemptsTo( + * Ensure.thatTheLastResponse().body("path.to.variable")./*validation*/() + * ) + * ``` + * + * @see LastResponseComparable + */ + fun body(path: String = "") = LastResponseComparable("$.body", path) } diff --git a/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseInteraction.kt b/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseInteraction.kt index 0ef00ea..302246c 100644 --- a/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseInteraction.kt +++ b/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/LastResponseInteraction.kt @@ -1,36 +1,81 @@ package io.iohk.atala.automation.serenity.ensure -import com.fasterxml.jackson.databind.ObjectMapper +import com.google.gson.Gson +import com.google.gson.JsonObject import com.jayway.jsonpath.JsonPath +import io.iohk.atala.automation.restassured.CustomGsonObjectMapperFactory import net.serenitybdd.annotations.Step import net.serenitybdd.screenplay.Actor import net.serenitybdd.screenplay.Interaction import net.serenitybdd.screenplay.ensure.Expectation +import net.serenitybdd.screenplay.ensure.KnowableValue import net.serenitybdd.screenplay.ensure.KnownValue -/** - * Performs validation for [LastResponseComparable] - * - * @param jsonPath xpath to get the field for validation - * @param expected expected value for the current field - * @param expectation matcher to validate the field - */ -class LastResponseInteraction>( - private val jsonPath: String, - private val expected: A, - private val expectation: Expectation<((Actor) -> Comparable?)?, A> -) : Interaction { - private val expectedDescription = "SerenityRest.lastResponse().$jsonPath" - val description = expectation.describe(expected, false, expectedDescription) - - @Step("{0} should see that #description") - override fun performAs(actor: T) { - val json = ObjectMapper().writeValueAsString(SerenityRestJson()) - val actual: A = JsonPath.parse(json).read(jsonPath) - val knowValue: KnownValue = KnownValue(actual, actual.toString()) - val result: Boolean = expectation.apply(knowValue, expected, actor) - if (!result) { - throw AssertionError(expectation.compareActualWithExpected(knowValue, expected, false, expectedDescription)) +interface LastResponseInteraction: Interaction { + val description: String + + companion object { + /** + * Performs validation for [LastResponseComparable] + * + * @param rootPath path to read from response + * @param bodyPath extra path to parse body + * @param expected expected value for the current field + * @param expectation matcher to validate the field + */ + inline fun perform( + rootPath: String, + bodyPath: String, + expected: A, + expectation: Expectation?, A>, + ): LastResponseInteraction { + return object : LastResponseInteraction { + private val expectedDescription by lazy { + var expectedDescription = "SerenityRest.lastResponse().$rootPath" + if (bodyPath.isNotEmpty()) { + expectedDescription += ".$bodyPath" + } + expectedDescription + } + + @Suppress("unused") + override val description by lazy { + expectation.describe(expected, false, expectedDescription) + } + + @Step("{0} should see that #description") + override fun performAs(actor: T) { + val serenityResponse = SerenityRestJson() + val responseJson = serenityResponse.toString() + val documentContext = JsonPath.parse(responseJson) + + val path = if (bodyPath.isNotEmpty()) { "$rootPath.$bodyPath" } else { rootPath } + val gson = CustomGsonObjectMapperFactory.builder().create() + val json = gson.toJson(documentContext.read(path)) + val value = isSingleValue(json, gson) + val actual = if (A::class == String::class && !value) { + json as A // return the value itself + } else { + gson.fromJson(json, A::class.java) + } + + val knowValue: KnownValue = KnownValue(actual, actual.toString()) + val result: Boolean = expectation.apply(knowValue, expected, actor) + if (!result) { + throw AssertionError(expectation.compareActualWithExpected(knowValue, expected, false, expectedDescription)) + } + + } + + private inline fun isSingleValue(json: String, gson: Gson): Boolean { + try { + gson.fromJson(json, A::class.java) + return true + } catch (e: Exception) { + return false + } + } + } } } } diff --git a/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/SerenityRestJson.kt b/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/SerenityRestJson.kt index 33e4ed8..4e807ad 100644 --- a/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/SerenityRestJson.kt +++ b/src/main/kotlin/io/iohk/atala/automation/serenity/ensure/SerenityRestJson.kt @@ -1,5 +1,9 @@ package io.iohk.atala.automation.serenity.ensure +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.iohk.atala.automation.restassured.CustomGsonObjectMapperFactory import net.serenitybdd.rest.SerenityRest /** @@ -9,9 +13,9 @@ import net.serenitybdd.rest.SerenityRest * and get the attributes through xpath. */ class SerenityRestJson { - val statusCode: Int + val statusCode: String val contentType: String - val body: String + val body: Any init { val lastResponse = try { @@ -20,8 +24,39 @@ class SerenityRestJson { throw AssertionError("Couldn't find the last response, did you make a prior REST request?", e) } - this.statusCode = lastResponse.statusCode + this.statusCode = lastResponse.statusCode.toString() this.contentType = lastResponse.contentType - this.body = lastResponse.body.asString() + + val gson = CustomGsonObjectMapperFactory.builder().create() + + val jsonBody = lastResponse.body.asString() + var bodyJsonObject: JsonObject? = null + var bodyJsonArray: JsonArray? = null + suppress { + bodyJsonObject = gson.fromJson(jsonBody, JsonObject::class.java) + } + suppress { + bodyJsonArray = gson.fromJson(jsonBody, JsonArray::class.java) + } + + if (bodyJsonObject != null) { + this.body = bodyJsonObject!! + } else if (bodyJsonArray != null) { + this.body = bodyJsonArray!! + } else { + throw IllegalStateException("Response type is not JsonObject nor JsonArray") + } + } + + override fun toString(): String { + val gson = CustomGsonObjectMapperFactory.builder().create() + return gson.toJson(this) + } + + private fun suppress(function: () -> Unit) { + try { + function.invoke() + } catch (_: Exception) { + } } } diff --git a/src/main/kotlin/io/iohk/atala/automation/serenity/objectfactory/AtalaObjectFactory.kt b/src/main/kotlin/io/iohk/atala/automation/serenity/objectfactory/AtalaObjectFactory.kt index 9227bf5..0d7bf82 100644 --- a/src/main/kotlin/io/iohk/atala/automation/serenity/objectfactory/AtalaObjectFactory.kt +++ b/src/main/kotlin/io/iohk/atala/automation/serenity/objectfactory/AtalaObjectFactory.kt @@ -12,7 +12,7 @@ import net.serenitybdd.core.annotations.events.BeforeScenario import net.serenitybdd.core.lifecycle.LifecycleRegister import net.serenitybdd.rest.SerenityRest import net.thucydides.core.steps.StepEventBus -import java.util.* +import java.util.Collections import javax.inject.Inject import kotlin.reflect.KClass import kotlin.reflect.full.IllegalCallableAccessException @@ -56,7 +56,8 @@ class AtalaObjectFactory : ObjectFactory { private fun newInstance(type: KClass): T { val instance = invokeConstructor(type) - Serenity.initializeWithNoStepListener(instance).throwExceptionsImmediately() + Serenity.initializeWithNoStepListener(instance) + Serenity.throwExceptionsImmediately() if (StepEventBus.getParallelEventBus().isBaseStepListenerRegistered) { val newTestOutcome = StepEventBus.getParallelEventBus().baseStepListener.currentTestOutcome LifecycleRegister.register(instance) diff --git a/src/main/resources/META-INF/services/net.thucydides.model.steps.StepListener b/src/main/resources/META-INF/services/net.thucydides.model.steps.StepListener new file mode 100644 index 0000000..5ac7276 --- /dev/null +++ b/src/main/resources/META-INF/services/net.thucydides.model.steps.StepListener @@ -0,0 +1 @@ +io.iohk.atala.automation.cucumber.plugins.SerenityStepListener diff --git a/src/test/kotlin/io/iohk/atala/automation/WithMockServer.kt b/src/test/kotlin/io/iohk/atala/automation/WithMockServer.kt index 332bdbb..131210d 100644 --- a/src/test/kotlin/io/iohk/atala/automation/WithMockServer.kt +++ b/src/test/kotlin/io/iohk/atala/automation/WithMockServer.kt @@ -1,63 +1,111 @@ package io.iohk.atala.automation -import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.equalToJson +import com.github.tomakehurst.wiremock.client.WireMock.get +import com.github.tomakehurst.wiremock.client.WireMock.post import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import com.github.tomakehurst.wiremock.junit.WireMockRule import io.restassured.RestAssured -import org.junit.After import org.junit.Before +import org.junit.Rule + /** * Provides the mock server for all tests */ open class WithMockServer { - private lateinit var wireMockServer: WireMockServer + private val httpsPort = 9009 + + val baseUrl = "https://localhost:$httpsPort" + + @Rule + @JvmField + var wireMockRule: WireMockRule = WireMockRule(WireMockConfiguration.options().httpsPort(httpsPort)) @Before fun setupServer() { - RestAssured.baseURI = "http://localhost" - RestAssured.port = 8080 - wireMockServer = WireMockServer(WireMockConfiguration.wireMockConfig().port(8080)) - wireMockServer.start() - - WireMock.stubFor( - WireMock.post(WireMock.urlEqualTo("/")) - .withRequestBody(WireMock.equalToJson("{\"field\": \"some-value\"}")).willReturn( - WireMock.aResponse().withStatus(200).withBody("""{ "operation": "success" }""") + RestAssured.useRelaxedHTTPSValidation() + + wireMockRule.stubFor(get("/") + .willReturn( + aResponse() + .withStatus(200) + .withBody("""{ "operation": "success" }""") ) ) - WireMock.stubFor( - WireMock.get(WireMock.urlEqualTo("/field")).willReturn( - WireMock.aResponse().withStatus(200).withHeader("Content-Type", "application/json") + wireMockRule.stubFor(post("/") + .withRequestBody(equalToJson("{\"field\": \"some-value\"}")) + .willReturn( + aResponse() + .withStatus(200) + .withBody("""{ "operation": "success" }""") + ) + ) + + wireMockRule.stubFor(get("/field") + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") .withBody("""{ "field": "response" }""") ) ) - WireMock.stubFor( - WireMock.get(WireMock.urlEqualTo("/subfield")).willReturn( - WireMock.aResponse().withStatus(200).withHeader("Content-Type", "application/json") + wireMockRule.stubFor(get("/nested") + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("""{ "nested": { "field": { "value": "value" } } }""") + ) + ) + + wireMockRule.stubFor(get(WireMock.urlEqualTo("/subfield")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") .withBody("""{ "subfield": { "field": "response" }}""") ) ) - WireMock.stubFor( - WireMock.get(WireMock.urlEqualTo("/list")).willReturn( - WireMock.aResponse().withStatus(200).withHeader("Content-Type", "application/json") + wireMockRule.stubFor(get(WireMock.urlEqualTo("/simplelist")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") .withBody("""{ "list": [1, 2, 3] }""") ) ) - WireMock.stubFor( - WireMock.get(WireMock.urlEqualTo("/offsetdatetime")).willReturn( - WireMock.aResponse().withStatus(200).withHeader("Content-Type", "application/json") + wireMockRule.stubFor(get(WireMock.urlEqualTo("/offsetdatetime")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") .withBody("""{ "date": "2023-09-14T11:24:46.868625Z" }""") ) ) - } - @After - fun teardown() { - wireMockServer.stop() + wireMockRule.stubFor(get(WireMock.urlEqualTo("/list")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("""[ "field", "response", { "date": "2023-09-14T11:24:46.868625Z" } ]""") + ) + ) + + wireMockRule.stubFor(get(WireMock.urlEqualTo("/objects")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("""[ {"field": "response"}, {"field": "value"} ]""") + ) + ) } } diff --git a/src/test/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityPretty.kt b/src/test/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityPretty.kt new file mode 100644 index 0000000..9c907db --- /dev/null +++ b/src/test/kotlin/io/iohk/atala/automation/cucumber/plugins/SerenityPretty.kt @@ -0,0 +1,71 @@ +package io.iohk.atala.automation.cucumber.plugins + +import io.cucumber.datatable.DataTable +import io.cucumber.java.Before +import io.cucumber.java.ParameterType +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import io.cucumber.junit.CucumberOptions +import io.iohk.atala.automation.serenity.ensure.Ensure +import net.serenitybdd.annotations.Step +import net.serenitybdd.cucumber.CucumberWithSerenity +import net.serenitybdd.screenplay.Ability +import net.serenitybdd.screenplay.Actor +import net.serenitybdd.screenplay.Interaction +import net.serenitybdd.screenplay.actors.OnStage +import net.serenitybdd.screenplay.actors.OnlineCast +import org.junit.runner.RunWith + + +@CucumberOptions( + features = ["src/test/resources"], + plugin = [SerenityWithCucumberFormatter.PLUGIN], +) +@RunWith(CucumberWithSerenity::class) +class SerenityPrettyRunner + +class Test: Ability { + open class Something : Interaction { + + @Step("{0} tests something") + override fun performAs(actor: T) {} + } + + companion object { + fun something(): Interaction { + return Something() + } + } +} +class Steps { + @ParameterType(".*") + fun actor(actorName: String): Actor { + return OnStage.theActorCalled(actorName).whoCan(Test()) + } + + @Before + fun setTheStage() { + OnStage.setTheStage(OnlineCast()) + } + + @Given("the {actor} actor") + fun something(actor: Actor) { + actor.attemptsTo( + Test.something(), + Ensure.that(1).isEqualTo(1), + ) + } + + @When("some datatable") + fun someDataTable(dataTable: DataTable) {} + + @When("some {}, {}, {} parameters") + fun someParameters(i1: Int, i2: Int, i3: Int) {} + + @When("some {} example") + fun someExample(example: String) {} + + @Then("should pass") + fun shouldPass() {} +} diff --git a/src/test/kotlin/io/iohk/atala/automation/extensions/PostTest.kt b/src/test/kotlin/io/iohk/atala/automation/extensions/PostTest.kt index 4224969..664fbe3 100644 --- a/src/test/kotlin/io/iohk/atala/automation/extensions/PostTest.kt +++ b/src/test/kotlin/io/iohk/atala/automation/extensions/PostTest.kt @@ -29,7 +29,7 @@ class PostTest : WithMockServer() { @Test fun `Post Rest Interaction should be enhanced with body property`() { - val actor = Actor.named("Test").whoCan(CallAnApi.at("http://localhost")) + val actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) actor.attemptsTo( Post.to("/").body(BodyTest()), diff --git a/src/test/kotlin/io/iohk/atala/automation/extensions/ResponseTest.kt b/src/test/kotlin/io/iohk/atala/automation/extensions/ResponseTest.kt index dd60524..17e34fd 100644 --- a/src/test/kotlin/io/iohk/atala/automation/extensions/ResponseTest.kt +++ b/src/test/kotlin/io/iohk/atala/automation/extensions/ResponseTest.kt @@ -31,7 +31,7 @@ class ResponseTest : WithMockServer() { @Test fun `Get last response typed object`() { - val actor = Actor.named("tester").whoCan(CallAnApi.at("http://localhost")) + val actor = Actor.named("tester").whoCan(CallAnApi.at(baseUrl)) actor.attemptsTo(Get.resource("/field")) val typedResponse = SerenityRest.lastResponse().get() MatcherAssert.assertThat(typedResponse, CoreMatchers.notNullValue()) @@ -40,7 +40,7 @@ class ResponseTest : WithMockServer() { @Test fun `Get last response typed subfield object`() { - val actor = Actor.named("tester").whoCan(CallAnApi.at("http://localhost")) + val actor = Actor.named("tester").whoCan(CallAnApi.at(baseUrl)) actor.attemptsTo(Get.resource("/subfield")) val typedResponse = SerenityRest.lastResponse().get("subfield") MatcherAssert.assertThat(typedResponse, CoreMatchers.notNullValue()) @@ -49,8 +49,8 @@ class ResponseTest : WithMockServer() { @Test fun `Get last response typed list`() { - val actor = Actor.named("tester").whoCan(CallAnApi.at("http://localhost")) - actor.attemptsTo(Get.resource("/list")) + val actor = Actor.named("tester").whoCan(CallAnApi.at(baseUrl)) + actor.attemptsTo(Get.resource("/simplelist")) val typedList = SerenityRest.lastResponse().getList("list") MatcherAssert.assertThat(typedList, CoreMatchers.notNullValue()) MatcherAssert.assertThat(typedList.size, CoreMatchers.equalTo(3)) diff --git a/src/test/kotlin/io/iohk/atala/automation/serenity/ensure/EnsureTest.kt b/src/test/kotlin/io/iohk/atala/automation/serenity/ensure/EnsureTest.kt index c56a3e2..dafb488 100644 --- a/src/test/kotlin/io/iohk/atala/automation/serenity/ensure/EnsureTest.kt +++ b/src/test/kotlin/io/iohk/atala/automation/serenity/ensure/EnsureTest.kt @@ -25,12 +25,29 @@ import org.openqa.selenium.By import org.openqa.selenium.WebDriver import java.time.LocalDate import java.time.LocalTime +import java.time.OffsetDateTime import java.util.* class EnsureTest : WithMockServer() { @DefaultUrl("classpath:test.html") private class TestPage : PageObject() + class Field(val field: String) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Field) return false + return (field == other.field) + } + + override fun toString(): String { + return """${this::class.simpleName}(field="$field")""" + } + + override fun hashCode(): Int { + return field.hashCode() + } + } + @Managed(driver = "chrome", options = "--headless") var driver: WebDriver? = null @@ -43,6 +60,7 @@ class EnsureTest : WithMockServer() { @After fun cleanup() { Serenity.done() + Serenity.recordReportData() } @Test @@ -111,11 +129,87 @@ class EnsureTest : WithMockServer() { @Test fun `Ensure should be able to check SerenityRest lastResponse()`() { - val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at("http://localhost")) + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) + actor.attemptsTo( + Get.resource("/field"), + Ensure.thatTheLastResponse().statusCode().isEqualTo(200), + Ensure.thatTheLastResponse().contentType().isEqualTo("application/json"), + Ensure.thatTheLastResponse().body("field").isEqualTo("response") + ) + } + + @Test + fun `Ensure should be able to check SerenityRest lastResponse() status code`() { + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) + actor.attemptsTo( + Get.resource("/field"), + Ensure.thatTheLastResponse().statusCode().isEqualTo(200) + ) + } + + @Test + fun `Ensure should be able to check SerenityRest lastResponse() typed body`() { + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) actor.attemptsTo( Get.resource("/field"), + Ensure.thatTheLastResponse().body().isEqualTo( + Field("response") + ) + ) + } + + @Test + fun `Ensure should be able to check SerenityRest lastResponse() array response body`() { + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) + actor.attemptsTo( + Get.resource("/list"), + Ensure.thatTheLastResponse().body("[0]").isEqualTo("field"), + Ensure.thatTheLastResponse().body("[1]").isEqualTo("response"), + Ensure.thatTheLastResponse().body("[2].date").isEqualTo("2023-09-14T11:24:46.868625Z"), + Ensure.thatTheLastResponse().body("[2].date").isEqualTo(OffsetDateTime.parse("2023-09-14T11:24:46.868625Z")), + ) + } + + @Test + fun `Ensure should be able to check SerenityRest lastResponse() array of object response body`() { + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) + actor.attemptsTo( + Get.resource("/objects"), + Ensure.thatTheLastResponse().body("[0].field").isEqualTo("response"), + Ensure.thatTheLastResponse().body("[1]").isEqualTo(Field("value")), + ) + } + + @Test + fun `Ensure should be able to check SerenityRest lastResponse() stringified object body`() { + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) + actor.attemptsTo( + Get.resource("/objects"), + Ensure.thatTheLastResponse().body("[0]").isEqualTo("""{"field":"response"}"""), + ) + } + + @Test + fun `Ensure should throw the expected error`() { + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) + val ex = assertThrows(AssertionError::class.java) { + actor.attemptsTo( + Get.resource("/field"), + Ensure.thatTheLastResponse().statusCode().isEqualTo(200), + Ensure.thatTheLastResponse().contentType().isEqualTo("application/json"), + Ensure.thatTheLastResponse().body("field").isEqualTo("test") + ) + } + assertThat(ex.message, containsString("SerenityRest.lastResponse().\$.body.field that is equal to: <\"test\">")) + } + + @Test + fun `Ensure should be able to check SerenityRest lastResponse() nested body path`() { + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) + actor.attemptsTo( + Get.resource("/nested"), Ensure.thatTheLastResponse().statusCode().isEqualTo(200), - Ensure.thatTheLastResponse().contentType().isEqualTo("application/json") + Ensure.thatTheLastResponse().body("nested.field.value").isEqualTo("value") ) } @@ -124,7 +218,7 @@ class EnsureTest : WithMockServer() { // clears any previous request SerenityRest.clear() - val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at("http://localhost")) + val actor: Actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) val exception = assertThrows(AssertionError::class.java) { actor.attemptsTo( Ensure.thatTheLastResponse().contentType().isEqualTo("application/json") @@ -136,7 +230,19 @@ class EnsureTest : WithMockServer() { @Test fun `LastResponseInteraction should have a description`() { val interaction = Ensure.thatTheLastResponse().statusCode().isEqualTo(1) - assertThat(interaction.description, equalTo("SerenityRest.lastResponse().statusCode that is equal to: <1>")) + assertThat( + interaction.description, + equalTo("SerenityRest.lastResponse().$.statusCode that is equal to: <1>") + ) + } + + @Test + fun `LastResponseInteraction should have a description with body path`() { + val interaction = Ensure.thatTheLastResponse().body("my.path").isEqualTo(1) + assertThat( + interaction.description, + equalTo("SerenityRest.lastResponse().$.body.my.path that is equal to: <1>") + ) } @Test @@ -152,7 +258,7 @@ class EnsureTest : WithMockServer() { val exception = assertThrows(AssertionError::class.java) { Ensure.reportSoftAssertions() } - assertThat(exception.message, containsString("SOFT ASSERTION FAILURES")) + assertThat(exception.message, containsString("ASSERTION ERRORS")) } } } diff --git a/src/test/kotlin/io/iohk/atala/automation/serenity/objectfactory/AtalaObjectFactoryTest.kt b/src/test/kotlin/io/iohk/atala/automation/serenity/objectfactory/AtalaObjectFactoryTest.kt index cd79a79..9292e9f 100644 --- a/src/test/kotlin/io/iohk/atala/automation/serenity/objectfactory/AtalaObjectFactoryTest.kt +++ b/src/test/kotlin/io/iohk/atala/automation/serenity/objectfactory/AtalaObjectFactoryTest.kt @@ -84,7 +84,7 @@ class AtalaObjectFactoryTest : WithMockServer() { @Test fun `Should parse OffsetDateTime when using AtalaObjectFactory`() { AtalaObjectFactory - val actor = Actor.named("tester").whoCan(CallAnApi.at("http://localhost")) + val actor = Actor.named("tester").whoCan(CallAnApi.at(baseUrl)) actor.attemptsTo(Get.resource("/offsetdatetime")) val date = SerenityRest.lastResponse().get() assertThat(date, notNullValue()) diff --git a/src/test/kotlin/io/iohk/atala/automation/serenity/questions/HttpRequestTest.kt b/src/test/kotlin/io/iohk/atala/automation/serenity/questions/HttpRequestTest.kt index aae575b..eae360f 100644 --- a/src/test/kotlin/io/iohk/atala/automation/serenity/questions/HttpRequestTest.kt +++ b/src/test/kotlin/io/iohk/atala/automation/serenity/questions/HttpRequestTest.kt @@ -25,7 +25,7 @@ class HttpRequestTest : WithMockServer() { @Test fun `Question about HttpRequest call`() { - val actor = Actor.named("Test").whoCan(CallAnApi.at("http://localhost")) + val actor = Actor.named("Test").whoCan(CallAnApi.at(baseUrl)) HttpRequest.get("/").answeredBy(actor) } diff --git a/src/test/resources/feature.feature b/src/test/resources/feature.feature new file mode 100644 index 0000000..0938a7c --- /dev/null +++ b/src/test/resources/feature.feature @@ -0,0 +1,17 @@ +@tag +Feature: Test Feature + + Scenario Outline: Test Scenario + Given the Test actor + When some datatable + | value | + | value2 | + | | + And some 1, 2, 3 parameters + And some example + Then should pass + Examples: + | example | + | example1 | + | example2 | + | example3 | diff --git a/src/test/resources/serenity.properties b/src/test/resources/serenity.properties new file mode 100644 index 0000000..9085a5e --- /dev/null +++ b/src/test/resources/serenity.properties @@ -0,0 +1,2 @@ +serenity.project.name=Atala Automation +