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::fixture::{Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::ResolvedCrateConfig;
13use alef_core::template_versions::{maven, toolchain};
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashSet;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20use super::kotlin;
21
22/// Kotlin Android e2e code generator.
23/// Emits a host-JVM test project that depends on the AAR-bundled Java facade
24/// and Kotlin wrapper via sourceSets and JNA, without requiring an Android emulator.
25pub struct KotlinAndroidE2eCodegen;
26
27impl E2eCodegen for KotlinAndroidE2eCodegen {
28    fn generate(
29        &self,
30        groups: &[FixtureGroup],
31        e2e_config: &E2eConfig,
32        config: &ResolvedCrateConfig,
33        type_defs: &[alef_core::ir::TypeDef],
34        _enums: &[alef_core::ir::EnumDef],
35    ) -> Result<Vec<GeneratedFile>> {
36        let lang = self.language_name();
37        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
38
39        let mut files = Vec::new();
40
41        // Resolve call config with overrides.
42        let call = &e2e_config.call;
43        let overrides = call.overrides.get(lang);
44        let _module_path = overrides
45            .and_then(|o| o.module.as_ref())
46            .cloned()
47            .unwrap_or_else(|| call.module.clone());
48        let function_name = overrides
49            .and_then(|o| o.function.as_ref())
50            .cloned()
51            .unwrap_or_else(|| call.function.clone());
52        let class_name = overrides
53            .and_then(|o| o.class.as_ref())
54            .cloned()
55            .unwrap_or_else(|| config.name.to_upper_camel_case());
56        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
57        let result_var = &call.result_var;
58
59        // Resolve package config.
60        let kotlin_android_pkg = e2e_config.resolve_package("kotlin_android");
61        let pkg_name = kotlin_android_pkg
62            .as_ref()
63            .and_then(|p| p.name.as_ref())
64            .cloned()
65            .unwrap_or_else(|| config.name.clone());
66
67        // Resolve Kotlin package for generated tests.
68        let _kotlin_android_pkg_path = kotlin_android_pkg
69            .as_ref()
70            .and_then(|p| p.path.as_deref())
71            .unwrap_or("../../packages/kotlin-android");
72        let kotlin_android_version = kotlin_android_pkg
73            .as_ref()
74            .and_then(|p| p.version.as_ref())
75            .cloned()
76            .or_else(|| config.resolved_version())
77            .unwrap_or_else(|| "0.1.0".to_string());
78        // Use the kotlin_android crate's `package` config — not the generic
79        // `config.kotlin_package()` accessor — so the generated tests live in
80        // the same JVM package as the AAR's emitted types and can reference
81        // them by simple name. `kotlin_package()` falls back to a
82        // `com.github.<org>` derivation from the GitHub URL when
83        // `[crates.kotlin] package` is absent, which produces a package
84        // mismatch for AAR consumers that only configure
85        // `[crates.kotlin_android] package`.
86        //
87        // Precedence: `[crates.e2e.packages.kotlin_android].module` (explicit
88        // override) > `[crates.kotlin_android].package` > derived fallback
89        // via `config.kotlin_package()`.
90        let kotlin_pkg_id = kotlin_android_pkg
91            .as_ref()
92            .and_then(|p| p.module.clone())
93            .or_else(|| config.kotlin_android.as_ref().and_then(|c| c.package.clone()))
94            .unwrap_or_else(|| config.kotlin_package());
95
96        // Detect whether any fixture needs the mock-server (HTTP fixtures or
97        // fixtures with a mock_response/mock_responses). When present, emit a
98        // JUnit Platform LauncherSessionListener that spawns the mock-server
99        // before any test runs and a META-INF/services SPI manifest registering
100        // it. Mirrors the Kotlin/JVM e2e pattern exactly.
101        let needs_mock_server = groups
102            .iter()
103            .flat_map(|g| g.fixtures.iter())
104            .any(|f| f.needs_mock_server());
105
106        // Generate build.gradle.kts for the host JVM project.
107        files.push(GeneratedFile {
108            path: output_base.join("build.gradle.kts"),
109            content: render_build_gradle_kotlin_android(
110                &pkg_name,
111                &kotlin_pkg_id,
112                &kotlin_android_version,
113                e2e_config.dep_mode,
114                needs_mock_server,
115            ),
116            generated_header: false,
117        });
118
119        // Generate settings.gradle.kts so Gradle can resolve the AGP
120        // (`com.android.library`) plugin from google()/gradlePluginPortal().
121        // Without this file the e2e project fails at configuration time with
122        // `Plugin [id: 'com.android.library'] was not found in any of the
123        // following sources`.
124        files.push(GeneratedFile {
125            path: output_base.join("settings.gradle.kts"),
126            content: render_settings_gradle_kotlin_android(&pkg_name),
127            generated_header: false,
128        });
129
130        // Generate test files per category. Path mirrors the configured Kotlin
131        // package so the package declaration in each test file matches its
132        // filesystem location.
133        let mut test_base = output_base.join("src").join("test").join("kotlin");
134        for segment in kotlin_pkg_id.split('.') {
135            test_base = test_base.join(segment);
136        }
137        let test_base = test_base.join("e2e");
138
139        if needs_mock_server {
140            files.push(GeneratedFile {
141                path: test_base.join("MockServerListener.kt"),
142                content: kotlin::render_mock_server_listener_kt(&kotlin_pkg_id),
143                generated_header: true,
144            });
145            files.push(GeneratedFile {
146                path: output_base
147                    .join("src")
148                    .join("test")
149                    .join("resources")
150                    .join("META-INF")
151                    .join("services")
152                    .join("org.junit.platform.launcher.LauncherSessionListener"),
153                content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
154                generated_header: false,
155            });
156        }
157
158        // Resolve options_type from override.
159        let options_type = overrides.and_then(|o| o.options_type.clone());
160
161        // Build a map from TypeDef name → set of field names whose Rust type
162        // is a `Named(T)` reference where `T` is NOT itself a known struct.
163        // Those fields are enum-typed and should route through `.getValue()` in
164        // generated assertions automatically, even without an explicit per-call
165        // `enum_fields` override in the alef.toml.
166        let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
167        let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
168            .iter()
169            .filter_map(|td| {
170                let enum_field_names: HashSet<String> = td
171                    .fields
172                    .iter()
173                    .filter(|field| is_enum_typed(&field.ty, &struct_names))
174                    .map(|field| field.name.clone())
175                    .collect();
176                if enum_field_names.is_empty() {
177                    None
178                } else {
179                    Some((td.name.clone(), enum_field_names))
180                }
181            })
182            .collect();
183
184        // kotlin_android lacks a JNI trait-handle bridge (see alef-backend-jni TODO), so
185        // [crates.kotlin_android] excludes the visitor function. Fixtures whose payload uses
186        // a visitor cannot be exercised through this binding — emitting them produces tests
187        // that call convert(html, null) and then assert on visitor-transformed output, which
188        // always fails. Skip any visitor-using fixture for kotlin_android.
189        for group in groups {
190            let active: Vec<&Fixture> = group
191                .fixtures
192                .iter()
193                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
194                .filter(|f| f.visitor.is_none())
195                .collect();
196
197            if active.is_empty() {
198                continue;
199            }
200
201            let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
202            let content = kotlin::render_test_file_android(
203                &group.category,
204                &active,
205                &class_name,
206                &function_name,
207                &kotlin_pkg_id,
208                result_var,
209                &e2e_config.call.args,
210                options_type.as_deref(),
211                result_is_simple,
212                e2e_config,
213                &type_enum_fields,
214            );
215            files.push(GeneratedFile {
216                path: test_base.join(&class_file_name),
217                content,
218                generated_header: true,
219            });
220
221            // Instrumented Android test for on-device emulator runs.
222            // Lives in src/androidTest/ and uses @RunWith(AndroidJUnit4::class).
223            let mut android_test_base = output_base.join("src").join("androidTest").join("kotlin");
224            for segment in kotlin_pkg_id.split('.') {
225                android_test_base = android_test_base.join(segment);
226            }
227            let android_test_base = android_test_base.join("e2e");
228            files.push(GeneratedFile {
229                path: android_test_base.join(class_file_name),
230                content: render_android_instrumented_test(
231                    &group.category,
232                    &active,
233                    &class_name,
234                    &function_name,
235                    &kotlin_pkg_id,
236                    result_var,
237                    &pkg_name,
238                ),
239                generated_header: true,
240            });
241        }
242
243        Ok(files)
244    }
245
246    fn language_name(&self) -> &'static str {
247        "kotlin_android"
248    }
249}
250
251// ---------------------------------------------------------------------------
252// Helpers
253// ---------------------------------------------------------------------------
254
255/// Returns true when `ty` is a `Named(T)` reference (or `Optional<Named(T)>`)
256/// where `T` is **not** a known struct name. Such fields are enum-typed and
257/// must route through `.getValue()` in generated assertions.
258fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
259    use alef_core::ir::TypeRef;
260    match ty {
261        TypeRef::Named(name) => !struct_names.contains(name.as_str()),
262        TypeRef::Optional(inner) => {
263            matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
264        }
265        _ => false,
266    }
267}
268
269// ---------------------------------------------------------------------------
270// Rendering
271// ---------------------------------------------------------------------------
272
273/// Render build.gradle.kts for the kotlin_android e2e project.
274///
275/// This is an Android library project (applies `com.android.library`) so that
276/// the `android { }` DSL — including Gradle Managed Devices — resolves at
277/// Kotlin script compile time. The host-JVM test sources live in
278/// `src/test/kotlin/` and run against the shared native library via JNA.
279fn render_build_gradle_kotlin_android(
280    _pkg_name: &str,
281    kotlin_pkg_id: &str,
282    _pkg_version: &str,
283    _dep_mode: crate::config::DependencyMode,
284    needs_mock_server: bool,
285) -> String {
286    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
287    let android_gradle_plugin = maven::ANDROID_GRADLE_PLUGIN;
288    let junit = maven::JUNIT;
289    let jackson = maven::JACKSON_E2E;
290    // E2E tests run on the host JVM (not Android), so pick a target that
291    // matches the JUnit Jupiter baseline (5.x → JVM 11, 6.x → JVM 17). The
292    // Android library itself still ships at ANDROID_JVM_TARGET for runtime
293    // compat; this only affects the host-side gradle test project.
294    let jvm_target = if junit.starts_with("6.") {
295        "17"
296    } else {
297        toolchain::ANDROID_JVM_TARGET
298    };
299    let jna = maven::JNA;
300    let jspecify = maven::JSPECIFY;
301    let coroutines = maven::KOTLINX_COROUTINES_CORE;
302    let launcher_dep = if needs_mock_server {
303        format!(r#"    testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
304    } else {
305        String::new()
306    };
307
308    format!(
309        r#"import com.android.build.api.dsl.ManagedVirtualDevice
310import org.jetbrains.kotlin.gradle.dsl.JvmTarget
311
312plugins {{
313    id("com.android.library") version "{android_gradle_plugin}"
314    kotlin("android") version "{kotlin_plugin}"
315}}
316
317group = "{kotlin_pkg_id}"
318version = "0.1.0"
319
320android {{
321    namespace = "{kotlin_pkg_id}.e2e"
322    compileSdk = 35
323
324    defaultConfig {{
325        minSdk = 21
326    }}
327
328    compileOptions {{
329        sourceCompatibility = JavaVersion.VERSION_{jvm_target}
330        targetCompatibility = JavaVersion.VERSION_{jvm_target}
331    }}
332
333    sourceSets {{
334        getByName("test") {{
335            // Include the AAR-bundled Java facade as test sources
336            java.srcDir("../../packages/kotlin-android/src/main/java")
337            // Include the AAR-bundled Kotlin wrapper as test sources
338            kotlin.srcDir("../../packages/kotlin-android/src/main/kotlin")
339        }}
340    }}
341
342    testOptions {{
343        // Gradle Managed Virtual Devices for on-device instrumented tests.
344        // Run: ./gradlew pixel6api34DebugAndroidTest
345        managedDevices {{
346            devices {{
347                create<ManagedVirtualDevice>("pixel6api34") {{
348                    device = "Pixel 6"
349                    apiLevel = 34
350                    systemImageSource = "aosp"
351                }}
352            }}
353        }}
354    }}
355}}
356
357kotlin {{
358    compilerOptions {{
359        jvmTarget = JvmTarget.JVM_{jvm_target}
360    }}
361}}
362
363// Repositories declared in settings.gradle.kts via
364// dependencyResolutionManagement (FAIL_ON_PROJECT_REPOS). Re-declaring them
365// here triggers Gradle "repository was added by build file" errors.
366
367dependencies {{
368    // JNA for loading the native library from java.library.path
369    testImplementation("net.java.dev.jna:jna:{jna}")
370
371    // Jackson for JSON assertion helpers
372    testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
373    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
374    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
375
376    // jackson-module-kotlin registers constructors/properties for Kotlin data
377    // classes, which have no default constructor and cannot be deserialized by
378    // plain Jackson without this module.
379    testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:{jackson}")
380
381    // jspecify for null-safety annotations on wrapped types
382    testImplementation("org.jspecify:jspecify:{jspecify}")
383
384    // Kotlin coroutines for async test helpers
385    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")
386
387    // JUnit 5 API and engine
388    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
389    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
390{launcher_dep}
391
392    // Kotlin stdlib test helpers
393    testImplementation(kotlin("test"))
394}}
395
396tasks.withType<Test> {{
397    useJUnitPlatform()
398
399    // Resolve the native library location (e.g., ../../target/release)
400    val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
401    systemProperty("java.library.path", libPath)
402    systemProperty("jna.library.path", libPath)
403
404    // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/
405    workingDir = file("${{rootDir}}/../../test_documents")
406}}
407"#
408    )
409}
410
411/// Render `settings.gradle.kts` for the kotlin_android e2e project.
412///
413/// Declares the plugin and dependency repositories Gradle needs to resolve
414/// `com.android.library` (and Kotlin/Android transitive deps). Mirrors the
415/// AAR-side settings emitter at `alef-backend-kotlin-android::gen_settings_gradle`.
416fn render_settings_gradle_kotlin_android(pkg_name: &str) -> String {
417    let project_name = sanitize_gradle_project_name(pkg_name);
418    format!(
419        r#"// Generated by alef. Do not edit by hand.
420
421pluginManagement {{
422    repositories {{
423        google()
424        mavenCentral()
425        gradlePluginPortal()
426    }}
427}}
428
429dependencyResolutionManagement {{
430    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
431    repositories {{
432        google()
433        mavenCentral()
434    }}
435}}
436
437rootProject.name = "{project_name}-e2e"
438"#
439    )
440}
441
442/// Derive a Gradle-safe `rootProject.name` from a registry package coordinate.
443///
444/// Registry-mode `pkg_name` is often a Maven coordinate (`group:artifact`)
445/// because it's used verbatim as a build-script dependency string. Gradle
446/// rejects project names containing any of `[/, \, :, <, >, ", ?, *, |]`,
447/// so we take the artifact segment after the last `:` and replace any
448/// remaining reserved characters with `-`.
449fn sanitize_gradle_project_name(pkg_name: &str) -> String {
450    let artifact = pkg_name.rsplit(':').next().unwrap_or(pkg_name);
451    artifact
452        .chars()
453        .map(|c| match c {
454            '/' | '\\' | ':' | '<' | '>' | '"' | '?' | '*' | '|' => '-',
455            other => other,
456        })
457        .collect()
458}
459
460/// Render an Android instrumented test class for a fixture group.
461///
462/// The generated class uses `@RunWith(AndroidJUnit4::class)` and loads the
463/// native library via `System.loadLibrary` so tests can run on-device via the
464/// Android emulator.
465fn render_android_instrumented_test(
466    category: &str,
467    fixtures: &[&crate::fixture::Fixture],
468    class_name: &str,
469    function_name: &str,
470    kotlin_pkg_id: &str,
471    result_var: &str,
472    lib_name: &str,
473) -> String {
474    let test_class = format!("{}Test", category.to_upper_camel_case());
475    let lib_snake = lib_name.replace('-', "_");
476    let mut out = String::new();
477    out.push_str(&format!("package {kotlin_pkg_id}.e2e\n\n"));
478    out.push_str("import androidx.test.ext.junit.runners.AndroidJUnit4\n");
479    out.push_str("import org.junit.BeforeClass\n");
480    out.push_str("import org.junit.Test\n");
481    out.push_str("import org.junit.runner.RunWith\n\n");
482    out.push_str("@RunWith(AndroidJUnit4::class)\n");
483    out.push_str(&format!("class {test_class} {{\n\n"));
484    out.push_str("    companion object {\n");
485    out.push_str("        @BeforeClass\n");
486    out.push_str("        @JvmStatic\n");
487    out.push_str("        fun loadNativeLibrary() {\n");
488    out.push_str(&format!("            System.loadLibrary(\"{lib_snake}_jni\")\n"));
489    out.push_str("        }\n");
490    out.push_str("    }\n\n");
491    for fixture in fixtures {
492        let test_name = fixture.id.replace(['-', '.', ' '], "_");
493        out.push_str("    @Test\n");
494        out.push_str(&format!("    fun test_{test_name}() {{\n"));
495        out.push_str(&format!("        val client = {class_name}()\n"));
496        out.push_str(&format!(
497            "        val {result_var} = client.{function_name}(/* fixture: {} */)\n",
498            fixture.id
499        ));
500        out.push_str(&format!("        // TODO: assert {result_var} is not an error\n"));
501        out.push_str("    }\n\n");
502    }
503    out.push_str("}\n");
504    out
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    /// Regression: the kotlin-android build.gradle.kts must declare
512    /// `jackson-module-kotlin` so that Jackson can deserialize Kotlin data
513    /// classes (which have no default constructor).  Without it, any test that
514    /// calls `MAPPER.readValue(...)` against a Kotlin data class throws
515    /// `InvalidDefinitionException: No suitable constructor found`.
516    #[test]
517    fn build_gradle_kotlin_android_includes_jackson_module_kotlin() {
518        let output = render_build_gradle_kotlin_android(
519            "liter-llm",
520            "dev.kreuzberg.literllm.android",
521            "1.0.0",
522            crate::config::DependencyMode::Local,
523            false,
524        );
525        assert!(
526            output.contains("jackson-module-kotlin"),
527            "build.gradle.kts must depend on jackson-module-kotlin, got:\n{output}"
528        );
529    }
530
531    /// Regression: the e2e settings.gradle.kts must declare the
532    /// `pluginManagement` block with `google()` and `gradlePluginPortal()` so
533    /// Gradle can resolve `com.android.library`. Missing settings.gradle.kts
534    /// causes `Plugin [id: 'com.android.library'] was not found` at config time.
535    #[test]
536    fn settings_gradle_kotlin_android_declares_plugin_repositories() {
537        let output = render_settings_gradle_kotlin_android("liter-llm");
538        assert!(
539            output.contains("pluginManagement"),
540            "settings.gradle.kts must declare pluginManagement block, got:\n{output}"
541        );
542        assert!(
543            output.contains("google()"),
544            "pluginManagement repositories must include google(), got:\n{output}"
545        );
546        assert!(
547            output.contains("gradlePluginPortal()"),
548            "pluginManagement repositories must include gradlePluginPortal(), got:\n{output}"
549        );
550        assert!(
551            output.contains("rootProject.name = \"liter-llm-e2e\""),
552            "rootProject.name must be derived from pkg_name, got:\n{output}"
553        );
554    }
555
556    /// Regression: registry-mode `pkg_name` may be a Maven coordinate
557    /// (`group:artifact`) because it's used verbatim as a Gradle dependency
558    /// string. Gradle rejects project names containing `:`, so the
559    /// emitter must strip the group prefix when deriving `rootProject.name`.
560    /// Without sanitization Gradle fails at configuration time with
561    /// "The project name '…' must not contain any of the following
562    /// characters: [/, \\, :, <, >, \", ?, *, |]".
563    #[test]
564    fn settings_gradle_kotlin_android_strips_maven_group_from_project_name() {
565        let output = render_settings_gradle_kotlin_android("dev.kreuzberg:html-to-markdown-android");
566        assert!(
567            output.contains("rootProject.name = \"html-to-markdown-android-e2e\""),
568            "rootProject.name must strip Maven group prefix, got:\n{output}"
569        );
570        let project_name_line = output
571            .lines()
572            .find(|line| line.starts_with("rootProject.name"))
573            .expect("rootProject.name line must be emitted");
574        assert!(
575            !project_name_line.contains(':'),
576            "rootProject.name line must not contain Gradle-reserved ':', got:\n{project_name_line}"
577        );
578    }
579}