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