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    ) -> 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 test files per category. Path mirrors the configured Kotlin
120        // package so the package declaration in each test file matches its
121        // filesystem location.
122        let mut test_base = output_base.join("src").join("test").join("kotlin");
123        for segment in kotlin_pkg_id.split('.') {
124            test_base = test_base.join(segment);
125        }
126        let test_base = test_base.join("e2e");
127
128        if needs_mock_server {
129            files.push(GeneratedFile {
130                path: test_base.join("MockServerListener.kt"),
131                content: kotlin::render_mock_server_listener_kt(&kotlin_pkg_id),
132                generated_header: true,
133            });
134            files.push(GeneratedFile {
135                path: output_base
136                    .join("src")
137                    .join("test")
138                    .join("resources")
139                    .join("META-INF")
140                    .join("services")
141                    .join("org.junit.platform.launcher.LauncherSessionListener"),
142                content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
143                generated_header: false,
144            });
145        }
146
147        // Resolve options_type from override.
148        let options_type = overrides.and_then(|o| o.options_type.clone());
149        let field_resolver = FieldResolver::new(
150            &e2e_config.fields,
151            &e2e_config.fields_optional,
152            &e2e_config.result_fields,
153            &e2e_config.fields_array,
154            &HashSet::new(),
155        );
156
157        // Build a map from TypeDef name → set of field names whose Rust type
158        // is a `Named(T)` reference where `T` is NOT itself a known struct.
159        // Those fields are enum-typed and should route through `.getValue()` in
160        // generated assertions automatically, even without an explicit per-call
161        // `enum_fields` override in the alef.toml.
162        let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
163        let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
164            .iter()
165            .filter_map(|td| {
166                let enum_field_names: HashSet<String> = td
167                    .fields
168                    .iter()
169                    .filter(|field| is_enum_typed(&field.ty, &struct_names))
170                    .map(|field| field.name.clone())
171                    .collect();
172                if enum_field_names.is_empty() {
173                    None
174                } else {
175                    Some((td.name.clone(), enum_field_names))
176                }
177            })
178            .collect();
179
180        for group in groups {
181            let active: Vec<&Fixture> = group
182                .fixtures
183                .iter()
184                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
185                .collect();
186
187            if active.is_empty() {
188                continue;
189            }
190
191            let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
192            let content = kotlin::render_test_file_android(
193                &group.category,
194                &active,
195                &class_name,
196                &function_name,
197                &kotlin_pkg_id,
198                result_var,
199                &e2e_config.call.args,
200                options_type.as_deref(),
201                &field_resolver,
202                result_is_simple,
203                &e2e_config.fields_enum,
204                e2e_config,
205                &type_enum_fields,
206            );
207            files.push(GeneratedFile {
208                path: test_base.join(class_file_name),
209                content,
210                generated_header: true,
211            });
212        }
213
214        Ok(files)
215    }
216
217    fn language_name(&self) -> &'static str {
218        "kotlin_android"
219    }
220}
221
222// ---------------------------------------------------------------------------
223// Helpers
224// ---------------------------------------------------------------------------
225
226/// Returns true when `ty` is a `Named(T)` reference (or `Optional<Named(T)>`)
227/// where `T` is **not** a known struct name. Such fields are enum-typed and
228/// must route through `.getValue()` in generated assertions.
229fn is_enum_typed(ty: &alef_core::ir::TypeRef, struct_names: &HashSet<&str>) -> bool {
230    use alef_core::ir::TypeRef;
231    match ty {
232        TypeRef::Named(name) => !struct_names.contains(name.as_str()),
233        TypeRef::Optional(inner) => {
234            matches!(inner.as_ref(), TypeRef::Named(name) if !struct_names.contains(name.as_str()))
235        }
236        _ => false,
237    }
238}
239
240// ---------------------------------------------------------------------------
241// Rendering
242// ---------------------------------------------------------------------------
243
244/// Render build.gradle.kts for the kotlin_android host-JVM e2e project.
245/// This is a JVM Kotlin project (NOT an Android library) that:
246/// 1. Adds the bundled Java facade as test sources via sourceSets
247/// 2. Adds the bundled Kotlin wrapper as test sources via sourceSets
248/// 3. Depends on JNA for native library loading
249/// 4. Runs tests against libkreuzberg_ffi loaded via JNA
250fn render_build_gradle_kotlin_android(
251    _pkg_name: &str,
252    kotlin_pkg_id: &str,
253    _pkg_version: &str,
254    _dep_mode: crate::config::DependencyMode,
255    needs_mock_server: bool,
256) -> String {
257    // For kotlin_android, we don't add the AAR as a dependency (since this is a
258    // host JVM project, not an Android project). Instead, we use sourceSets to
259    // include the bundled Java facade and Kotlin wrapper from the AAR's source
260    // directories.
261    let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
262    let junit = maven::JUNIT;
263    let jackson = maven::JACKSON_E2E;
264    let jvm_target = toolchain::ANDROID_JVM_TARGET;
265    let jna = maven::JNA;
266    let jspecify = maven::JSPECIFY;
267    let coroutines = maven::KOTLINX_COROUTINES_CORE;
268    let launcher_dep = if needs_mock_server {
269        format!(r#"    testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
270    } else {
271        String::new()
272    };
273
274    format!(
275        r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
276
277plugins {{
278    kotlin("jvm") version "{kotlin_plugin}"
279    java
280}}
281
282group = "{kotlin_pkg_id}"
283version = "0.1.0"
284
285java {{
286    sourceCompatibility = JavaVersion.VERSION_{jvm_target}
287    targetCompatibility = JavaVersion.VERSION_{jvm_target}
288}}
289
290kotlin {{
291    compilerOptions {{
292        jvmTarget.set(JvmTarget.JVM_{jvm_target})
293    }}
294}}
295
296repositories {{
297    mavenCentral()
298}}
299
300sourceSets {{
301    test {{
302        // Include the AAR-bundled Java facade as test sources
303        java.srcDir("../../packages/kotlin-android/src/main/java")
304        // Include the AAR-bundled Kotlin wrapper as test sources
305        kotlin.srcDir("../../packages/kotlin-android/src/main/kotlin")
306    }}
307}}
308
309dependencies {{
310    // JNA for loading libkreuzberg_ffi from java.library.path
311    testImplementation("net.java.dev.jna:jna:{jna}")
312
313    // Jackson for JSON assertion helpers
314    testImplementation("com.fasterxml.jackson.core:jackson-annotations:{jackson}")
315    testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
316    testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
317
318    // jspecify for null-safety annotations on wrapped types
319    testImplementation("org.jspecify:jspecify:{jspecify}")
320
321    // Kotlin coroutines for async test helpers
322    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:{coroutines}")
323
324    // JUnit 5 API and engine
325    testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
326    testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
327{launcher_dep}
328
329    // Kotlin stdlib test helpers
330    testImplementation(kotlin("test"))
331}}
332
333tasks.test {{
334    useJUnitPlatform()
335
336    // Resolve libkreuzberg_ffi location (e.g., ../../target/release)
337    val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
338    systemProperty("java.library.path", libPath)
339    systemProperty("jna.library.path", libPath)
340
341    // Resolve fixture paths (e.g. "docx/fake.docx") against test_documents/
342    workingDir = file("${{rootDir}}/../../test_documents")
343}}
344"#
345    )
346}