1use crate::config::E2eConfig;
7use crate::escape::{escape_kotlin, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions::{maven, toolchain};
14use anyhow::Result;
15use heck::{ToLowerCamelCase, ToUpperCamelCase};
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23pub struct KotlinE2eCodegen;
25
26impl E2eCodegen for KotlinE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 config: &ResolvedCrateConfig,
32 _type_defs: &[alef_core::ir::TypeDef],
33 ) -> Result<Vec<GeneratedFile>> {
34 let lang = self.language_name();
35 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37 let mut files = Vec::new();
38
39 let call = &e2e_config.call;
41 let overrides = call.overrides.get(lang);
42 let _module_path = overrides
43 .and_then(|o| o.module.as_ref())
44 .cloned()
45 .unwrap_or_else(|| call.module.clone());
46 let function_name = overrides
47 .and_then(|o| o.function.as_ref())
48 .cloned()
49 .unwrap_or_else(|| call.function.clone());
50 let class_name = overrides
51 .and_then(|o| o.class.as_ref())
52 .cloned()
53 .unwrap_or_else(|| config.name.to_upper_camel_case());
54 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
55 let result_var = &call.result_var;
56
57 let kotlin_pkg = e2e_config.resolve_package("kotlin");
59 let pkg_name = kotlin_pkg
60 .as_ref()
61 .and_then(|p| p.name.as_ref())
62 .cloned()
63 .unwrap_or_else(|| config.name.clone());
64
65 let _kotlin_pkg_path = kotlin_pkg
67 .as_ref()
68 .and_then(|p| p.path.as_ref())
69 .cloned()
70 .unwrap_or_else(|| "../../packages/kotlin".to_string());
71 let kotlin_version = kotlin_pkg
72 .as_ref()
73 .and_then(|p| p.version.as_ref())
74 .cloned()
75 .or_else(|| config.resolved_version())
76 .unwrap_or_else(|| "0.1.0".to_string());
77 let kotlin_pkg_id = config.kotlin_package();
78
79 let needs_mock_server = groups
85 .iter()
86 .flat_map(|g| g.fixtures.iter())
87 .any(|f| f.needs_mock_server());
88
89 files.push(GeneratedFile {
91 path: output_base.join("build.gradle.kts"),
92 content: render_build_gradle(
93 &pkg_name,
94 &kotlin_pkg_id,
95 &kotlin_version,
96 e2e_config.dep_mode,
97 needs_mock_server,
98 ),
99 generated_header: false,
100 });
101
102 let mut test_base = output_base.join("src").join("test").join("kotlin");
106 for segment in kotlin_pkg_id.split('.') {
107 test_base = test_base.join(segment);
108 }
109 let test_base = test_base.join("e2e");
110
111 if needs_mock_server {
112 files.push(GeneratedFile {
113 path: test_base.join("MockServerListener.kt"),
114 content: render_mock_server_listener_kt(&kotlin_pkg_id),
115 generated_header: true,
116 });
117 files.push(GeneratedFile {
118 path: output_base
119 .join("src")
120 .join("test")
121 .join("resources")
122 .join("META-INF")
123 .join("services")
124 .join("org.junit.platform.launcher.LauncherSessionListener"),
125 content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
126 generated_header: false,
127 });
128 }
129
130 let options_type = overrides.and_then(|o| o.options_type.clone());
132 let field_resolver = FieldResolver::new(
133 &e2e_config.fields,
134 &e2e_config.fields_optional,
135 &e2e_config.result_fields,
136 &e2e_config.fields_array,
137 &HashSet::new(),
138 );
139
140 for group in groups {
141 let active: Vec<&Fixture> = group
142 .fixtures
143 .iter()
144 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
145 .collect();
146
147 if active.is_empty() {
148 continue;
149 }
150
151 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
152 let content = render_test_file(
153 &group.category,
154 &active,
155 &class_name,
156 &function_name,
157 &kotlin_pkg_id,
158 result_var,
159 &e2e_config.call.args,
160 options_type.as_deref(),
161 &field_resolver,
162 result_is_simple,
163 &e2e_config.fields_enum,
164 e2e_config,
165 );
166 files.push(GeneratedFile {
167 path: test_base.join(class_file_name),
168 content,
169 generated_header: true,
170 });
171 }
172
173 Ok(files)
174 }
175
176 fn language_name(&self) -> &'static str {
177 "kotlin"
178 }
179}
180
181fn render_build_gradle(
186 pkg_name: &str,
187 kotlin_pkg_id: &str,
188 pkg_version: &str,
189 dep_mode: crate::config::DependencyMode,
190 needs_mock_server: bool,
191) -> String {
192 let dep_block = match dep_mode {
193 crate::config::DependencyMode::Registry => {
194 format!(r#" testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
196 }
197 crate::config::DependencyMode::Local => {
198 let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name);
203 format!(r#" testImplementation(files("../../target/release/{jar_name}.jar"))"#)
204 }
205 };
206
207 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
208 let junit = maven::JUNIT;
209 let jackson = maven::JACKSON_E2E;
210 let jvm_target = toolchain::JVM_TARGET;
211 let launcher_dep = if needs_mock_server {
212 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
213 } else {
214 String::new()
215 };
216 format!(
217 r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
218
219plugins {{
220 kotlin("jvm") version "{kotlin_plugin}"
221}}
222
223group = "{kotlin_pkg_id}"
224version = "0.1.0"
225
226java {{
227 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
228 targetCompatibility = JavaVersion.VERSION_{jvm_target}
229}}
230
231kotlin {{
232 compilerOptions {{
233 jvmTarget.set(JvmTarget.JVM_{jvm_target})
234 }}
235}}
236
237repositories {{
238 mavenCentral()
239}}
240
241dependencies {{
242{dep_block}
243 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
244 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
245{launcher_dep}
246 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
247 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
248 testImplementation(kotlin("test"))
249}}
250
251tasks.test {{
252 useJUnitPlatform()
253 val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
254 systemProperty("java.library.path", libPath)
255 systemProperty("jna.library.path", libPath)
256}}
257"#
258 )
259}
260
261fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
270 let header = hash::header(CommentStyle::DoubleSlash);
271 format!(
272 r#"{header}package {kotlin_pkg_id}.e2e
273
274import java.io.BufferedReader
275import java.io.IOException
276import java.io.InputStreamReader
277import java.nio.charset.StandardCharsets
278import java.nio.file.Path
279import java.nio.file.Paths
280import java.util.regex.Pattern
281import org.junit.platform.launcher.LauncherSession
282import org.junit.platform.launcher.LauncherSessionListener
283
284/**
285 * Spawns the mock-server binary once per JUnit launcher session and
286 * exposes its URL as the `mockServerUrl` system property. Generated
287 * test bodies read the property (with `MOCK_SERVER_URL` env-var
288 * fallback) so tests can run via plain `./gradlew test` without any
289 * external mock-server orchestration. Mirrors the Ruby spec_helper /
290 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
291 * skipping the spawn entirely.
292 */
293class MockServerListener : LauncherSessionListener {{
294 private var mockServer: Process? = null
295
296 override fun launcherSessionOpened(session: LauncherSession) {{
297 val preset = System.getenv("MOCK_SERVER_URL")
298 if (!preset.isNullOrEmpty()) {{
299 System.setProperty("mockServerUrl", preset)
300 return
301 }}
302 val repoRoot = locateRepoRoot()
303 ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
304 val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
305 val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
306 val fixturesDir = repoRoot.resolve("fixtures").toFile()
307 check(bin.exists()) {{
308 "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
309 }}
310 val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
311 .redirectErrorStream(false)
312 val server = try {{
313 pb.start()
314 }} catch (e: IOException) {{
315 throw IllegalStateException("MockServerListener: failed to start mock-server", e)
316 }}
317 mockServer = server
318 // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
319 // Cap the loop so a misbehaving mock-server cannot block indefinitely.
320 val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
321 var url: String? = null
322 try {{
323 for (i in 0 until 16) {{
324 val line = stdout.readLine() ?: break
325 when {{
326 line.startsWith("MOCK_SERVER_URL=") -> {{
327 url = line.removePrefix("MOCK_SERVER_URL=").trim()
328 }}
329 line.startsWith("MOCK_SERVERS=") -> {{
330 val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
331 System.setProperty("mockServers", jsonVal)
332 // Parse JSON map of fixture_id -> url and expose as system properties.
333 val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
334 val matcher = p.matcher(jsonVal)
335 while (matcher.find()) {{
336 System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
337 }}
338 break
339 }}
340 url != null -> break
341 }}
342 }}
343 }} catch (e: IOException) {{
344 server.destroyForcibly()
345 throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
346 }}
347 if (url.isNullOrEmpty()) {{
348 server.destroyForcibly()
349 error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
350 }}
351 // TCP-readiness probe: ensure axum::serve is accepting before tests start.
352 // The mock-server binds the TcpListener synchronously then prints the URL
353 // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
354 // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
355 val healthUri = java.net.URI.create(url)
356 val host = healthUri.host
357 val port = healthUri.port
358 val deadline = System.nanoTime() + 5_000_000_000L
359 while (System.nanoTime() < deadline) {{
360 try {{
361 java.net.Socket().use {{ s ->
362 s.connect(java.net.InetSocketAddress(host, port), 100)
363 break
364 }}
365 }} catch (_: java.io.IOException) {{
366 try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
367 }}
368 }}
369 System.setProperty("mockServerUrl", url)
370 // Drain remaining stdout/stderr in daemon threads so a full pipe
371 // does not block the child.
372 Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
373 Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
374 }}
375
376 override fun launcherSessionClosed(session: LauncherSession) {{
377 val server = mockServer ?: return
378 try {{ server.outputStream.close() }} catch (_: IOException) {{}}
379 try {{
380 if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
381 server.destroyForcibly()
382 }}
383 }} catch (ie: InterruptedException) {{
384 Thread.currentThread().interrupt()
385 server.destroyForcibly()
386 }}
387 }}
388
389 companion object {{
390 private fun locateRepoRoot(): Path? {{
391 var dir: Path? = Paths.get("").toAbsolutePath()
392 while (dir != null) {{
393 if (dir.resolve("fixtures").toFile().isDirectory
394 && dir.resolve("e2e").toFile().isDirectory) {{
395 return dir
396 }}
397 dir = dir.parent
398 }}
399 return null
400 }}
401
402 private fun drain(reader: BufferedReader) {{
403 try {{
404 val buf = CharArray(1024)
405 while (reader.read(buf) >= 0) {{ /* drain */ }}
406 }} catch (_: IOException) {{}}
407 }}
408 }}
409}}
410"#
411 )
412}
413
414#[allow(clippy::too_many_arguments)]
415fn render_test_file(
416 category: &str,
417 fixtures: &[&Fixture],
418 class_name: &str,
419 function_name: &str,
420 kotlin_pkg_id: &str,
421 result_var: &str,
422 args: &[crate::config::ArgMapping],
423 options_type: Option<&str>,
424 field_resolver: &FieldResolver,
425 result_is_simple: bool,
426 enum_fields: &HashSet<String>,
427 e2e_config: &E2eConfig,
428) -> String {
429 let mut out = String::new();
430 out.push_str(&hash::header(CommentStyle::DoubleSlash));
431 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
432
433 let (import_path, simple_class) = if class_name.contains('.') {
436 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
437 (class_name, simple)
438 } else {
439 ("", class_name)
440 };
441
442 let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
443 let _ = writeln!(out);
444
445 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
447
448 let mut per_fixture_options_types: HashSet<String> = HashSet::new();
452 for f in fixtures.iter() {
453 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
454 let call_overrides = cc.overrides.get("kotlin");
455 let effective_opts = call_overrides.and_then(|o| o.options_type.as_deref()).or(options_type);
456 if let Some(opts) = effective_opts {
457 let fixture_args = if cc.args.is_empty() { args } else { cc.args.as_slice() };
460 let has_json_obj = fixture_args
461 .iter()
462 .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&f.input, &arg.field).is_null());
463 if has_json_obj {
464 per_fixture_options_types.insert(opts.to_string());
465 }
466 }
467 }
468 let needs_object_mapper_for_options = !per_fixture_options_types.is_empty();
469 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
471 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
472 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
473 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
474 })
475 });
476 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
478
479 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
480 let _ = writeln!(out, "import kotlin.test.assertEquals");
481 let _ = writeln!(out, "import kotlin.test.assertTrue");
482 let _ = writeln!(out, "import kotlin.test.assertFalse");
483 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
484 let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
486 if has_call_fixtures && !import_path.is_empty() {
487 let _ = writeln!(out, "import {import_path}");
488 }
489 if needs_object_mapper {
490 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
491 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
492 }
493 if needs_object_mapper && has_call_fixtures {
495 let mut sorted_opts: Vec<&String> = per_fixture_options_types.iter().collect();
496 sorted_opts.sort();
497 for opts_type in sorted_opts {
498 let opts_package = if !import_path.is_empty() {
499 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
500 format!("{pkg}.{opts_type}")
501 } else {
502 opts_type.clone()
503 };
504 let _ = writeln!(out, "import {opts_package}");
505 }
506 }
507 if needs_object_mapper_for_handle && !import_path.is_empty() {
509 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
510 let _ = writeln!(out, "import {pkg}.CrawlConfig");
511 }
512 let _ = writeln!(out);
513
514 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
515 let _ = writeln!(out, "class {test_class_name} {{");
516
517 if needs_object_mapper {
518 let _ = writeln!(out);
519 let _ = writeln!(out, " companion object {{");
520 let _ = writeln!(
521 out,
522 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
523 );
524 let _ = writeln!(out, " }}");
525 }
526
527 for fixture in fixtures {
528 render_test_method(
529 &mut out,
530 fixture,
531 simple_class,
532 function_name,
533 result_var,
534 args,
535 options_type,
536 field_resolver,
537 result_is_simple,
538 enum_fields,
539 e2e_config,
540 );
541 let _ = writeln!(out);
542 }
543
544 let _ = writeln!(out, "}}");
545 out
546}
547
548struct KotlinTestClientRenderer;
555
556impl client::TestClientRenderer for KotlinTestClientRenderer {
557 fn language_name(&self) -> &'static str {
558 "kotlin"
559 }
560
561 fn sanitize_test_name(&self, id: &str) -> String {
562 sanitize_ident(id).to_upper_camel_case()
563 }
564
565 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
566 let _ = writeln!(out, " @Test");
567 let _ = writeln!(out, " fun test{fn_name}() {{");
568 let _ = writeln!(out, " // {description}");
569 if let Some(reason) = skip_reason {
570 let escaped = escape_kotlin(reason);
571 let _ = writeln!(
572 out,
573 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
574 );
575 }
576 }
577
578 fn render_test_close(&self, out: &mut String) {
579 let _ = writeln!(out, " }}");
580 }
581
582 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
583 let method = ctx.method.to_uppercase();
584 let fixture_path = ctx.path;
585
586 const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
588
589 let _ = writeln!(
590 out,
591 " val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
592 );
593 let _ = writeln!(out, " val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
594
595 let body_publisher = if let Some(body) = ctx.body {
596 let json = serde_json::to_string(body).unwrap_or_default();
597 let escaped = escape_kotlin(&json);
598 format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
599 } else {
600 "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
601 };
602
603 let _ = writeln!(out, " val builder = java.net.http.HttpRequest.newBuilder(uri)");
604 let _ = writeln!(out, " .method(\"{method}\", {body_publisher})");
605
606 if ctx.body.is_some() {
608 let content_type = ctx.content_type.unwrap_or("application/json");
609 let _ = writeln!(out, " .header(\"Content-Type\", \"{content_type}\")");
610 }
611
612 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
614 header_pairs.sort_by_key(|(k, _)| k.as_str());
615 for (name, value) in &header_pairs {
616 if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
617 continue;
618 }
619 let escaped_name = escape_kotlin(name);
620 let escaped_value = escape_kotlin(value);
621 let _ = writeln!(out, " .header(\"{escaped_name}\", \"{escaped_value}\")");
622 }
623
624 if !ctx.cookies.is_empty() {
626 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
627 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
628 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
629 let cookie_header = escape_kotlin(&cookie_str.join("; "));
630 let _ = writeln!(out, " .header(\"Cookie\", \"{cookie_header}\")");
631 }
632
633 let _ = writeln!(
634 out,
635 " val {} = java.net.http.HttpClient.newHttpClient()",
636 ctx.response_var
637 );
638 let _ = writeln!(
639 out,
640 " .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
641 );
642 }
643
644 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
645 let _ = writeln!(
646 out,
647 " assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
648 );
649 }
650
651 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
652 let escaped_name = escape_kotlin(name);
653 match expected {
654 "<<present>>" => {
655 let _ = writeln!(
656 out,
657 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
658 );
659 }
660 "<<absent>>" => {
661 let _ = writeln!(
662 out,
663 " assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
664 );
665 }
666 "<<uuid>>" => {
667 let _ = writeln!(
668 out,
669 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").matches(\"[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}\"), \"header {escaped_name} should be a UUID\")"
670 );
671 }
672 exact => {
673 let escaped_value = escape_kotlin(exact);
674 let _ = writeln!(
675 out,
676 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
677 );
678 }
679 }
680 }
681
682 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
683 match expected {
684 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
685 let json_str = serde_json::to_string(expected).unwrap_or_default();
686 let escaped = escape_kotlin(&json_str);
687 let _ = writeln!(out, " val bodyJson = MAPPER.readTree({response_var}.body())");
688 let _ = writeln!(out, " val expectedJson = MAPPER.readTree(\"{escaped}\")");
689 let _ = writeln!(out, " assertEquals(expectedJson, bodyJson, \"body mismatch\")");
690 }
691 serde_json::Value::String(s) => {
692 let escaped = escape_kotlin(s);
693 let _ = writeln!(
694 out,
695 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
696 );
697 }
698 other => {
699 let escaped = escape_kotlin(&other.to_string());
700 let _ = writeln!(
701 out,
702 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
703 );
704 }
705 }
706 }
707
708 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
709 if let Some(obj) = expected.as_object() {
710 let _ = writeln!(out, " val _partialTree = MAPPER.readTree({response_var}.body())");
711 for (key, val) in obj {
712 let escaped_key = escape_kotlin(key);
713 match val {
714 serde_json::Value::String(s) => {
715 let escaped_val = escape_kotlin(s);
716 let _ = writeln!(
717 out,
718 " assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
719 );
720 }
721 serde_json::Value::Bool(b) => {
722 let _ = writeln!(
723 out,
724 " assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
725 );
726 }
727 serde_json::Value::Number(n) => {
728 let _ = writeln!(
729 out,
730 " assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
731 );
732 }
733 other => {
734 let json_str = serde_json::to_string(other).unwrap_or_default();
735 let escaped_val = escape_kotlin(&json_str);
736 let _ = writeln!(
737 out,
738 " assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
739 );
740 }
741 }
742 }
743 }
744 }
745
746 fn render_assert_validation_errors(
747 &self,
748 out: &mut String,
749 response_var: &str,
750 errors: &[ValidationErrorExpectation],
751 ) {
752 let _ = writeln!(out, " val _veTree = MAPPER.readTree({response_var}.body())");
753 let _ = writeln!(out, " val _veErrors = _veTree.path(\"errors\")");
754 for ve in errors {
755 let escaped_msg = escape_kotlin(&ve.msg);
756 let _ = writeln!(
757 out,
758 " assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
759 );
760 }
761 }
762}
763
764fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
769 if http.expected_response.status_code == 101 {
771 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
772 let description = &fixture.description;
773 let _ = writeln!(out, " @Test");
774 let _ = writeln!(out, " fun test{method_name}() {{");
775 let _ = writeln!(out, " // {description}");
776 let _ = writeln!(
777 out,
778 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
779 );
780 let _ = writeln!(out, " }}");
781 return;
782 }
783
784 client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
785}
786
787#[allow(clippy::too_many_arguments)]
788fn render_test_method(
789 out: &mut String,
790 fixture: &Fixture,
791 class_name: &str,
792 _function_name: &str,
793 _result_var: &str,
794 _args: &[crate::config::ArgMapping],
795 options_type: Option<&str>,
796 field_resolver: &FieldResolver,
797 result_is_simple: bool,
798 enum_fields: &HashSet<String>,
799 e2e_config: &E2eConfig,
800) {
801 if let Some(http) = &fixture.http {
803 render_http_test_method(out, fixture, http);
804 return;
805 }
806
807 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
809 let lang = "kotlin";
810 let call_overrides = call_config.overrides.get(lang);
811
812 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
816 e2e_config
817 .call
818 .overrides
819 .get(lang)
820 .and_then(|o| o.client_factory.as_deref())
821 });
822
823 let effective_function_name = call_overrides
824 .and_then(|o| o.function.as_ref())
825 .cloned()
826 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
827 let effective_result_var = &call_config.result_var;
828 let effective_args = &call_config.args;
829 let function_name = effective_function_name.as_str();
830 let result_var = effective_result_var.as_str();
831 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
832 let effective_options_type = call_overrides.and_then(|o| o.options_type.as_deref()).or(options_type);
834 let options_type = effective_options_type;
835
836 let method_name = fixture.id.to_upper_camel_case();
837 let description = &fixture.description;
838 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
839
840 let needs_deser = options_type.is_some()
844 && args
845 .iter()
846 .any(|arg| arg.arg_type == "json_object" && !super::resolve_field(&fixture.input, &arg.field).is_null());
847
848 let _ = writeln!(out, " @Test");
849 let _ = writeln!(out, " fun test{method_name}() {{");
850 let _ = writeln!(out, " // {description}");
851
852 if let (true, Some(opts_type)) = (needs_deser, options_type) {
854 for arg in args {
855 if arg.arg_type == "json_object" {
856 let val = super::resolve_field(&fixture.input, &arg.field);
857 if !val.is_null() {
858 let normalized = super::normalize_json_keys_to_snake_case(val);
859 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
860 let var_name = &arg.name;
861 let _ = writeln!(
862 out,
863 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
864 escape_kotlin(&json_str)
865 );
866 }
867 }
868 }
869 }
870
871 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
872
873 if let Some(factory) = client_factory {
878 let fixture_id = &fixture.id;
879 let mock_url_expr = format!("System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"");
880 for line in &setup_lines {
881 let _ = writeln!(out, " {line}");
882 }
883 let _ = writeln!(
884 out,
885 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
886 );
887 if expects_error {
888 let _ = writeln!(out, " assertFailsWith<Exception> {{");
889 let _ = writeln!(out, " client.{function_name}({args_str})");
890 let _ = writeln!(out, " }}");
891 let _ = writeln!(out, " client.close()");
892 let _ = writeln!(out, " }}");
893 return;
894 }
895 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
896 for assertion in &fixture.assertions {
897 render_assertion(
898 out,
899 assertion,
900 result_var,
901 class_name,
902 field_resolver,
903 result_is_simple,
904 enum_fields,
905 );
906 }
907 let _ = writeln!(out, " client.close()");
908 let _ = writeln!(out, " }}");
909 return;
910 }
911
912 if expects_error {
914 let _ = writeln!(out, " assertFailsWith<Exception> {{");
917 for line in &setup_lines {
918 let _ = writeln!(out, " {line}");
919 }
920 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
921 let _ = writeln!(out, " }}");
922 let _ = writeln!(out, " }}");
923 return;
924 }
925
926 for line in &setup_lines {
927 let _ = writeln!(out, " {line}");
928 }
929
930 let _ = writeln!(
931 out,
932 " val {result_var} = {class_name}.{function_name}({args_str})"
933 );
934
935 for assertion in &fixture.assertions {
936 render_assertion(
937 out,
938 assertion,
939 result_var,
940 class_name,
941 field_resolver,
942 result_is_simple,
943 enum_fields,
944 );
945 }
946
947 let _ = writeln!(out, " }}");
948}
949
950fn build_args_and_setup(
954 input: &serde_json::Value,
955 args: &[crate::config::ArgMapping],
956 class_name: &str,
957 options_type: Option<&str>,
958 fixture_id: &str,
959) -> (Vec<String>, String) {
960 if args.is_empty() {
961 return (Vec::new(), String::new());
962 }
963
964 let mut setup_lines: Vec<String> = Vec::new();
965 let mut parts: Vec<String> = Vec::new();
966
967 for arg in args {
968 if arg.arg_type == "mock_url" {
969 setup_lines.push(format!(
970 "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
971 arg.name,
972 ));
973 parts.push(arg.name.clone());
974 continue;
975 }
976
977 if arg.arg_type == "handle" {
978 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
979 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
980 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
981 if config_value.is_null()
982 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
983 {
984 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
985 } else {
986 let json_str = serde_json::to_string(config_value).unwrap_or_default();
987 let name = &arg.name;
988 setup_lines.push(format!(
989 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
990 escape_kotlin(&json_str),
991 ));
992 setup_lines.push(format!(
993 "val {} = {class_name}.{constructor_name}({name}Config)",
994 arg.name,
995 name = name,
996 ));
997 }
998 parts.push(arg.name.clone());
999 continue;
1000 }
1001
1002 let val_resolved = super::resolve_field(input, &arg.field);
1004 let val: Option<&serde_json::Value> = if val_resolved.is_null() {
1005 None
1006 } else {
1007 Some(val_resolved)
1008 };
1009 match val {
1010 None | Some(serde_json::Value::Null) if arg.optional => {
1011 continue;
1012 }
1013 None | Some(serde_json::Value::Null) => {
1014 let default_val = match arg.arg_type.as_str() {
1015 "string" => "\"\"".to_string(),
1016 "int" | "integer" => "0".to_string(),
1017 "float" | "number" => "0.0".to_string(),
1018 "bool" | "boolean" => "false".to_string(),
1019 _ => "null".to_string(),
1020 };
1021 parts.push(default_val);
1022 }
1023 Some(v) => {
1024 if arg.arg_type == "json_object" && options_type.is_some() {
1026 parts.push(arg.name.clone());
1027 continue;
1028 }
1029 if arg.arg_type == "bytes" {
1031 let val = json_to_kotlin(v);
1032 parts.push(format!("{val}.toByteArray()"));
1033 continue;
1034 }
1035 parts.push(json_to_kotlin(v));
1036 }
1037 }
1038 }
1039
1040 (setup_lines, parts.join(", "))
1041}
1042
1043fn render_assertion(
1044 out: &mut String,
1045 assertion: &Assertion,
1046 result_var: &str,
1047 _class_name: &str,
1048 field_resolver: &FieldResolver,
1049 result_is_simple: bool,
1050 enum_fields: &HashSet<String>,
1051) {
1052 if let Some(f) = &assertion.field {
1054 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1055 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
1056 return;
1057 }
1058 }
1059
1060 let field_is_enum = assertion
1062 .field
1063 .as_deref()
1064 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1065
1066 let field_expr = if result_is_simple {
1068 result_var.to_string()
1069 } else {
1070 match &assertion.field {
1071 Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1072 _ => result_var.to_string(),
1073 }
1074 };
1075
1076 let field_is_optional = !result_is_simple
1080 && assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1081 let resolved = field_resolver.resolve(f);
1082 if field_resolver.has_map_access(f) {
1083 return false;
1084 }
1085 if field_resolver.is_optional(resolved) {
1087 return true;
1088 }
1089 let mut prefix = String::new();
1092 for part in resolved.split('.') {
1093 let key = part.split('[').next().unwrap_or(part);
1095 if !prefix.is_empty() {
1096 prefix.push('.');
1097 }
1098 prefix.push_str(key);
1099 if field_resolver.is_optional(&prefix) {
1100 return true;
1101 }
1102 }
1103 false
1104 });
1105
1106 let string_field_expr = if field_is_optional {
1109 format!("{field_expr}.orEmpty()")
1110 } else {
1111 field_expr.clone()
1112 };
1113
1114 let nonnull_field_expr = if field_is_optional {
1117 format!("{field_expr}!!")
1118 } else {
1119 field_expr.clone()
1120 };
1121
1122 let string_expr = if field_is_enum {
1124 format!("{string_field_expr}.getValue()")
1125 } else {
1126 string_field_expr.clone()
1127 };
1128
1129 match assertion.assertion_type.as_str() {
1130 "equals" => {
1131 if let Some(expected) = &assertion.value {
1132 let kotlin_val = json_to_kotlin(expected);
1133 if expected.is_string() {
1134 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1135 } else {
1136 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1137 }
1138 }
1139 }
1140 "contains" => {
1141 if let Some(expected) = &assertion.value {
1142 let kotlin_val = json_to_kotlin(expected);
1143 let _ = writeln!(
1144 out,
1145 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1146 );
1147 }
1148 }
1149 "contains_all" => {
1150 if let Some(values) = &assertion.values {
1151 for val in values {
1152 let kotlin_val = json_to_kotlin(val);
1153 let _ = writeln!(
1154 out,
1155 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1156 );
1157 }
1158 }
1159 }
1160 "not_contains" => {
1161 if let Some(expected) = &assertion.value {
1162 let kotlin_val = json_to_kotlin(expected);
1163 let _ = writeln!(
1164 out,
1165 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1166 );
1167 }
1168 }
1169 "not_empty" => {
1170 let _ = writeln!(
1171 out,
1172 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1173 );
1174 }
1175 "is_empty" => {
1176 let _ = writeln!(
1177 out,
1178 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1179 );
1180 }
1181 "contains_any" => {
1182 if let Some(values) = &assertion.values {
1183 let checks: Vec<String> = values
1184 .iter()
1185 .map(|v| {
1186 let kotlin_val = json_to_kotlin(v);
1187 format!("{string_expr}.contains({kotlin_val})")
1188 })
1189 .collect();
1190 let joined = checks.join(" || ");
1191 let _ = writeln!(
1192 out,
1193 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1194 );
1195 }
1196 }
1197 "greater_than" => {
1198 if let Some(val) = &assertion.value {
1199 let kotlin_val = json_to_kotlin(val);
1200 let _ = writeln!(
1201 out,
1202 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
1203 );
1204 }
1205 }
1206 "less_than" => {
1207 if let Some(val) = &assertion.value {
1208 let kotlin_val = json_to_kotlin(val);
1209 let _ = writeln!(
1210 out,
1211 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
1212 );
1213 }
1214 }
1215 "greater_than_or_equal" => {
1216 if let Some(val) = &assertion.value {
1217 let kotlin_val = json_to_kotlin(val);
1218 let _ = writeln!(
1219 out,
1220 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
1221 );
1222 }
1223 }
1224 "less_than_or_equal" => {
1225 if let Some(val) = &assertion.value {
1226 let kotlin_val = json_to_kotlin(val);
1227 let _ = writeln!(
1228 out,
1229 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
1230 );
1231 }
1232 }
1233 "starts_with" => {
1234 if let Some(expected) = &assertion.value {
1235 let kotlin_val = json_to_kotlin(expected);
1236 let _ = writeln!(
1237 out,
1238 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1239 );
1240 }
1241 }
1242 "ends_with" => {
1243 if let Some(expected) = &assertion.value {
1244 let kotlin_val = json_to_kotlin(expected);
1245 let _ = writeln!(
1246 out,
1247 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1248 );
1249 }
1250 }
1251 "min_length" => {
1252 if let Some(val) = &assertion.value {
1253 if let Some(n) = val.as_u64() {
1254 let _ = writeln!(
1255 out,
1256 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1257 );
1258 }
1259 }
1260 }
1261 "max_length" => {
1262 if let Some(val) = &assertion.value {
1263 if let Some(n) = val.as_u64() {
1264 let _ = writeln!(
1265 out,
1266 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1267 );
1268 }
1269 }
1270 }
1271 "count_min" => {
1272 if let Some(val) = &assertion.value {
1273 if let Some(n) = val.as_u64() {
1274 let _ = writeln!(
1275 out,
1276 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1277 );
1278 }
1279 }
1280 }
1281 "count_equals" => {
1282 if let Some(val) = &assertion.value {
1283 if let Some(n) = val.as_u64() {
1284 let _ = writeln!(
1285 out,
1286 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1287 );
1288 }
1289 }
1290 }
1291 "is_true" => {
1292 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
1293 }
1294 "is_false" => {
1295 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
1296 }
1297 "matches_regex" => {
1298 if let Some(expected) = &assertion.value {
1299 let kotlin_val = json_to_kotlin(expected);
1300 let _ = writeln!(
1301 out,
1302 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1303 );
1304 }
1305 }
1306 "not_error" => {
1307 }
1309 "error" => {
1310 }
1312 "method_result" => {
1313 let _ = writeln!(
1315 out,
1316 " // method_result assertions not yet implemented for Kotlin"
1317 );
1318 }
1319 other => {
1320 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1321 }
1322 }
1323}
1324
1325fn json_to_kotlin(value: &serde_json::Value) -> String {
1327 match value {
1328 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1329 serde_json::Value::Bool(b) => b.to_string(),
1330 serde_json::Value::Number(n) => {
1331 if n.is_f64() {
1332 format!("{}d", n)
1333 } else {
1334 n.to_string()
1335 }
1336 }
1337 serde_json::Value::Null => "null".to_string(),
1338 serde_json::Value::Array(arr) => {
1339 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1340 format!("listOf({})", items.join(", "))
1341 }
1342 serde_json::Value::Object(_) => {
1343 let json_str = serde_json::to_string(value).unwrap_or_default();
1344 format!("\"{}\"", escape_kotlin(&json_str))
1345 }
1346 }
1347}