Skip to main content

alef_e2e/codegen/
kotlin_android.rs

1//! Kotlin Android e2e test generator using kotlin.test and JUnit 5.
2//!
3//! Generates host-JVM tests that validate the AAR-bundled Java facade and Kotlin wrapper
4//! via JNA against libkreuzberg_ffi. Tests are emitted to `e2e/kotlin_android/src/test/kotlin/`
5//! without requiring an Android emulator — the tests run directly on the host JVM against
6//! the shared library.
7
8use 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
23/// Kotlin Android e2e code generator.
24/// Emits a host-JVM test project that depends on the AAR-bundled Java facade
25/// and Kotlin wrapper via sourceSets and JNA, without requiring an Android emulator.
26pub 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        // Resolve call config with overrides.
43        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        // Resolve package config.
61        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        // Resolve Kotlin package for generated tests.
69        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        // Use the kotlin_android crate's `package` config — not the generic
80        // `config.kotlin_package()` accessor — so the generated tests live in
81        // the same JVM package as the AAR's emitted types and can reference
82        // them by simple name. `kotlin_package()` falls back to a
83        // `com.github.<org>` derivation from the GitHub URL when
84        // `[crates.kotlin] package` is absent, which produces a package
85        // mismatch for AAR consumers that only configure
86        // `[crates.kotlin_android] package`.
87        //
88        // Precedence: `[crates.e2e.packages.kotlin_android].module` (explicit
89        // override) > `[crates.kotlin_android].package` > derived fallback
90        // via `config.kotlin_package()`.
91        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        // Detect whether any fixture needs the mock-server (HTTP fixtures or
98        // fixtures with a mock_response/mock_responses). When present, emit a
99        // JUnit Platform LauncherSessionListener that spawns the mock-server
100        // before any test runs and a META-INF/services SPI manifest registering
101        // it. Mirrors the Kotlin/JVM e2e pattern exactly.
102        let needs_mock_server = groups
103            .iter()
104            .flat_map(|g| g.fixtures.iter())
105            .any(|f| f.needs_mock_server());
106
107        // Generate build.gradle.kts for the host JVM project.
108        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        // Generate settings.gradle.kts so Gradle can resolve the AGP
121        // (`com.android.library`) plugin from google()/gradlePluginPortal().
122        // Without this file the e2e project fails at configuration time with
123        // `Plugin [id: 'com.android.library'] was not found in any of the
124        // following sources`.
125        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        // Generate test files per category. Path mirrors the configured Kotlin
132        // package so the package declaration in each test file matches its
133        // filesystem location.
134        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        // Resolve options_type from override.
160        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        // Build a map from TypeDef name → set of field names whose Rust type
170        // is a `Named(T)` reference where `T` is NOT itself a known struct.
171        // Those fields are enum-typed and should route through `.getValue()` in
172        // generated assertions automatically, even without an explicit per-call
173        // `enum_fields` override in the alef.toml.
174        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            // Instrumented Android test for on-device emulator runs.
226            // Lives in src/androidTest/ and uses @RunWith(AndroidJUnit4::class).
227            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
255// ---------------------------------------------------------------------------
256// Helpers
257// ---------------------------------------------------------------------------
258
259/// Returns true when `ty` is a `Named(T)` reference (or `Optional<Named(T)>`)
260/// where `T` is **not** a known struct name. Such fields are enum-typed and
261/// must route through `.getValue()` in generated assertions.
262fn 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
273// ---------------------------------------------------------------------------
274// Rendering
275// ---------------------------------------------------------------------------
276
277/// Render build.gradle.kts for the kotlin_android e2e project.
278///
279/// This is an Android library project (applies `com.android.library`) so that
280/// the `android { }` DSL — including Gradle Managed Devices — resolves at
281/// Kotlin script compile time. The host-JVM test sources live in
282/// `src/test/kotlin/` and run against the shared native library via JNA.
283fn 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    // E2E tests run on the host JVM (not Android), so pick a target that
295    // matches the JUnit Jupiter baseline (5.x → JVM 11, 6.x → JVM 17). The
296    // Android library itself still ships at ANDROID_JVM_TARGET for runtime
297    // compat; this only affects the host-side gradle test project.
298    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    sourceSets {{
338        getByName("test") {{
339            // Include the AAR-bundled Java facade as test sources
340            java.srcDir("../../packages/kotlin-android/src/main/java")
341            // Include the AAR-bundled Kotlin wrapper as test sources
342            kotlin.srcDir("../../packages/kotlin-android/src/main/kotlin")
343        }}
344    }}
345
346    testOptions {{
347        // Gradle Managed Virtual Devices for on-device instrumented tests.
348        // Run: ./gradlew pixel6api34DebugAndroidTest
349        managedDevices {{
350            devices {{
351                create<ManagedVirtualDevice>("pixel6api34") {{
352                    device = "Pixel 6"
353                    apiLevel = 34
354                    systemImageSource = "aosp"
355                }}
356            }}
357        }}
358    }}
359}}
360
361kotlin {{
362    compilerOptions {{
363        jvmTarget = JvmTarget.JVM_{jvm_target}
364    }}
365}}
366
367// Repositories declared in settings.gradle.kts via
368// dependencyResolutionManagement (FAIL_ON_PROJECT_REPOS). Re-declaring them
369// here triggers Gradle "repository was added by build file" errors.
370
371dependencies {{
372    // JNA for loading the native library from java.library.path
373    testImplementation("net.java.dev.jna:jna:{jna}")
374
375    // Jackson for JSON assertion helpers
376    testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
377    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
378    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
379
380    // jackson-module-kotlin registers constructors/properties for Kotlin data
381    // classes, which have no default constructor and cannot be deserialized by
382    // plain Jackson without this module.
383    testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:{jackson}")
384
385    // jspecify for null-safety annotations on wrapped types
386    testImplementation("org.jspecify:jspecify:{jspecify}")
387
388    // Kotlin coroutines for async test helpers
389    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")
390
391    // JUnit 5 API and engine
392    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
393    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
394{launcher_dep}
395
396    // Kotlin stdlib test helpers
397    testImplementation(kotlin("test"))
398}}
399
400tasks.withType<Test> {{
401    useJUnitPlatform()
402
403    // Resolve the native library location (e.g., ../../target/release)
404    val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
405    systemProperty("java.library.path", libPath)
406    systemProperty("jna.library.path", libPath)
407
408    // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/
409    workingDir = file("${{rootDir}}/../../test_documents")
410}}
411"#
412    )
413}
414
415/// Render `settings.gradle.kts` for the kotlin_android e2e project.
416///
417/// Declares the plugin and dependency repositories Gradle needs to resolve
418/// `com.android.library` (and Kotlin/Android transitive deps). Mirrors the
419/// AAR-side settings emitter at `alef-backend-kotlin-android::gen_settings_gradle`.
420fn render_settings_gradle_kotlin_android(pkg_name: &str) -> String {
421    format!(
422        r#"// Generated by alef. Do not edit by hand.
423
424pluginManagement {{
425    repositories {{
426        google()
427        mavenCentral()
428        gradlePluginPortal()
429    }}
430}}
431
432dependencyResolutionManagement {{
433    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
434    repositories {{
435        google()
436        mavenCentral()
437    }}
438}}
439
440rootProject.name = "{pkg_name}-e2e"
441"#
442    )
443}
444
445/// Render an Android instrumented test class for a fixture group.
446///
447/// The generated class uses `@RunWith(AndroidJUnit4::class)` and loads the
448/// native library via `System.loadLibrary` so tests can run on-device via the
449/// Android emulator.
450fn render_android_instrumented_test(
451    category: &str,
452    fixtures: &[&crate::fixture::Fixture],
453    class_name: &str,
454    function_name: &str,
455    kotlin_pkg_id: &str,
456    result_var: &str,
457    lib_name: &str,
458) -> String {
459    let test_class = format!("{}Test", category.to_upper_camel_case());
460    let lib_snake = lib_name.replace('-', "_");
461    let mut out = String::new();
462    out.push_str(&format!("package {kotlin_pkg_id}.e2e\n\n"));
463    out.push_str("import androidx.test.ext.junit.runners.AndroidJUnit4\n");
464    out.push_str("import org.junit.BeforeClass\n");
465    out.push_str("import org.junit.Test\n");
466    out.push_str("import org.junit.runner.RunWith\n\n");
467    out.push_str("@RunWith(AndroidJUnit4::class)\n");
468    out.push_str(&format!("class {test_class} {{\n\n"));
469    out.push_str("    companion object {\n");
470    out.push_str("        @BeforeClass\n");
471    out.push_str("        @JvmStatic\n");
472    out.push_str("        fun loadNativeLibrary() {\n");
473    out.push_str(&format!("            System.loadLibrary(\"{lib_snake}_jni\")\n"));
474    out.push_str("        }\n");
475    out.push_str("    }\n\n");
476    for fixture in fixtures {
477        let test_name = fixture.id.replace(['-', '.', ' '], "_");
478        out.push_str("    @Test\n");
479        out.push_str(&format!("    fun test_{test_name}() {{\n"));
480        out.push_str(&format!("        val client = {class_name}()\n"));
481        out.push_str(&format!(
482            "        val {result_var} = client.{function_name}(/* fixture: {} */)\n",
483            fixture.id
484        ));
485        out.push_str(&format!("        // TODO: assert {result_var} is not an error\n"));
486        out.push_str("    }\n\n");
487    }
488    out.push_str("}\n");
489    out
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    /// Regression: the kotlin-android build.gradle.kts must declare
497    /// `jackson-module-kotlin` so that Jackson can deserialize Kotlin data
498    /// classes (which have no default constructor).  Without it, any test that
499    /// calls `MAPPER.readValue(...)` against a Kotlin data class throws
500    /// `InvalidDefinitionException: No suitable constructor found`.
501    #[test]
502    fn build_gradle_kotlin_android_includes_jackson_module_kotlin() {
503        let output = render_build_gradle_kotlin_android(
504            "liter-llm",
505            "dev.kreuzberg.literllm.android",
506            "1.0.0",
507            crate::config::DependencyMode::Local,
508            false,
509        );
510        assert!(
511            output.contains("jackson-module-kotlin"),
512            "build.gradle.kts must depend on jackson-module-kotlin, got:\n{output}"
513        );
514    }
515
516    /// Regression: the e2e settings.gradle.kts must declare the
517    /// `pluginManagement` block with `google()` and `gradlePluginPortal()` so
518    /// Gradle can resolve `com.android.library`. Missing settings.gradle.kts
519    /// causes `Plugin [id: 'com.android.library'] was not found` at config time.
520    #[test]
521    fn settings_gradle_kotlin_android_declares_plugin_repositories() {
522        let output = render_settings_gradle_kotlin_android("liter-llm");
523        assert!(
524            output.contains("pluginManagement"),
525            "settings.gradle.kts must declare pluginManagement block, got:\n{output}"
526        );
527        assert!(
528            output.contains("google()"),
529            "pluginManagement repositories must include google(), got:\n{output}"
530        );
531        assert!(
532            output.contains("gradlePluginPortal()"),
533            "pluginManagement repositories must include gradlePluginPortal(), got:\n{output}"
534        );
535        assert!(
536            output.contains("rootProject.name = \"liter-llm-e2e\""),
537            "rootProject.name must be derived from pkg_name, got:\n{output}"
538        );
539    }
540}