1use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::field_access::FieldResolver;
11use crate::fixture::{Fixture, FixtureGroup};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::template_versions::{maven, toolchain};
15use anyhow::Result;
16use heck::ToUpperCamelCase;
17use std::collections::HashSet;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::kotlin;
22
23pub struct KotlinAndroidE2eCodegen;
27
28impl E2eCodegen for KotlinAndroidE2eCodegen {
29 fn generate(
30 &self,
31 groups: &[FixtureGroup],
32 e2e_config: &E2eConfig,
33 config: &ResolvedCrateConfig,
34 type_defs: &[alef_core::ir::TypeDef],
35 _enums: &[alef_core::ir::EnumDef],
36 ) -> Result<Vec<GeneratedFile>> {
37 let lang = self.language_name();
38 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
39
40 let mut files = Vec::new();
41
42 let call = &e2e_config.call;
44 let overrides = call.overrides.get(lang);
45 let _module_path = overrides
46 .and_then(|o| o.module.as_ref())
47 .cloned()
48 .unwrap_or_else(|| call.module.clone());
49 let function_name = overrides
50 .and_then(|o| o.function.as_ref())
51 .cloned()
52 .unwrap_or_else(|| call.function.clone());
53 let class_name = overrides
54 .and_then(|o| o.class.as_ref())
55 .cloned()
56 .unwrap_or_else(|| config.name.to_upper_camel_case());
57 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
58 let result_var = &call.result_var;
59
60 let kotlin_android_pkg = e2e_config.resolve_package("kotlin_android");
62 let pkg_name = kotlin_android_pkg
63 .as_ref()
64 .and_then(|p| p.name.as_ref())
65 .cloned()
66 .unwrap_or_else(|| config.name.clone());
67
68 let _kotlin_android_pkg_path = kotlin_android_pkg
70 .as_ref()
71 .and_then(|p| p.path.as_deref())
72 .unwrap_or("../../packages/kotlin-android");
73 let kotlin_android_version = kotlin_android_pkg
74 .as_ref()
75 .and_then(|p| p.version.as_ref())
76 .cloned()
77 .or_else(|| config.resolved_version())
78 .unwrap_or_else(|| "0.1.0".to_string());
79 let kotlin_pkg_id = kotlin_android_pkg
92 .as_ref()
93 .and_then(|p| p.module.clone())
94 .or_else(|| config.kotlin_android.as_ref().and_then(|c| c.package.clone()))
95 .unwrap_or_else(|| config.kotlin_package());
96
97 let needs_mock_server = groups
103 .iter()
104 .flat_map(|g| g.fixtures.iter())
105 .any(|f| f.needs_mock_server());
106
107 files.push(GeneratedFile {
109 path: output_base.join("build.gradle.kts"),
110 content: render_build_gradle_kotlin_android(
111 &pkg_name,
112 &kotlin_pkg_id,
113 &kotlin_android_version,
114 e2e_config.dep_mode,
115 needs_mock_server,
116 ),
117 generated_header: false,
118 });
119
120 files.push(GeneratedFile {
126 path: output_base.join("settings.gradle.kts"),
127 content: render_settings_gradle_kotlin_android(&pkg_name),
128 generated_header: false,
129 });
130
131 let mut test_base = output_base.join("src").join("test").join("kotlin");
135 for segment in kotlin_pkg_id.split('.') {
136 test_base = test_base.join(segment);
137 }
138 let test_base = test_base.join("e2e");
139
140 if needs_mock_server {
141 files.push(GeneratedFile {
142 path: test_base.join("MockServerListener.kt"),
143 content: kotlin::render_mock_server_listener_kt(&kotlin_pkg_id),
144 generated_header: true,
145 });
146 files.push(GeneratedFile {
147 path: output_base
148 .join("src")
149 .join("test")
150 .join("resources")
151 .join("META-INF")
152 .join("services")
153 .join("org.junit.platform.launcher.LauncherSessionListener"),
154 content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
155 generated_header: false,
156 });
157 }
158
159 let options_type = overrides.and_then(|o| o.options_type.clone());
161 let field_resolver = FieldResolver::new(
162 &e2e_config.fields,
163 &e2e_config.fields_optional,
164 &e2e_config.result_fields,
165 &e2e_config.fields_array,
166 &HashSet::new(),
167 );
168
169 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
175 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
176 .iter()
177 .filter_map(|td| {
178 let enum_field_names: HashSet<String> = td
179 .fields
180 .iter()
181 .filter(|field| is_enum_typed(&field.ty, &struct_names))
182 .map(|field| field.name.clone())
183 .collect();
184 if enum_field_names.is_empty() {
185 None
186 } else {
187 Some((td.name.clone(), enum_field_names))
188 }
189 })
190 .collect();
191
192 for group in groups {
193 let active: Vec<&Fixture> = group
194 .fixtures
195 .iter()
196 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
197 .collect();
198
199 if active.is_empty() {
200 continue;
201 }
202
203 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
204 let content = kotlin::render_test_file_android(
205 &group.category,
206 &active,
207 &class_name,
208 &function_name,
209 &kotlin_pkg_id,
210 result_var,
211 &e2e_config.call.args,
212 options_type.as_deref(),
213 &field_resolver,
214 result_is_simple,
215 &e2e_config.fields_enum,
216 e2e_config,
217 &type_enum_fields,
218 );
219 files.push(GeneratedFile {
220 path: test_base.join(&class_file_name),
221 content,
222 generated_header: true,
223 });
224
225 let mut android_test_base = output_base.join("src").join("androidTest").join("kotlin");
228 for segment in kotlin_pkg_id.split('.') {
229 android_test_base = android_test_base.join(segment);
230 }
231 let android_test_base = android_test_base.join("e2e");
232 files.push(GeneratedFile {
233 path: android_test_base.join(class_file_name),
234 content: render_android_instrumented_test(
235 &group.category,
236 &active,
237 &class_name,
238 &function_name,
239 &kotlin_pkg_id,
240 result_var,
241 &pkg_name,
242 ),
243 generated_header: true,
244 });
245 }
246
247 Ok(files)
248 }
249
250 fn language_name(&self) -> &'static str {
251 "kotlin_android"
252 }
253}
254
255fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
263 use alef_core::ir::TypeRef;
264 match ty {
265 TypeRef::Named(name) => !struct_names.contains(name.as_str()),
266 TypeRef::Optional(inner) => {
267 matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
268 }
269 _ => false,
270 }
271}
272
273fn render_build_gradle_kotlin_android(
284 _pkg_name: &str,
285 kotlin_pkg_id: &str,
286 _pkg_version: &str,
287 _dep_mode: crate::config::DependencyMode,
288 needs_mock_server: bool,
289) -> String {
290 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
291 let android_gradle_plugin = maven::ANDROID_GRADLE_PLUGIN;
292 let junit = maven::JUNIT;
293 let jackson = maven::JACKSON_E2E;
294 let jvm_target = if junit.starts_with("6.") {
299 "17"
300 } else {
301 toolchain::ANDROID_JVM_TARGET
302 };
303 let jna = maven::JNA;
304 let jspecify = maven::JSPECIFY;
305 let coroutines = maven::KOTLINX_COROUTINES_CORE;
306 let launcher_dep = if needs_mock_server {
307 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
308 } else {
309 String::new()
310 };
311
312 format!(
313 r#"import com.android.build.api.dsl.ManagedVirtualDevice
314import org.jetbrains.kotlin.gradle.dsl.JvmTarget
315
316plugins {{
317 id("com.android.library") version "{android_gradle_plugin}"
318 kotlin("android") version "{kotlin_plugin}"
319}}
320
321group = "{kotlin_pkg_id}"
322version = "0.1.0"
323
324android {{
325 namespace = "{kotlin_pkg_id}.e2e"
326 compileSdk = 35
327
328 defaultConfig {{
329 minSdk = 21
330 }}
331
332 compileOptions {{
333 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
334 targetCompatibility = JavaVersion.VERSION_{jvm_target}
335 }}
336
337 kotlinOptions {{
338 jvmTarget = "{jvm_target}"
339 }}
340
341 sourceSets {{
342 getByName("test") {{
343 // Include the AAR-bundled Java facade as test sources
344 java.srcDir("../../packages/kotlin-android/src/main/java")
345 // Include the AAR-bundled Kotlin wrapper as test sources
346 kotlin.srcDir("../../packages/kotlin-android/src/main/kotlin")
347 }}
348 }}
349
350 testOptions {{
351 // Gradle Managed Virtual Devices for on-device instrumented tests.
352 // Run: ./gradlew pixel6api34DebugAndroidTest
353 managedDevices {{
354 devices {{
355 create<ManagedVirtualDevice>("pixel6api34") {{
356 device = "Pixel 6"
357 apiLevel = 34
358 systemImageSource = "aosp"
359 }}
360 }}
361 }}
362 }}
363}}
364
365repositories {{
366 mavenCentral()
367 google()
368}}
369
370dependencies {{
371 // JNA for loading the native library from java.library.path
372 testImplementation("net.java.dev.jna:jna:{jna}")
373
374 // Jackson for JSON assertion helpers
375 testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
376 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
377 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
378
379 // jackson-module-kotlin registers constructors/properties for Kotlin data
380 // classes, which have no default constructor and cannot be deserialized by
381 // plain Jackson without this module.
382 testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:{jackson}")
383
384 // jspecify for null-safety annotations on wrapped types
385 testImplementation("org.jspecify:jspecify:{jspecify}")
386
387 // Kotlin coroutines for async test helpers
388 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")
389
390 // JUnit 5 API and engine
391 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
392 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
393{launcher_dep}
394
395 // Kotlin stdlib test helpers
396 testImplementation(kotlin("test"))
397}}
398
399tasks.withType<Test> {{
400 useJUnitPlatform()
401
402 // Resolve the native library location (e.g., ../../target/release)
403 val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
404 systemProperty("java.library.path", libPath)
405 systemProperty("jna.library.path", libPath)
406
407 // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/
408 workingDir = file("${{rootDir}}/../../test_documents")
409}}
410"#
411 )
412}
413
414fn render_settings_gradle_kotlin_android(pkg_name: &str) -> String {
420 format!(
421 r#"// Generated by alef. Do not edit by hand.
422
423pluginManagement {{
424 repositories {{
425 google()
426 mavenCentral()
427 gradlePluginPortal()
428 }}
429}}
430
431dependencyResolutionManagement {{
432 repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
433 repositories {{
434 google()
435 mavenCentral()
436 }}
437}}
438
439rootProject.name = "{pkg_name}-e2e"
440"#
441 )
442}
443
444fn render_android_instrumented_test(
450 category: &str,
451 fixtures: &[&crate::fixture::Fixture],
452 class_name: &str,
453 function_name: &str,
454 kotlin_pkg_id: &str,
455 result_var: &str,
456 lib_name: &str,
457) -> String {
458 let test_class = format!("{}Test", category.to_upper_camel_case());
459 let lib_snake = lib_name.replace('-', "_");
460 let mut out = String::new();
461 out.push_str(&format!("package {kotlin_pkg_id}.e2e\n\n"));
462 out.push_str("import androidx.test.ext.junit.runners.AndroidJUnit4\n");
463 out.push_str("import org.junit.BeforeClass\n");
464 out.push_str("import org.junit.Test\n");
465 out.push_str("import org.junit.runner.RunWith\n\n");
466 out.push_str("@RunWith(AndroidJUnit4::class)\n");
467 out.push_str(&format!("class {test_class} {{\n\n"));
468 out.push_str(" companion object {\n");
469 out.push_str(" @BeforeClass\n");
470 out.push_str(" @JvmStatic\n");
471 out.push_str(" fun loadNativeLibrary() {\n");
472 out.push_str(&format!(" System.loadLibrary(\"{lib_snake}_jni\")\n"));
473 out.push_str(" }\n");
474 out.push_str(" }\n\n");
475 for fixture in fixtures {
476 let test_name = fixture.id.replace(['-', '.', ' '], "_");
477 out.push_str(" @Test\n");
478 out.push_str(&format!(" fun test_{test_name}() {{\n"));
479 out.push_str(&format!(" val client = {class_name}()\n"));
480 out.push_str(&format!(
481 " val {result_var} = client.{function_name}(/* fixture: {} */)\n",
482 fixture.id
483 ));
484 out.push_str(&format!(" // TODO: assert {result_var} is not an error\n"));
485 out.push_str(" }\n\n");
486 }
487 out.push_str("}\n");
488 out
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
501 fn build_gradle_kotlin_android_includes_jackson_module_kotlin() {
502 let output = render_build_gradle_kotlin_android(
503 "liter-llm",
504 "dev.kreuzberg.literllm.android",
505 "1.0.0",
506 crate::config::DependencyMode::Local,
507 false,
508 );
509 assert!(
510 output.contains("jackson-module-kotlin"),
511 "build.gradle.kts must depend on jackson-module-kotlin, got:\n{output}"
512 );
513 }
514
515 #[test]
520 fn settings_gradle_kotlin_android_declares_plugin_repositories() {
521 let output = render_settings_gradle_kotlin_android("liter-llm");
522 assert!(
523 output.contains("pluginManagement"),
524 "settings.gradle.kts must declare pluginManagement block, got:\n{output}"
525 );
526 assert!(
527 output.contains("google()"),
528 "pluginManagement repositories must include google(), got:\n{output}"
529 );
530 assert!(
531 output.contains("gradlePluginPortal()"),
532 "pluginManagement repositories must include gradlePluginPortal(), got:\n{output}"
533 );
534 assert!(
535 output.contains("rootProject.name = \"liter-llm-e2e\""),
536 "rootProject.name must be derived from pkg_name, got:\n{output}"
537 );
538 }
539}