Skip to content

Commit 94a65e0

Browse files
committed
When tests fail, generate a playground link
1 parent 98b8cbc commit 94a65e0

File tree

4 files changed

+259
-13
lines changed

4 files changed

+259
-13
lines changed

example-projects/simple-project/test/PersonTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,19 @@ class PersonTest : OrbitalSpec({
4444
))
4545
}
4646

47+
48+
xit("should generate a playground link when a test fails") {
49+
"""
50+
find { Person(PersonId == 1) } as PossibleAdult
51+
""".queryForObject(
52+
stub("getPerson").returns("""{ "id" : 123, "age" : 36 }""")
53+
)
54+
.shouldBe(mapOf(
55+
"id" to 123,
56+
"age" to 12, // This will intentionally fail
57+
"isAdult" to true
58+
))
59+
}
60+
4761
}
4862
})

preflight-core/preflight-runtime/build.gradle.kts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ dependencies {
1818
// Not published to maven central, and not needed for testing
1919
// as it relates to saml auth
2020
exclude(group = "org.pac4j")
21+
22+
// This might need adding (and shading), but later...
23+
// Can we avoid by just using the OSS version in Preflight?
24+
exclude(group = "org.jooq.pro")
25+
exclude(group = "org.pac4j")
26+
}
27+
api("org.opentest4j:opentest4j:1.3.0")
28+
api("com.orbitalhq:taxi-playground-core:$orbitalVersion") {
29+
exclude(group = "io.confluent")
30+
exclude(group = "org.jooq.pro")
31+
exclude(group = "org.pac4j")
2132
}
2233
api("com.orbitalhq:taxiql-query-engine:$orbitalVersion") {
2334
artifact { classifier = "tests" }
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package com.orbitalhq.preflight.dsl
2+
3+
import arrow.core.Either
4+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
5+
import com.orbitalhq.SourcePackage
6+
import com.orbitalhq.playground.OperationStub
7+
import com.orbitalhq.schemas.taxi.TaxiSchema
8+
import io.kotest.assertions.AssertionFailedError
9+
import io.kotest.core.test.TestCase
10+
import java.io.ByteArrayOutputStream
11+
import java.util.zip.GZIPOutputStream
12+
import kotlin.io.encoding.Base64
13+
import kotlin.io.encoding.ExperimentalEncodingApi
14+
15+
object PlaygroundScenarioFactory {
16+
private val objectMapper = jacksonObjectMapper()
17+
fun buildPlaygroundScenario(
18+
capturedScenario: CapturedQuery,
19+
sourcePackage: SourcePackage,
20+
schema: TaxiSchema,
21+
failure: AssertionFailedError,
22+
testCase: TestCase,
23+
playgroundHost: String = "https://playground.taxilang.org"
24+
): Pair<PlaygroundQueryMessage, String> {
25+
26+
val sources = sourcePackage.sources.joinToString("\n") { it.content }
27+
val stubs = buildOperationStubs(capturedScenario, schema)
28+
val readme = """
29+
## ❌ Failed test scenario
30+
31+
Your test `${testCase.name.testName}` failed. Bummer. 👎
32+
33+
Don't worry, it happens to the best of us.
34+
35+
The test failed with the following error:
36+
37+
```
38+
${failure.message.orEmpty()}
39+
```
40+
41+
Here's the query you were trying to run:
42+
43+
```taxiql
44+
${capturedScenario.query.trim()}
45+
```
46+
47+
Stubs have been wired up, so you can re-run your query here. Either click the run button above,
48+
or the in the Query panel.
49+
""".trimIndent()
50+
val playgroundMessage = PlaygroundQueryMessage(
51+
sources,
52+
capturedScenario.query.trim(),
53+
emptyMap(),
54+
stubs,
55+
null, // TODO : expected json doesn't actually do anything yet.
56+
readme = readme
57+
)
58+
59+
return playgroundMessage to getPlaygroundUrl(playgroundMessage,playgroundHost )
60+
61+
}
62+
63+
private fun buildOperationStubs(
64+
capturedScenario: CapturedQuery,
65+
schema: TaxiSchema
66+
): List<OperationStub> {
67+
return capturedScenario.stub.responses.mapNotNull { (operationKey, response) ->
68+
when (response) {
69+
is Either.Right -> {
70+
val resultTypedInstances = response.value
71+
val listOfResults = resultTypedInstances.map { it.toRawObject() }
72+
73+
// find the operation this was intended for
74+
capturedScenario.stub
75+
val operation = schema.operations.firstOrNull { it.name == operationKey } ?: return@mapNotNull null
76+
val responseAsJson = if (operation.returnType.isCollection) {
77+
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(listOfResults)
78+
} else {
79+
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(listOfResults.firstOrNull())
80+
}
81+
OperationStub(
82+
operationKey,
83+
responseAsJson
84+
)
85+
}
86+
87+
else -> null
88+
}
89+
}
90+
}
91+
92+
@OptIn(ExperimentalEncodingApi::class)
93+
fun getPlaygroundUrl(
94+
queryMessage: PlaygroundQueryMessage,
95+
playgroundHost: String = "https://playground.taxilang.org",
96+
enableDevTools: Boolean = false
97+
): String {
98+
val json = jacksonObjectMapper().writeValueAsString(queryMessage)
99+
100+
// Compress the JSON with GZIP
101+
val gzipOutput = ByteArrayOutputStream()
102+
GZIPOutputStream(gzipOutput).use {
103+
it.write(json.toByteArray())
104+
}
105+
val compressed = gzipOutput.toByteArray()
106+
107+
// Base64 encode:
108+
val base64Encoded = Base64.encode(compressed)
109+
val devToolsPart = if (enableDevTools) {
110+
"?enableDevTools=true"
111+
} else ""
112+
113+
return "$playgroundHost/$devToolsPart#pako:$base64Encoded"
114+
}
115+
116+
}
117+
118+
// TODO : This is duplicated here because I need to add the readme property upstream on the
119+
// orbital instance of this message
120+
data class PlaygroundQueryMessage(
121+
val schema: String,
122+
val query: String,
123+
val parameters: Map<String, Any> = emptyMap(),
124+
val stubs: List<OperationStub> = emptyList(),
125+
val expectedJson: String? = null,
126+
/**
127+
* The nebula stack Id that's currently running for this session.
128+
* Null if one doesn't exist yet.
129+
*/
130+
val stackId: String? = null,
131+
val readme: String?
132+
)

preflight-core/preflight-runtime/src/main/kotlin/com/orbitalhq/preflight/dsl/PreflightExtension.kt

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.orbitalhq.firstRawObject
55
import com.orbitalhq.firstRawValue
66
import com.orbitalhq.firstTypedInstace
77
import com.orbitalhq.models.TypedInstance
8+
import com.orbitalhq.preflight.dsl.PreflightExtension.Companion.PreflightTestCaseKey
89
import com.orbitalhq.query.QueryResult
910
import com.orbitalhq.rawObjects
1011
import com.orbitalhq.schemaServer.core.adaptors.taxi.TaxiSchemaSourcesAdaptor
@@ -25,9 +26,24 @@ import java.nio.file.Paths
2526
import kotlin.io.path.absolute
2627
import com.orbitalhq.schemaServer.core.file.packages.FileSystemPackageLoader
2728
import com.orbitalhq.utils.files.ReactivePollingFileSystemMonitor
29+
import io.kotest.assertions.AssertionFailedError
30+
import io.kotest.core.extensions.TestCaseExtension
31+
import io.kotest.core.listeners.AfterTestListener
32+
import io.kotest.core.test.TestCase
33+
import io.kotest.core.test.TestResult
34+
import kotlinx.coroutines.withContext
35+
import lang.taxi.query.TaxiQLQueryString
2836
import java.time.Duration
37+
import kotlin.coroutines.CoroutineContext
38+
import kotlin.coroutines.coroutineContext
39+
40+
class PreflightExtension(val projectRoot: Path = Paths.get("./")) : BeforeSpecListener, AfterTestListener,
41+
TestCaseExtension {
42+
43+
companion object {
44+
val PreflightTestCaseKey = object : CoroutineContext.Key<PreflightTestCaseContext> {}
45+
}
2946

30-
class PreflightExtension(val projectRoot: Path = Paths.get("./")) : BeforeSpecListener {
3147
/**
3248
* Provides access to the compiled taxi document.
3349
* This is lower-level than Orbital's schema object
@@ -38,22 +54,31 @@ class PreflightExtension(val projectRoot: Path = Paths.get("./")) : BeforeSpecLi
3854
lateinit var schema: TaxiSchema
3955
private set
4056

57+
lateinit var sourcePackage: SourcePackage
58+
private set
59+
4160
/**
4261
* Provides access to the actual Taxi project (the equivalent of the
4362
* taxi.conf file)
4463
*/
4564
lateinit var taxiProject: TaxiPackageProject
4665
private set
4766

67+
private val capturedScenarios = mutableMapOf<TestCase, CapturedQuery>()
68+
69+
4870
override suspend fun beforeSpec(spec: Spec) {
4971
val loader = TaxiPackageLoader.forDirectoryContainingTaxiFile(projectRoot.absolute().normalize())
5072
this.taxiProject = loader.load()
5173

52-
val sourcePackage = loadSourcePackage(this.taxiProject.packageRootPath!!)
74+
sourcePackage = loadSourcePackage(this.taxiProject.packageRootPath!!)
5375

5476
withClue("Taxi project should compile without errors") {
5577
val taxiSchema = try {
56-
TaxiSchema.from(sourcePackage, onErrorBehaviour = TaxiSchema.Companion.TaxiSchemaErrorBehaviour.THROW_EXCEPTION)
78+
TaxiSchema.from(
79+
sourcePackage,
80+
onErrorBehaviour = TaxiSchema.Companion.TaxiSchemaErrorBehaviour.THROW_EXCEPTION
81+
)
5782
} catch (e: CompilationException) {
5883
fail("Taxi project has errors: \n${e.message}")
5984
}
@@ -79,7 +104,7 @@ class PreflightExtension(val projectRoot: Path = Paths.get("./")) : BeforeSpecLi
79104
return sourcePackage
80105
}
81106

82-
fun orbital():Pair<Orbital, StubService> {
107+
fun orbital(): Pair<Orbital, StubService> {
83108
return testVyne(this.schema)
84109
}
85110

@@ -91,32 +116,96 @@ class PreflightExtension(val projectRoot: Path = Paths.get("./")) : BeforeSpecLi
91116
}
92117
}
93118

94-
suspend fun queryForScalar(taxiQl: String, vararg stubScenarios: StubScenario) = queryForScalar(taxiQl, stubScenariosToCustomizer(stubScenarios.toList()))
95-
suspend fun queryForScalar(taxiQl: String, stubCustomizer: (StubService) -> Unit = {}):Any? {
119+
suspend fun queryForScalar(taxiQl: String, vararg stubScenarios: StubScenario) =
120+
queryForScalar(taxiQl, stubScenariosToCustomizer(stubScenarios.toList()))
121+
122+
suspend fun queryForScalar(taxiQl: String, stubCustomizer: (StubService) -> Unit = {}): Any? {
96123
return query(taxiQl, stubCustomizer)
97124
.firstRawValue()
98125
}
99126

100127

101-
suspend fun queryForObject(taxiQl: String, vararg stubScenarios: StubScenario) = queryForObject(taxiQl, stubScenariosToCustomizer(stubScenarios.toList()))
102-
suspend fun queryForObject(taxiQl: String, stubCustomizer: (StubService) -> Unit = {}):Map<String,Any?> {
128+
suspend fun queryForObject(taxiQl: String, vararg stubScenarios: StubScenario) =
129+
queryForObject(taxiQl, stubScenariosToCustomizer(stubScenarios.toList()))
130+
131+
suspend fun queryForObject(taxiQl: String, stubCustomizer: (StubService) -> Unit = {}): Map<String, Any?> {
103132
return query(taxiQl, stubCustomizer)
104133
.firstRawObject()
105134
}
106-
suspend fun queryForCollection(taxiQl: String, vararg stubScenarios: StubScenario) = queryForCollection(taxiQl, stubScenariosToCustomizer(stubScenarios.toList()))
107-
suspend fun queryForCollection(taxiQl: String, stubCustomizer: (StubService) -> Unit = {}): List<Map<String, Any?>> {
135+
136+
suspend fun queryForCollection(taxiQl: String, vararg stubScenarios: StubScenario) =
137+
queryForCollection(taxiQl, stubScenariosToCustomizer(stubScenarios.toList()))
138+
139+
suspend fun queryForCollection(
140+
taxiQl: String,
141+
stubCustomizer: (StubService) -> Unit = {}
142+
): List<Map<String, Any?>> {
108143
return query(taxiQl, stubCustomizer)
109144
.rawObjects()
110145
}
111-
suspend fun queryForTypedInstance(taxiQl: String, vararg stubScenarios: StubScenario) = queryForTypedInstance(taxiQl, stubScenariosToCustomizer(stubScenarios.toList()))
112-
suspend fun queryForTypedInstance(taxiQl: String, stubCustomizer: (StubService) -> Unit = {}):TypedInstance {
146+
147+
suspend fun queryForTypedInstance(taxiQl: String, vararg stubScenarios: StubScenario) =
148+
queryForTypedInstance(taxiQl, stubScenariosToCustomizer(stubScenarios.toList()))
149+
150+
suspend fun queryForTypedInstance(taxiQl: String, stubCustomizer: (StubService) -> Unit = {}): TypedInstance {
113151
return query(taxiQl, stubCustomizer)
114152
.firstTypedInstace()
115153
}
116154

117155
suspend fun query(taxiQl: String, stubCustomizer: (StubService) -> Unit = {}): QueryResult {
118-
val (orbital,stub) = orbital()
156+
val (orbital, stub) = orbital()
119157
stubCustomizer(stub)
158+
val testContext = coroutineContext[PreflightTestCaseKey]
159+
if (testContext != null) {
160+
capturedScenarios[testContext.testCase] = CapturedQuery(stub, taxiQl)
161+
} else {
162+
println("A test is executing without a context - this shouldn't happen")
163+
}
120164
return orbital.query(taxiQl)
121165
}
166+
167+
override suspend fun intercept(testCase: TestCase, execute: suspend (TestCase) -> TestResult): TestResult {
168+
val context = PreflightTestCaseContext(testCase)
169+
return withContext(context) {
170+
val testResult = execute(testCase)
171+
val capturedScenario = capturedScenarios[testCase]
172+
if (testResult.isErrorOrFailure && capturedScenario != null) {
173+
174+
val failure = testResult as TestResult.Failure
175+
val originalError = failure.cause as AssertionFailedError
176+
val (_, playgroundLink) = PlaygroundScenarioFactory.buildPlaygroundScenario(
177+
capturedScenario,
178+
sourcePackage,
179+
schema,
180+
originalError,
181+
testCase
182+
)
183+
val errorMessageWithPlaygroundLink = """${originalError.message}
184+
|
185+
|This error is explorable in Taxi Playground at the following link: $playgroundLink
186+
""".trimMargin()
187+
val failureWithPlaygroundLink = failure.copy(
188+
cause = AssertionFailedError(
189+
message = errorMessageWithPlaygroundLink,
190+
cause = originalError.cause,
191+
expectedValue = originalError.expectedValue,
192+
actualValue = originalError.actualValue,
193+
)
194+
)
195+
failureWithPlaygroundLink
196+
} else {
197+
testResult
198+
}
199+
200+
}
201+
}
202+
}
203+
204+
data class CapturedQuery(
205+
val stub: StubService,
206+
val query: TaxiQLQueryString
207+
)
208+
209+
data class PreflightTestCaseContext(val testCase: TestCase) : CoroutineContext.Element {
210+
override val key = PreflightTestCaseKey
122211
}

0 commit comments

Comments
 (0)