Skip to main content

alef_e2e/codegen/
dart.rs

1//! Dart e2e test generator using package:test.
2//!
3//! Generates `packages/dart/test/<fixture_id>_test.dart` files from JSON
4//! fixtures (one file per fixture group, mirroring the Gleam per-fixture-file
5//! layout) and a `pubspec.yaml` at the e2e package root.
6
7use crate::config::E2eConfig;
8use crate::escape::sanitize_filename;
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions::pub_dev;
15use anyhow::Result;
16use heck::{ToLowerCamelCase, ToSnakeCase};
17use std::collections::HashSet;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22
23/// Dart e2e code generator.
24pub struct DartE2eCodegen;
25
26impl E2eCodegen for DartE2eCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        alef_config: &AlefConfig,
32    ) -> Result<Vec<GeneratedFile>> {
33        let lang = self.language_name();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36        let mut files = Vec::new();
37
38        // Resolve call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let function_name = overrides
42            .and_then(|o| o.function.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.function.clone());
45        let result_var = &call.result_var;
46        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
47
48        // Resolve package config.
49        let dart_pkg = e2e_config.resolve_package("dart");
50        // Match the canonical pubspec name used by `dart_pubspec_name()` so the
51        // import `package:<pkg>/<module>.dart` resolves consistently across
52        // scaffold, publish, and e2e.
53        let pkg_name = dart_pkg
54            .as_ref()
55            .and_then(|p| p.name.as_ref())
56            .cloned()
57            .unwrap_or_else(|| alef_config.dart_pubspec_name());
58        let pkg_path = dart_pkg
59            .as_ref()
60            .and_then(|p| p.path.as_ref())
61            .cloned()
62            .unwrap_or_else(|| "../../packages/dart".to_string());
63        let pkg_version = dart_pkg
64            .as_ref()
65            .and_then(|p| p.version.as_ref())
66            .cloned()
67            .unwrap_or_else(|| "0.1.0".to_string());
68
69        // Generate pubspec.yaml.
70        files.push(GeneratedFile {
71            path: output_base.join("pubspec.yaml"),
72            content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
73            generated_header: false,
74        });
75
76        let test_base = output_base.join("test");
77
78        let field_resolver = FieldResolver::new(
79            &e2e_config.fields,
80            &e2e_config.fields_optional,
81            &e2e_config.result_fields,
82            &e2e_config.fields_array,
83        );
84
85        // One test file per fixture group.
86        for group in groups {
87            let active: Vec<&Fixture> = group
88                .fixtures
89                .iter()
90                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
91                .collect();
92
93            if active.is_empty() {
94                continue;
95            }
96
97            let filename = format!("{}_test.dart", sanitize_filename(&group.category));
98            let content = render_test_file(
99                &group.category,
100                &active,
101                e2e_config,
102                &pkg_name,
103                &function_name,
104                result_var,
105                &e2e_config.call.args,
106                &field_resolver,
107                result_is_simple,
108                &e2e_config.fields_enum,
109            );
110            files.push(GeneratedFile {
111                path: test_base.join(filename),
112                content,
113                generated_header: true,
114            });
115        }
116
117        Ok(files)
118    }
119
120    fn language_name(&self) -> &'static str {
121        "dart"
122    }
123}
124
125// ---------------------------------------------------------------------------
126// Rendering
127// ---------------------------------------------------------------------------
128
129fn render_pubspec(
130    pkg_name: &str,
131    pkg_path: &str,
132    pkg_version: &str,
133    dep_mode: crate::config::DependencyMode,
134) -> String {
135    let test_ver = pub_dev::TEST_PACKAGE;
136
137    let dep_block = match dep_mode {
138        crate::config::DependencyMode::Registry => {
139            format!("  {pkg_name}: ^{pkg_version}")
140        }
141        crate::config::DependencyMode::Local => {
142            format!("  {pkg_name}:\n    path: {pkg_path}")
143        }
144    };
145
146    format!(
147        r#"name: e2e_dart
148version: 0.1.0
149publish_to: none
150
151environment:
152  sdk: ">=3.0.0 <4.0.0"
153
154dependencies:
155{dep_block}
156
157dev_dependencies:
158  test: {test_ver}
159"#
160    )
161}
162
163#[allow(clippy::too_many_arguments)]
164fn render_test_file(
165    category: &str,
166    fixtures: &[&Fixture],
167    e2e_config: &E2eConfig,
168    pkg_name: &str,
169    function_name: &str,
170    result_var: &str,
171    args: &[crate::config::ArgMapping],
172    field_resolver: &FieldResolver,
173    result_is_simple: bool,
174    enum_fields: &HashSet<String>,
175) -> String {
176    let mut out = String::new();
177    out.push_str(&hash::header(CommentStyle::DoubleSlash));
178    let module_name = pkg_name.to_snake_case();
179    // mock_url args reference Platform.environment which lives in dart:io.
180    let needs_dart_io = args.iter().any(|a| a.arg_type == "mock_url");
181    let _ = writeln!(out, "import 'package:test/test.dart';");
182    if needs_dart_io {
183        let _ = writeln!(out, "import 'dart:io';");
184    }
185    let _ = writeln!(out, "import 'package:{module_name}/{module_name}.dart';");
186    let _ = writeln!(out);
187
188    let _ = writeln!(out, "// E2e tests for category: {category}");
189    let _ = writeln!(out, "void main() {{");
190
191    for fixture in fixtures {
192        render_test_case(
193            &mut out,
194            fixture,
195            e2e_config,
196            function_name,
197            result_var,
198            args,
199            field_resolver,
200            result_is_simple,
201            enum_fields,
202        );
203    }
204
205    let _ = writeln!(out, "}}");
206    out
207}
208
209#[allow(clippy::too_many_arguments)]
210fn render_test_case(
211    out: &mut String,
212    fixture: &Fixture,
213    e2e_config: &E2eConfig,
214    _function_name: &str,
215    _result_var: &str,
216    _args: &[crate::config::ArgMapping],
217    field_resolver: &FieldResolver,
218    result_is_simple: bool,
219    enum_fields: &HashSet<String>,
220) {
221    // Resolve per-fixture call config.
222    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
223    let lang = "dart";
224    let call_overrides = call_config.overrides.get(lang);
225    let function_name = call_overrides
226        .and_then(|o| o.function.as_ref())
227        .cloned()
228        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
229    let result_var = &call_config.result_var;
230    let args = &call_config.args;
231
232    let description = &fixture.description;
233    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
234    let is_async = call_config.r#async;
235
236    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
237
238    // Async tests must always use `async` callbacks — `expect(throwsA(...))` on a
239    // synchronous lambda wrapping an async call drops the rejection. Use
240    // `expectLater` + `throwsA` for async-error fixtures.
241    if is_async {
242        let _ = writeln!(out, "  test('{description}', () async {{");
243    } else {
244        let _ = writeln!(out, "  test('{description}', () {{");
245    }
246
247    for line in &setup_lines {
248        let _ = writeln!(out, "    {line}");
249    }
250
251    if expects_error {
252        if is_async {
253            let _ = writeln!(
254                out,
255                "    await expectLater({function_name}({args_str}), throwsA(isA<Exception>()));"
256            );
257        } else {
258            let _ = writeln!(
259                out,
260                "    expect(() => {function_name}({args_str}), throwsA(isA<Exception>()));"
261            );
262        }
263        let _ = writeln!(out, "  }});");
264        let _ = writeln!(out);
265        return;
266    }
267
268    if is_async {
269        let _ = writeln!(out, "    final {result_var} = await {function_name}({args_str});");
270    } else {
271        let _ = writeln!(out, "    final {result_var} = {function_name}({args_str});");
272    }
273
274    for assertion in &fixture.assertions {
275        render_assertion(
276            out,
277            assertion,
278            result_var,
279            field_resolver,
280            result_is_simple,
281            enum_fields,
282        );
283    }
284
285    let _ = writeln!(out, "  }});");
286    let _ = writeln!(out);
287}
288
289/// Build setup lines and the argument list for the function call.
290fn build_args_and_setup(
291    input: &serde_json::Value,
292    args: &[crate::config::ArgMapping],
293    fixture_id: &str,
294) -> (Vec<String>, String) {
295    if args.is_empty() {
296        return (Vec::new(), String::new());
297    }
298
299    let mut setup_lines: Vec<String> = Vec::new();
300    let mut parts: Vec<String> = Vec::new();
301
302    for arg in args {
303        if arg.arg_type == "mock_url" {
304            setup_lines.push(format!(
305                "final {} = Platform.environment['MOCK_SERVER_URL']! + '/fixtures/{fixture_id}';",
306                arg.name,
307            ));
308            parts.push(arg.name.clone());
309            continue;
310        }
311
312        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
313        let val = input.get(field);
314        match val {
315            None | Some(serde_json::Value::Null) if arg.optional => {
316                continue;
317            }
318            None | Some(serde_json::Value::Null) => {
319                let default_val = match arg.arg_type.as_str() {
320                    "string" => "''".to_string(),
321                    "int" | "integer" => "0".to_string(),
322                    "float" | "number" => "0.0".to_string(),
323                    "bool" | "boolean" => "false".to_string(),
324                    _ => "null".to_string(),
325                };
326                parts.push(default_val);
327            }
328            Some(v) => {
329                parts.push(json_to_dart(v));
330            }
331        }
332    }
333
334    (setup_lines, parts.join(", "))
335}
336
337fn render_assertion(
338    out: &mut String,
339    assertion: &Assertion,
340    result_var: &str,
341    field_resolver: &FieldResolver,
342    result_is_simple: bool,
343    enum_fields: &HashSet<String>,
344) {
345    // Skip assertions on fields that don't exist on the result type.
346    if let Some(f) = &assertion.field {
347        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
348            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
349            return;
350        }
351    }
352
353    // Determine if this field is an enum type.
354    let field_is_enum = assertion
355        .field
356        .as_deref()
357        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
358
359    let field_expr = if result_is_simple {
360        result_var.to_string()
361    } else {
362        match &assertion.field {
363            Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
364            _ => result_var.to_string(),
365        }
366    };
367
368    // For enum fields, use .name to get the string value.
369    let string_expr = if field_is_enum {
370        format!("{field_expr}.name")
371    } else {
372        field_expr.clone()
373    };
374
375    match assertion.assertion_type.as_str() {
376        "equals" => {
377            if let Some(expected) = &assertion.value {
378                let dart_val = json_to_dart(expected);
379                if expected.is_string() {
380                    let _ = writeln!(out, "    expect({string_expr}.trim(), equals({dart_val}));");
381                } else {
382                    let _ = writeln!(out, "    expect({field_expr}, equals({dart_val}));");
383                }
384            }
385        }
386        "contains" => {
387            if let Some(expected) = &assertion.value {
388                let dart_val = json_to_dart(expected);
389                let _ = writeln!(out, "    expect({string_expr}, contains({dart_val}));");
390            }
391        }
392        "contains_all" => {
393            if let Some(values) = &assertion.values {
394                for val in values {
395                    let dart_val = json_to_dart(val);
396                    let _ = writeln!(out, "    expect({string_expr}, contains({dart_val}));");
397                }
398            }
399        }
400        "not_contains" => {
401            if let Some(expected) = &assertion.value {
402                let dart_val = json_to_dart(expected);
403                let _ = writeln!(out, "    expect({string_expr}, isNot(contains({dart_val})));");
404            }
405        }
406        "not_empty" => {
407            let _ = writeln!(out, "    expect({field_expr}, isNotEmpty);");
408        }
409        "is_empty" => {
410            let _ = writeln!(out, "    expect({field_expr}, isEmpty);");
411        }
412        "contains_any" => {
413            if let Some(values) = &assertion.values {
414                let checks: Vec<String> = values
415                    .iter()
416                    .map(|v| {
417                        let dart_val = json_to_dart(v);
418                        format!("{string_expr}.contains({dart_val})")
419                    })
420                    .collect();
421                let joined = checks.join(" || ");
422                let _ = writeln!(
423                    out,
424                    "    expect({joined}, isTrue, reason: 'expected to contain at least one of the specified values');"
425                );
426            }
427        }
428        "greater_than" => {
429            if let Some(val) = &assertion.value {
430                let dart_val = json_to_dart(val);
431                let _ = writeln!(out, "    expect({field_expr}, greaterThan({dart_val}));");
432            }
433        }
434        "less_than" => {
435            if let Some(val) = &assertion.value {
436                let dart_val = json_to_dart(val);
437                let _ = writeln!(out, "    expect({field_expr}, lessThan({dart_val}));");
438            }
439        }
440        "greater_than_or_equal" => {
441            if let Some(val) = &assertion.value {
442                let dart_val = json_to_dart(val);
443                let _ = writeln!(out, "    expect({field_expr}, greaterThanOrEqualTo({dart_val}));");
444            }
445        }
446        "less_than_or_equal" => {
447            if let Some(val) = &assertion.value {
448                let dart_val = json_to_dart(val);
449                let _ = writeln!(out, "    expect({field_expr}, lessThanOrEqualTo({dart_val}));");
450            }
451        }
452        "starts_with" => {
453            if let Some(expected) = &assertion.value {
454                let dart_val = json_to_dart(expected);
455                let _ = writeln!(out, "    expect({string_expr}, startsWith({dart_val}));");
456            }
457        }
458        "ends_with" => {
459            if let Some(expected) = &assertion.value {
460                let dart_val = json_to_dart(expected);
461                let _ = writeln!(out, "    expect({string_expr}, endsWith({dart_val}));");
462            }
463        }
464        "min_length" => {
465            if let Some(val) = &assertion.value {
466                if let Some(n) = val.as_u64() {
467                    let _ = writeln!(out, "    expect({field_expr}.length, greaterThanOrEqualTo({n}));");
468                }
469            }
470        }
471        "max_length" => {
472            if let Some(val) = &assertion.value {
473                if let Some(n) = val.as_u64() {
474                    let _ = writeln!(out, "    expect({field_expr}.length, lessThanOrEqualTo({n}));");
475                }
476            }
477        }
478        "count_min" => {
479            if let Some(val) = &assertion.value {
480                if let Some(n) = val.as_u64() {
481                    let _ = writeln!(out, "    expect({field_expr}.length, greaterThanOrEqualTo({n}));");
482                }
483            }
484        }
485        "count_equals" => {
486            if let Some(val) = &assertion.value {
487                if let Some(n) = val.as_u64() {
488                    let _ = writeln!(out, "    expect({field_expr}.length, equals({n}));");
489                }
490            }
491        }
492        "is_true" => {
493            let _ = writeln!(out, "    expect({field_expr}, isTrue);");
494        }
495        "is_false" => {
496            let _ = writeln!(out, "    expect({field_expr}, isFalse);");
497        }
498        "matches_regex" => {
499            if let Some(expected) = &assertion.value {
500                let dart_val = json_to_dart(expected);
501                let _ = writeln!(out, "    expect({string_expr}, matches({dart_val}));");
502            }
503        }
504        "not_error" => {
505            // Already handled by the call succeeding without exception.
506        }
507        "error" => {
508            // Handled at the test case level.
509        }
510        "method_result" => {
511            let _ = writeln!(out, "    // method_result assertions not yet implemented for Dart");
512        }
513        other => {
514            panic!("Dart e2e generator: unsupported assertion type: {other}");
515        }
516    }
517}
518
519/// Convert a `serde_json::Value` to a Dart literal string.
520fn json_to_dart(value: &serde_json::Value) -> String {
521    match value {
522        serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
523        serde_json::Value::Bool(b) => b.to_string(),
524        serde_json::Value::Number(n) => n.to_string(),
525        serde_json::Value::Null => "null".to_string(),
526        serde_json::Value::Array(arr) => {
527            let items: Vec<String> = arr.iter().map(json_to_dart).collect();
528            format!("[{}]", items.join(", "))
529        }
530        serde_json::Value::Object(_) => {
531            let json_str = serde_json::to_string(value).unwrap_or_default();
532            format!("'{}'", escape_dart(&json_str))
533        }
534    }
535}
536
537/// Escape a string for embedding in a Dart single-quoted string literal.
538fn escape_dart(s: &str) -> String {
539    s.replace('\\', "\\\\")
540        .replace('\'', "\\'")
541        .replace('\n', "\\n")
542        .replace('\r', "\\r")
543        .replace('\t', "\\t")
544        .replace('$', "\\$")
545}