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 let mut test_base = output_base.join("src").join("test").join("kotlin");
124 for segment in kotlin_pkg_id.split('.') {
125 test_base = test_base.join(segment);
126 }
127 let test_base = test_base.join("e2e");
128
129 if needs_mock_server {
130 files.push(GeneratedFile {
131 path: test_base.join("MockServerListener.kt"),
132 content: kotlin::render_mock_server_listener_kt(&kotlin_pkg_id),
133 generated_header: true,
134 });
135 files.push(GeneratedFile {
136 path: output_base
137 .join("src")
138 .join("test")
139 .join("resources")
140 .join("META-INF")
141 .join("services")
142 .join("org.junit.platform.launcher.LauncherSessionListener"),
143 content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
144 generated_header: false,
145 });
146 }
147
148 let options_type = overrides.and_then(|o| o.options_type.clone());
150 let field_resolver = FieldResolver::new(
151 &e2e_config.fields,
152 &e2e_config.fields_optional,
153 &e2e_config.result_fields,
154 &e2e_config.fields_array,
155 &HashSet::new(),
156 );
157
158 let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
164 let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
165 .iter()
166 .filter_map(|td| {
167 let enum_field_names: HashSet<String> = td
168 .fields
169 .iter()
170 .filter(|field| is_enum_typed(&field.ty, &struct_names))
171 .map(|field| field.name.clone())
172 .collect();
173 if enum_field_names.is_empty() {
174 None
175 } else {
176 Some((td.name.clone(), enum_field_names))
177 }
178 })
179 .collect();
180
181 for group in groups {
182 let active: Vec<&Fixture> = group
183 .fixtures
184 .iter()
185 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
186 .collect();
187
188 if active.is_empty() {
189 continue;
190 }
191
192 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
193 let content = kotlin::render_test_file_android(
194 &group.category,
195 &active,
196 &class_name,
197 &function_name,
198 &kotlin_pkg_id,
199 result_var,
200 &e2e_config.call.args,
201 options_type.as_deref(),
202 &field_resolver,
203 result_is_simple,
204 &e2e_config.fields_enum,
205 e2e_config,
206 &type_enum_fields,
207 );
208 files.push(GeneratedFile {
209 path: test_base.join(&class_file_name),
210 content,
211 generated_header: true,
212 });
213
214 let mut android_test_base = output_base.join("src").join("androidTest").join("kotlin");
217 for segment in kotlin_pkg_id.split('.') {
218 android_test_base = android_test_base.join(segment);
219 }
220 let android_test_base = android_test_base.join("e2e");
221 files.push(GeneratedFile {
222 path: android_test_base.join(class_file_name),
223 content: render_android_instrumented_test(
224 &group.category,
225 &active,
226 &class_name,
227 &function_name,
228 &kotlin_pkg_id,
229 result_var,
230 &pkg_name,
231 ),
232 generated_header: true,
233 });
234 }
235
236 Ok(files)
237 }
238
239 fn language_name(&self) -> &'static str {
240 "kotlin_android"
241 }
242}
243
244fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
252 use alef_core::ir::TypeRef;
253 match ty {
254 TypeRef::Named(name) => !struct_names.contains(name.as_str()),
255 TypeRef::Optional(inner) => {
256 matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
257 }
258 _ => false,
259 }
260}
261
262fn render_build_gradle_kotlin_android(
273 _pkg_name: &str,
274 kotlin_pkg_id: &str,
275 _pkg_version: &str,
276 _dep_mode: crate::config::DependencyMode,
277 needs_mock_server: bool,
278) -> String {
279 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
280 let android_gradle_plugin = maven::ANDROID_GRADLE_PLUGIN;
281 let junit = maven::JUNIT;
282 let jackson = maven::JACKSON_E2E;
283 let jvm_target = if junit.starts_with("6.") {
288 "17"
289 } else {
290 toolchain::ANDROID_JVM_TARGET
291 };
292 let jna = maven::JNA;
293 let jspecify = maven::JSPECIFY;
294 let coroutines = maven::KOTLINX_COROUTINES_CORE;
295 let launcher_dep = if needs_mock_server {
296 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
297 } else {
298 String::new()
299 };
300
301 format!(
302 r#"import com.android.build.api.dsl.ManagedVirtualDevice
303import org.jetbrains.kotlin.gradle.dsl.JvmTarget
304
305plugins {{
306 id("com.android.library") version "{android_gradle_plugin}"
307 kotlin("android") version "{kotlin_plugin}"
308}}
309
310group = "{kotlin_pkg_id}"
311version = "0.1.0"
312
313android {{
314 namespace = "{kotlin_pkg_id}.e2e"
315 compileSdk = 35
316
317 defaultConfig {{
318 minSdk = 21
319 }}
320
321 compileOptions {{
322 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
323 targetCompatibility = JavaVersion.VERSION_{jvm_target}
324 }}
325
326 kotlinOptions {{
327 jvmTarget = "{jvm_target}"
328 }}
329
330 sourceSets {{
331 getByName("test") {{
332 // Include the AAR-bundled Java facade as test sources
333 java.srcDir("../../packages/kotlin-android/src/main/java")
334 // Include the AAR-bundled Kotlin wrapper as test sources
335 kotlin.srcDir("../../packages/kotlin-android/src/main/kotlin")
336 }}
337 }}
338
339 testOptions {{
340 // Gradle Managed Virtual Devices for on-device instrumented tests.
341 // Run: ./gradlew pixel6api34DebugAndroidTest
342 managedDevices {{
343 devices {{
344 create<ManagedVirtualDevice>("pixel6api34") {{
345 device = "Pixel 6"
346 apiLevel = 34
347 systemImageSource = "aosp"
348 }}
349 }}
350 }}
351 }}
352}}
353
354repositories {{
355 mavenCentral()
356 google()
357}}
358
359dependencies {{
360 // JNA for loading the native library from java.library.path
361 testImplementation("net.java.dev.jna:jna:{jna}")
362
363 // Jackson for JSON assertion helpers
364 testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
365 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
366 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
367
368 // jackson-module-kotlin registers constructors/properties for Kotlin data
369 // classes, which have no default constructor and cannot be deserialized by
370 // plain Jackson without this module.
371 testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:{jackson}")
372
373 // jspecify for null-safety annotations on wrapped types
374 testImplementation("org.jspecify:jspecify:{jspecify}")
375
376 // Kotlin coroutines for async test helpers
377 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")
378
379 // JUnit 5 API and engine
380 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
381 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
382{launcher_dep}
383
384 // Kotlin stdlib test helpers
385 testImplementation(kotlin("test"))
386}}
387
388tasks.withType<Test> {{
389 useJUnitPlatform()
390
391 // Resolve the native library location (e.g., ../../target/release)
392 val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
393 systemProperty("java.library.path", libPath)
394 systemProperty("jna.library.path", libPath)
395
396 // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/
397 workingDir = file("${{rootDir}}/../../test_documents")
398}}
399"#
400 )
401}
402
403fn render_android_instrumented_test(
409 category: &str,
410 fixtures: &[&crate::fixture::Fixture],
411 class_name: &str,
412 function_name: &str,
413 kotlin_pkg_id: &str,
414 result_var: &str,
415 lib_name: &str,
416) -> String {
417 let test_class = format!("{}Test", category.to_upper_camel_case());
418 let lib_snake = lib_name.replace('-', "_");
419 let mut out = String::new();
420 out.push_str(&format!("package {kotlin_pkg_id}.e2e\n\n"));
421 out.push_str("import androidx.test.ext.junit.runners.AndroidJUnit4\n");
422 out.push_str("import org.junit.BeforeClass\n");
423 out.push_str("import org.junit.Test\n");
424 out.push_str("import org.junit.runner.RunWith\n\n");
425 out.push_str("@RunWith(AndroidJUnit4::class)\n");
426 out.push_str(&format!("class {test_class} {{\n\n"));
427 out.push_str(" companion object {\n");
428 out.push_str(" @BeforeClass\n");
429 out.push_str(" @JvmStatic\n");
430 out.push_str(" fun loadNativeLibrary() {\n");
431 out.push_str(&format!(" System.loadLibrary(\"{lib_snake}_jni\")\n"));
432 out.push_str(" }\n");
433 out.push_str(" }\n\n");
434 for fixture in fixtures {
435 let test_name = fixture.id.replace(['-', '.', ' '], "_");
436 out.push_str(" @Test\n");
437 out.push_str(&format!(" fun test_{test_name}() {{\n"));
438 out.push_str(&format!(" val client = {class_name}()\n"));
439 out.push_str(&format!(
440 " val {result_var} = client.{function_name}(/* fixture: {} */)\n",
441 fixture.id
442 ));
443 out.push_str(&format!(" // TODO: assert {result_var} is not an error\n"));
444 out.push_str(" }\n\n");
445 }
446 out.push_str("}\n");
447 out
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
460 fn build_gradle_kotlin_android_includes_jackson_module_kotlin() {
461 let output = render_build_gradle_kotlin_android(
462 "liter-llm",
463 "dev.kreuzberg.literllm.android",
464 "1.0.0",
465 crate::config::DependencyMode::Local,
466 false,
467 );
468 assert!(
469 output.contains("jackson-module-kotlin"),
470 "build.gradle.kts must depend on jackson-module-kotlin, got:\n{output}"
471 );
472 }
473}