feat(kotest): typesafe Kotest scenario DSL integration - #688
Open
wilmveel wants to merge 33 commits into
Open
Conversation
wilmveel
force-pushed
the
kotest-scenario-dsl
branch
15 times, most recently
from
July 16, 2026 20:45
64c6934 to
029dff3
Compare
wilmveel
commented
Jul 21, 2026
| val parameterTypes: List<Type>, | ||
| val returnType: Type, | ||
| val receiver: Type? = null, | ||
| val isSuspend: kotlin.Boolean = false, |
wilmveel
commented
Jul 21, 2026
| returnType: Type, | ||
| receiver: Type? = null, | ||
| parameterTypes: List<Type> = emptyList(), | ||
| isSuspend: Boolean = false, |
jerrevanveluw
requested changes
Jul 22, 2026
jerrevanveluw
left a comment
Collaborator
There was a problem hiding this comment.
Is the example added to the pipeline?
wilmveel
commented
Jul 22, 2026
|
|
||
| // Hard keywords that match the identifier pattern and therefore must be backtick-escaped | ||
| // when used as a name. Soft/modifier keywords are contextual and need no escaping. | ||
| private val hardKeywords = setOf( |
Contributor
Author
There was a problem hiding this comment.
use reserved keword from emitter
wilmveel
force-pushed
the
kotest-scenario-dsl
branch
4 times, most recently
from
July 29, 2026 19:33
697d82f to
d41bc1c
Compare
Add a new `src:integration:kotest` module that emits a typesafe Kotest
scenario DSL alongside the generated Kotlin models, driven by the IR
`KotestDslExtension`. Each operation and type gets a block-style entry
point that reads through the generated name:
- endpoint: `PutTodo.generate.request { … }.call()` (plus
`response200 { … }` for canned responses)
- channel: `Queue.generate.message { … }.send()` and
`Queue.generate.listen { expecting { … } }`
- type: `TodoDto.gen { … }` returning a `Gen<TodoDto>`
Per-field values are pinned with kotest `Gen`s; un-set fields are drawn
by the Wirespec generator so a scenario only spells out what it cares
about. A per-test ambient context (installed by the endpoint/channel
extensions) resolves the transport and a reproducible `RandomSource`.
Builders are shared, not replicated: every record has one reusable
`<Type>Builder`, referenced by endpoint request bodies, channel payloads
and `<Type>.gen`. Nested record fields expose a `<field>Block { … }`
sub-block that opens the nested type's builder, so overrides compose to
any depth. `<Type>.gen` is an extension on the type's companion object;
the extension injects an empty `companion object` into each generated
record so there is a receiver to hang on, keeping the model itself
dependency-free.
Build wiring: register the module, add kotest-property + coroutines, and
move the generated `Wirespec.kt` runtime to commonMain so this KMP module
can reference the `Wirespec.*` interfaces from its own commonMain.
Includes `examples/gradle-kotest`, an end-to-end Spring + embedded-Kafka
example driving the DSL against a live server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `Gen<Response<*>>.mock { req -> … }`, the response-side twin of
`Gen<Request>.call()`: it draws a response and stubs it on a mock server
for every incoming request the typed predicate accepts. Per-endpoint
generated (so `req` is the endpoint's typed Request), backed by a new
framework-neutral `MockServer` interface (mirroring `ChannelTransport`)
resolved from a `WirespecMockContext` installed by `WirespecMockExtension`
— the response-side counterpart to `WirespecEndpointExtension`.
Trim the channel DSL to send-only: drop the `listen`/receive half
(`expecting`/`collecting`/`returning`) from the generator and runtime, and
`receive()` from the `ChannelTransport` interface. Asserting on what the
app published is left to the test's own broker consumer.
Example (gradle-kotest):
- WireMock-backed `MockServer` (`WireMockMockServer`, the mock analogue of
`KafkaChannelTransport`) and a full end-to-end mock scenario: a second
contract `inventory.ws` for a downstream service the app calls via the
generated `GetStock` client, mocked with `.mock { req -> req.path.sku == … }`
in `ProductAvailabilityMockTest`.
- `KafkaChannelTransport` simplified to producer-only; `CampaignChannelScenarioTest`
asserts published events with a plain Kafka consumer (standard Kotest).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…extension subpackage - Make `ChannelTransport` a `fun interface`; the example's `KafkaChannelTransport` becomes a factory returning a SAM lambda over its producer. - `WirespecMockExtension` gains a caller-owned form (shared server, per-test reset, no per-spec close), so it registers once in `ProjectConfig` like the endpoint/channel extensions against a suite-wide server whose base URL is wired via `@DynamicPropertySource`. - Move `WireMockMockServer` into `ProjectConfig.kt` and back it with the wirespec WireMock integration: expose `requestBuilder(method, pathTemplate)` / `responseBuilder(rawResponse)` there and reuse them, keeping only the typed-predicate matcher example-side. - Move the three Kotest extensions into the `...kotest.extension` subpackage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…example transport Move the WireMock Request -> Wirespec.RawRequest mapping out of the example's ProjectConfig into the shared wiremock integration module as a public Request.toRawRequest(), and point the example at the integration-provided HttpTransportation instead of its own copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… topic Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Visibility enum to the IR (Ast/Dsl) and render it in KotlinGenerator,
then thread explicit visibility through the kotest scenario DSL emitters.
Make the endpoint request-scope slot vars (path/query/header/body) private so
each slot is settable only via its function form (`path { … }`), dropping the
redundant public `var` assignment path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`KotestDslExtension` emits the scenario DSL into the generated dir wired to the main source set, so the DSL runtime (`wirespec.integration.kotest`) and `kotest-property` have to compile against main — they were declared `testImplementation`, leaving `Gen`/`recordGen`/`endpointCall`/… unresolved. Promote both to `implementation`, matching the adjacent comment's intent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace raw code fragments with typed IR nodes where they map 1:1 with byte-identical output: the `generate` getters, the endpoint `request` body (`val scope = …Scope()` / `scope.block()` / `return scope.buildRequest()`), and the response `build()` cast. Imperative bodies that need trailing/ multi-statement lambdas or method chains (flush, registration, body copy, messageGen/send) stay raw — the shared KotlinGenerator can't emit those yet. Verified byte-identical: regenerated the gradle-kotest example's DSL and diffed against the pre-change output; unit and scenario tests stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the JDK-HttpClient-backed Wirespec.Transportation out of the kotest integration into a new dependency-light src:integration:java module (published as community.flock.wirespec.integration:java-jvm), so other integrations can share it. The module api-exposes the wirespec runtime since HttpTransportation implements Wirespec.Transportation. Repackage to community.flock.wirespec.integration.java.transport and repoint the gradle-kotest example (imports + catalog + dependency) and the kotest README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in HttpTransportation Path segments were joined into the URI raw while query values were encoded, so any URI-illegal character in a path param (e.g. a space or `#`) made URI.create throw before the request was ever sent. Path segments are now percent-encoded like query values, translating URLEncoder's `+` to `%20` for the path context. The default `Content-Type: application/json` was appended unconditionally for any non-null body, on top of headers already copied from the request, producing a duplicate header when the request declared its own Content-Type. It is now added only when the request carries no Content-Type of its own. Adds the module's first test source set: HttpTransportationTest drives the transport against an in-process JDK HttpServer and asserts on the received request. Both correctness fixes are regression-proven — the tests fail against the pre-fix code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ommonTest The module has a single jvm() target, so its commonMain compiles against the JVM and can use reflection, ConcurrentHashMap and coroutines directly. The commonMain vs jvmMain split was therefore organizational, not a portability boundary, so the whole reflection-driven runtime (ArbReceiver, EndpointReflection, CallExecutor, PrimitiveArbs, the *CallBuilder/ResponseBuilder terminals, the generator adapter) and the kotest framework extensions move into commonMain, and the four tests move into commonTest. jvmMain and jvmTest are removed entirely. The kotest-engine, kotlinx-coroutines-core and kotlin-reflect dependencies move from the jvmMain block into commonMain; commonTest drops the two dependencies it re-declared (kotest.property and :src:integration:wirespec already come through commonMain via dependsOn). Also folds in the in-flight reorganization this migration built on: the context handles move to a context/ subpackage, and the DSL emitter's per-model import collection is hoisted into a shared EndpointShape.modelImportsFor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up quality pass on the scenario-DSL runtime, no behavior change: - PrimitiveArbs: replace the exception-driven `supports()` (which built an Arb inside runCatching just to test membership) with `forTypeOrNull()`, so callers resolve the generator in a single lookup instead of building it twice. - EndpointReflection: extract `pickEmitterConstructor(excludeParam, label)`; the request (`method`) and response-variant (`status`) constructor selection were the same algorithm copied twice. - Extensions: extract a shared `SpecScopedResource<T>` for the per-spec lazy build + mutex + afterSpec close that Channel and Mock duplicated, and hoist the merge-or-create-ambient idiom (with its RandomSource seeding) into mergeEndpoint/mergeChannel/mergeMock so all three extensions share one path. - CallExecutor: move a stacked KDoc block onto buildResponseWith, the function it actually documents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Senior-review pass over src/integration/kotest, collapsing duplication and
tightening nullability without changing behavior:
- EndpointReflection: extract Parameter.isListType/listElementClassOrNull to
de-duplicate the List<T> element-class reflection; map param names to non-null
at the source instead of carrying List<String?> and filtering late.
- EndpointShape: unify the NestedObject/NestedList sealed branches via
nestedTypeName/childFields accessors, collapsing collectNestedTypeNames and
collectFieldTypeNames.
- KotestWirespecGenerator: extract applyOverride() for the duplicated
draw-then-wrap override path; inline single-use range locals in generateLeaf.
- CallExecutor: entries.associate -> mapKeys; drop redundant bodyGen!! by
branching on the gen; split out resolveGeneratedBody.
- ChannelCallBuilder: factor the shared payload build; drop the redundant
{ it() } wrapper in favor of block = it.
- ResponseMock: try/catch -> runCatching { }.getOrDefault(false).
- EndpointDslFile: smart-cast bodyType local over bodyType!!; extract
requireBodyElementType() for the duplicated error.
Also folded in the surrounding branch work: rename IR Type.Function.isSuspend
to isAsync (Ast/Dsl/KotlinGenerator), doc tidy-ups in the wiremock and java
integrations, and gradle-kotest example updates.
Two architecture fixes on the kotest integration module, driven by a
deep-modules / clear-interfaces review. Behavior-preserving; jvmTest green.
- Kill the Response<NNN> naming-convention leak: EndpointReflection,
EndpointCallBuilder and EndpointShape each encoded "variant class =
Response + 3-digit status" independently. Centralize into one internal
ResponseVariantNaming (className/statusOf); all three defer to it.
- Split the overloaded extension/ package by lifecycle. It fused the
build-time IR emitter (deps: compiler + ir) with the test-time Kotest
framework hooks (deps: kotest.core) under one word ("extension" meaning
both IrExtension and TestCaseExtension). Move the 10 internal codegen
helpers (*DslFile, *Shape, RecordBuilder, KotlinIdentifier,
KotlinTypeMapper, ValueSetter) into a new emit/ package; the runtime
Kotest extensions and the public KotestDslExtension stay in extension/,
so no public FQN changes. KotestDslExtension now imports the *DslFile
builders from emit/.
…er, drop dead code
Three cleanups on the kotest integration module (behavior-preserving; jvmTest green):
- Combine each emit/*Shape into its *DslFile: ChannelShape -> ChannelDslFile,
TypeShape -> TypeDslFile, EndpointShape -> EndpointDslFile. All were single-
consumer (EndpointShape stays referenced across the package, which is fine —
file boundaries don't affect same-package access).
- Reuse the IR generator's identifier escaper instead of a local copy: promote
KotlinGenerator's private String.escapeIdentifier to a public
String.escapeKotlinIdentifier (:src:compiler:ir) and delete kotest's
KotlinIdentifier, repointing its call sites. Removes a duplicated Kotlin
keyword list and guarantees raw-code emission escapes names identically to
the generator that renders the final file.
- Remove unused code:
* ChannelCallBuilder: buildMessage/buildMessageFields/send()/send(payload)/
sendFields + the orphaned private generatePayload (superseded by
messageGen / send(gen)).
* EndpointCallBuilder.buildRequest() + CallExecutor.buildRequest()
(superseded by buildRequestGen).
* The unreferenced typed-terminal subsystem: expecting()/collecting(),
statusOf, the customAssertion/expectedStatuses fields,
CallExecutor.executeEndpoint, and ContractValidator's expected-status check
incl. the public ContractViolationKind.UnexpectedStatus. The .call() path
(executeRequest) is untouched.
`<T>` already means `<T : Any?>`, so the explicit bound was noise. The container variants keep their meaningful `T : Any` non-null bounds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Remove the write-only `type: KType` from KotestFieldEnum/KotestFieldUnion (only KotestFieldShape.type is ever read). - Centralize the base-or-override generator resolution in ArbReceiver.generateModel(), collapsing the identical block in recordGen and ChannelCallBuilder.buildPayload (RNG draw order preserved). - Drop a compiler-flagged redundant cast in JvmRefinedWrapper.ctorFor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bind the receiver once via `when (val field = this)` so the array/nullable/ dict branches reference the smart-cast field directly, dropping the explicit `as Wirespec.GeneratorField*<Any>` casts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop pre-rendering Kotlin type strings in the kotest emit stage. Instead carry language-neutral IrType through the DSL shapes via the pipeline's Reference.convert(), and let KotlinGenerator render them at generation time. - Add KotlinGenerator.generateType(Type) so a single type can be rendered in the generate step (used where a type name is embedded in raw emitted code, e.g. channelCall<Payload>). - Thread IrType through NamedTypedField, ResponseVariantShape.bodyType and BodyFieldShape.Primitive; valueSetter/genOf/genNullableOf take IrType. - mapWithRefinedUnwrap returns IrType (Array/Dict/Nullable/convert nodes). - Delete the bespoke KotlinTypeMapper. Output is byte-for-byte identical (validated by KotestDslExtensionTest's exact emitted-type assertions). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the src/integration/java module (and its Gradle path :src:integration:jvm) and Kotlin package community.flock.wirespec.integration.java -> .jvm. Update the gradle-kotest example's dependency catalog (java-jvm -> jvm-jvm), imports, and the kotest README reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nvert Group the Kotest generator core (KotestWirespecGenerator, KotestField, KotestOverrides, KotestBuilderJvm, KotestWirespecKotlinGenerator) under a dedicated generator package, and rename the emit package to convert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…im comments Replace the single WirespecAmbient coroutine-context element (which merged endpoint/channel/mock contexts plus a shared RandomSource) with three independent context elements folded into their extension files, plus a shared WirespecSeed element that preserves one-seed-per-test reproducibility. Also strip inline comments and collapse verbose KDoc to one-line summaries across the module's commonMain sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…VariantNaming Delete the dead WirespecRequestScope helper (referenced only by its own test) and inline the ResponseVariantNaming convention into its two call sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An all-nullable slot's registrations were wrapped in `<slot>?.let { … }`, so
omitting the block left every param unregistered and CallExecutor fell back to
a random value instead of null. Build the slot builder as nullable and register
each field unconditionally, so an absent block yields `Arb.constant(null)`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scenario DSL's `generate` entry points were typed `Gen<T>` even though every one was already backed by an `Arb` at runtime. Type them as `Arb` so callers get the richer Arb API (next/take/map/…) off `generate`. Runtime backends: recordGen, buildRequestGen, buildGen, messageGen. Emitters: <Type>.Companion.generate, endpoint request/responseNNN/build/ buildRequest, channel message. Input/override slots and consumer receivers (.call/.mock/.send) stay `Gen` — they accept any generator, and Arb is a Gen, so results still chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branch on builder-slot presence instead of the drawn value when copying primitive body fields. Using `?: base` conflated an unset slot with a slot explicitly configured to draw null (e.g. `endDate(null)` on a nullable field), silently restoring the base value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apply the same slot-presence branching to the collection override: a configured whole-collection slot that draws null now stays null instead of falling through to the block override or the base value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A param-less request/response emitted as a Kotlin `data object` only exposes a private synthetic constructor, which made reflective instantiation fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Endpoint, channel and record DSL entries were emitted using the raw Wirespec identifier, while the declarations themselves are emitted pascal-cased. Specs with underscored definition names (`Publish_Event`) produced DSL code referencing names that do not resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Channels sharing a payload type emitted identical Gen<Payload>.send extensions into one package, failing compilation with conflicting overloads; only the first channel per payload now carries it. Endpoints with a raw primitive body (e.g. Bytes) get a whole-value body slot wired through bodyTransform instead of a per-field builder, and the runtime falls back to primitive Arbs (incl. ByteArray) where no reflectable model generator exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wilmveel
force-pushed
the
kotest-scenario-dsl
branch
from
August 12, 2026 07:36
75168a4 to
b27eec1
Compare
A bare null for Reference.Any produced uncompilable generator code: non-null Any fields can't hold null and the GeneratorFieldNullable/ Array/Dict lambdas left T uninferred. Route any leaves through a GeneratorFieldString descriptor so every target language gets a concrete, assignable value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What
New
src:integration:kotestmodule that emits a typesafe Kotest scenario DSL next to the generated Kotlin models, via the IRKotestDslExtension. Endpoints, channels, and types each get a block-style entry point read through the generated name. Every builder entry point returns aGen<…>— compose it intocheckAll, orcall()/send()/draw()a single value out of it:Fields are pinned with kotest
Gens; un-set fields are drawn by the Wirespec generator.call()/send()draw seeded by the per-test ambientRandomSource(installed by the endpoint/channel extensions), which also resolves the transport.Shared builders
Every record has one reusable
<Type>Builder, referenced by endpoint request bodies, channel payloads, and<Type>.generatealike rather than replicated per operation. Nested record fields expose a<field>Block { … }sub-block that opens the nested type's builder, so overrides compose to any depth.<Type>.generateis a companion-object extension mirroring the endpoint/channel<X>.generate. Generated records have no companion, so the extension injects an emptycompanion objectinto each — thegeneratelogic stays in the kotest package, keeping the model dependency-free.Spring bridge
Both test extensions are framework-agnostic (no Spring/DI dependency); the module carries no Spring on its classpath:
WirespecEndpointExtension— supply an endpoint context eagerly (WirespecEndpointContext, ortransportation+serialization), or use the factory form:suspendfactories forWirespec.Transportation+Wirespec.Serialization, resolved per test.WirespecChannelExtension— the same, plus a managed overload for a stateful transport:suspendfactories it builds once, resets per test (reset), and closes after the spec. A same-named generic factory keepsreset = { it.clear() }cast-free.Each has a same-named factory function alongside its constructors, so named arguments (
serialization = …,transport(ation) = …) select the factory form unambiguously. A caller wires its framework in those factories — the example resolves the server port,Wirespec.Serializationbean, and Kafka bootstrap servers from the Spring test context viatestContextManager(). The managed channel extension builds its transport per spec (keyed bySpec), so a single instance is safe to register suite-wide.The example registers everything once in a single
io.kotest.provided.ProjectConfig—SpringExtensionplus the two wirespec extensions — so specs carry no extension wiring (no@ApplyExtension, no in-bodyextension(...), no per-app subclass or@TestConfigurationbean); they just declare@SpringBootTest/@EmbeddedKafka.Build wiring
src:integration:kotestinsettings.gradle.kts; addkotest-property+kotlinx-coroutines-coreto the catalog.Wirespec.ktruntime tocommonMainso this KMP module can reference theWirespec.*interfaces from its owncommonMain.Example & testing
examples/gradle-kotest— end-to-end Spring + embedded-Kafka app driving the DSL against a live server and a real broker.KotestDslExtensionTestasserts the emitted DSL (Gen<> entry points, builder dedup, companion injection); the generated output was also compiled withkotlincagainst the real runtime for a flat and a nested/channel spec.🤖 Generated with Claude Code