Skip to main content

alef_e2e/codegen/
dart.rs

1//! Dart e2e test generator using package:test and package:http.
2//!
3//! Generates `e2e/dart/test/<category>_test.dart` files from JSON fixtures.
4//! HTTP fixtures hit the mock server at `MOCK_SERVER_URL/fixtures/<id>`.
5//! Non-HTTP fixtures without a dart-specific call override emit a skip stub.
6
7use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::fixture::{Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::ResolvedCrateConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions::pub_dev;
15use anyhow::Result;
16use std::cell::Cell;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
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        config: &ResolvedCrateConfig,
32        _type_defs: &[alef_core::ir::TypeDef],
33    ) -> Result<Vec<GeneratedFile>> {
34        let lang = self.language_name();
35        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37        let mut files = Vec::new();
38
39        // Resolve package config.
40        let dart_pkg = e2e_config.resolve_package("dart");
41        let pkg_name = dart_pkg
42            .as_ref()
43            .and_then(|p| p.name.as_ref())
44            .cloned()
45            .unwrap_or_else(|| config.dart_pubspec_name());
46        let pkg_path = dart_pkg
47            .as_ref()
48            .and_then(|p| p.path.as_ref())
49            .cloned()
50            .unwrap_or_else(|| "../../packages/dart".to_string());
51        let pkg_version = dart_pkg
52            .as_ref()
53            .and_then(|p| p.version.as_ref())
54            .cloned()
55            .or_else(|| config.resolved_version())
56            .unwrap_or_else(|| "0.1.0".to_string());
57
58        // Generate pubspec.yaml with http dependency for HTTP client tests.
59        files.push(GeneratedFile {
60            path: output_base.join("pubspec.yaml"),
61            content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
62            generated_header: false,
63        });
64
65        // Generate dart_test.yaml to limit parallelism — the mock server uses keep-alive
66        // connections and gets overwhelmed when test files run in parallel.
67        files.push(GeneratedFile {
68            path: output_base.join("dart_test.yaml"),
69            content: concat!(
70                "# Generated by alef — DO NOT EDIT.\n",
71                "# Run test files sequentially to avoid overwhelming the mock server with\n",
72                "# concurrent keep-alive connections.\n",
73                "concurrency: 1\n",
74            )
75            .to_string(),
76            generated_header: false,
77        });
78
79        let test_base = output_base.join("test");
80
81        // One test file per fixture group.
82        let bridge_class = config.dart_bridge_class_name();
83
84        for group in groups {
85            let active: Vec<&Fixture> = group
86                .fixtures
87                .iter()
88                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
89                .collect();
90
91            if active.is_empty() {
92                continue;
93            }
94
95            let filename = format!("{}_test.dart", sanitize_filename(&group.category));
96            let content = render_test_file(&group.category, &active, e2e_config, lang, &pkg_name, &bridge_class);
97            files.push(GeneratedFile {
98                path: test_base.join(filename),
99                content,
100                generated_header: true,
101            });
102        }
103
104        Ok(files)
105    }
106
107    fn language_name(&self) -> &'static str {
108        "dart"
109    }
110}
111
112// ---------------------------------------------------------------------------
113// Rendering
114// ---------------------------------------------------------------------------
115
116fn render_pubspec(
117    pkg_name: &str,
118    pkg_path: &str,
119    pkg_version: &str,
120    dep_mode: crate::config::DependencyMode,
121) -> String {
122    let test_ver = pub_dev::TEST_PACKAGE;
123    let http_ver = pub_dev::HTTP_PACKAGE;
124
125    let dep_block = match dep_mode {
126        crate::config::DependencyMode::Registry => {
127            format!("  {pkg_name}: ^{pkg_version}")
128        }
129        crate::config::DependencyMode::Local => {
130            format!("  {pkg_name}:\n    path: {pkg_path}")
131        }
132    };
133
134    format!(
135        r#"name: e2e_dart
136version: 0.1.0
137publish_to: none
138
139environment:
140  sdk: ">=3.0.0 <4.0.0"
141
142dependencies:
143{dep_block}
144
145dev_dependencies:
146  test: {test_ver}
147  http: {http_ver}
148"#
149    )
150}
151
152fn render_test_file(
153    category: &str,
154    fixtures: &[&Fixture],
155    e2e_config: &E2eConfig,
156    lang: &str,
157    pkg_name: &str,
158    bridge_class: &str,
159) -> String {
160    let mut out = String::new();
161    out.push_str(&hash::header(CommentStyle::DoubleSlash));
162
163    // Check if any fixture needs the http package (HTTP server tests).
164    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
165
166    // Check if any fixture needs Uint8List.fromList (batch item byte arrays).
167    let has_batch_byte_items = fixtures.iter().any(|f| {
168        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
169        call_config.args.iter().any(|a| {
170            a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
171        })
172    });
173
174    // Detect whether any fixture uses file_path or bytes args — if so, setUpAll must chdir
175    // to the test_documents directory so that relative paths like "docx/fake.docx" resolve.
176    // Mirrors the Ruby/Python conftest and Swift setUp patterns.
177    let needs_chdir = fixtures.iter().any(|f| {
178        if f.is_http_test() {
179            return false;
180        }
181        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
182        call_config
183            .args
184            .iter()
185            .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
186    });
187
188    // Detect whether any non-HTTP fixture uses a handle arg — if so we need dart:convert
189    // to call jsonDecode when building the engine config from a JSON string.
190    let has_handle_args = fixtures.iter().any(|f| {
191        if f.is_http_test() {
192            return false;
193        }
194        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
195        call_config.args.iter().any(|a| a.arg_type == "handle")
196    });
197
198    let _ = writeln!(out, "import 'package:test/test.dart';");
199    let _ = writeln!(out, "import 'dart:io';");
200    if has_batch_byte_items {
201        let _ = writeln!(out, "import 'dart:typed_data';");
202    }
203    let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
204    // RustLib is the flutter_rust_bridge entrypoint; must be initialized before any FRB call.
205    // It lives in the FRB-generated frb_generated.dart inside `{pkg_name}_bridge_generated/`.
206    let _ = writeln!(
207        out,
208        "import 'package:{pkg_name}/src/{pkg_name}_bridge_generated/frb_generated.dart' show RustLib;"
209    );
210    if has_http_fixtures {
211        let _ = writeln!(out, "import 'dart:async';");
212    }
213    // dart:convert provides jsonDecode for handle-arg engine construction and HTTP response parsing.
214    if has_http_fixtures || has_handle_args {
215        let _ = writeln!(out, "import 'dart:convert';");
216    }
217    let _ = writeln!(out);
218
219    // Emit file-level HTTP client and serialization mutex.
220    //
221    // The shared HttpClient reuses keep-alive connections to minimize TCP overhead.
222    // The mutex (_lock) ensures requests are serialized within the file so the
223    // connection pool is not exercised concurrently by dart:test's async runner.
224    //
225    // _withRetry wraps the entire request closure with one automatic retry on
226    // transient connection errors (keep-alive connections can be silently closed
227    // by the server just as the client tries to reuse them).
228    if has_http_fixtures {
229        let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
230        let _ = writeln!(out);
231        let _ = writeln!(out, "var _lock = Future<void>.value();");
232        let _ = writeln!(out);
233        let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
234        let _ = writeln!(out, "  final current = _lock;");
235        let _ = writeln!(out, "  final next = Completer<void>();");
236        let _ = writeln!(out, "  _lock = next.future;");
237        let _ = writeln!(out, "  try {{");
238        let _ = writeln!(out, "    await current;");
239        let _ = writeln!(out, "    return await fn();");
240        let _ = writeln!(out, "  }} finally {{");
241        let _ = writeln!(out, "    next.complete();");
242        let _ = writeln!(out, "  }}");
243        let _ = writeln!(out, "}}");
244        let _ = writeln!(out);
245        // The `fn` here should be the full request closure — on socket failure we
246        // recreate the HttpClient (drops old pooled connections) and retry once.
247        let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
248        let _ = writeln!(out, "  try {{");
249        let _ = writeln!(out, "    return await fn();");
250        let _ = writeln!(out, "  }} on SocketException {{");
251        let _ = writeln!(out, "    _httpClient.close(force: true);");
252        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
253        let _ = writeln!(out, "    return fn();");
254        let _ = writeln!(out, "  }} on HttpException {{");
255        let _ = writeln!(out, "    _httpClient.close(force: true);");
256        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
257        let _ = writeln!(out, "    return fn();");
258        let _ = writeln!(out, "  }}");
259        let _ = writeln!(out, "}}");
260        let _ = writeln!(out);
261    }
262
263    let _ = writeln!(out, "// E2e tests for category: {category}");
264    let _ = writeln!(out, "void main() {{");
265
266    // Emit setUpAll to initialize the flutter_rust_bridge before any test runs and,
267    // when fixtures load files by path, chdir to test_documents so that relative
268    // paths like "docx/fake.docx" resolve correctly.
269    //
270    // The test_documents directory lives two levels above e2e/dart/ (at the repo root).
271    // The FIXTURES_DIR environment variable can override this for CI environments.
272    let _ = writeln!(out, "  setUpAll(() async {{");
273    let _ = writeln!(out, "    await RustLib.init();");
274    if needs_chdir {
275        let test_docs_path = e2e_config.test_documents_relative_from(0);
276        let _ = writeln!(
277            out,
278            "    final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
279        );
280        let _ = writeln!(out, "    final _dir = Directory(_testDocs);");
281        let _ = writeln!(out, "    if (_dir.existsSync()) Directory.current = _dir;");
282    }
283    let _ = writeln!(out, "  }});");
284    let _ = writeln!(out);
285
286    // Close the shared client after all tests in this file complete.
287    if has_http_fixtures {
288        let _ = writeln!(out, "  tearDownAll(() => _httpClient.close());");
289        let _ = writeln!(out);
290    }
291
292    for fixture in fixtures {
293        render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
294    }
295
296    let _ = writeln!(out, "}}");
297    out
298}
299
300fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
301    // HTTP fixtures: hit the mock server.
302    if let Some(http) = &fixture.http {
303        render_http_test_case(out, fixture, http);
304        return;
305    }
306
307    // Non-HTTP fixtures: render a call-based test using the resolved call config.
308    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
309    let call_overrides = call_config.overrides.get(lang);
310    let mut function_name = call_overrides
311        .and_then(|o| o.function.as_ref())
312        .cloned()
313        .unwrap_or_else(|| call_config.function.clone());
314    // Convert snake_case function names to camelCase for Dart conventions.
315    function_name = function_name
316        .split('_')
317        .enumerate()
318        .map(|(i, part)| {
319            if i == 0 {
320                part.to_string()
321            } else {
322                let mut chars = part.chars();
323                match chars.next() {
324                    None => String::new(),
325                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
326                }
327            }
328        })
329        .collect::<Vec<_>>()
330        .join("");
331    let result_var = &call_config.result_var;
332    let description = escape_dart(&fixture.description);
333    let fixture_id = &fixture.id;
334    // `is_async` retained for future use (e.g. non-FRB backends); unused with FRB since
335    // all wrappers return Future<T>.
336    let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
337
338    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
339
340    // Build argument list from fixture.input and call_config.args.
341    // Use `resolve_field` (respects the `field` path like "input.data") rather than
342    // looking up by `arg_def.name` directly — the name and the field key may differ.
343    //
344    // For `extract_file_sync` / `extract_file` fixtures that omit `mime_type`,
345    // derive the MIME from the path extension so `extractBytesSync`/`extractBytes`
346    // can be called (both require an explicit MIME type).
347    let file_path_for_mime: Option<&str> = call_config
348        .args
349        .iter()
350        .find(|a| a.arg_type == "file_path")
351        .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
352
353    // Detect whether this call converts a file_path arg to bytes at test-run time.
354    // Dart cannot pass OS-level file paths through the FRB bridge — the idiomatic API
355    // is always bytes. When a file_path arg is present (and no caller-supplied dart
356    // function override has already been applied), remap the function name:
357    //   extractFile      → extractBytes
358    //   extractFileSync  → extractBytesSync
359    let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
360    // Apply the remap only when no per-fixture dart override has already specified the
361    // function — if the fixture author set a dart-specific function name we trust it.
362    let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
363    if has_file_path_arg && !caller_supplied_override {
364        function_name = match function_name.as_str() {
365            "extractFile" => "extractBytes".to_string(),
366            "extractFileSync" => "extractBytesSync".to_string(),
367            other => other.to_string(),
368        };
369    }
370
371    // setup_lines holds per-test statements that must precede the main call:
372    // engine construction (handle args) and URL building (mock_url args).
373    let mut setup_lines: Vec<String> = Vec::new();
374    let mut args = Vec::new();
375
376    for arg_def in &call_config.args {
377        match arg_def.arg_type.as_str() {
378            "mock_url" => {
379                let name = arg_def.name.clone();
380                if fixture.has_host_root_route() {
381                    let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
382                    setup_lines.push(format!(
383                        r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
384                    ));
385                } else {
386                    setup_lines.push(format!(
387                        r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
388                    ));
389                }
390                args.push(name);
391                continue;
392            }
393            "handle" => {
394                let name = arg_def.name.clone();
395                let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
396                let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
397                // Derive the create-function name: "engine" → "createEngine".
398                let create_fn = {
399                    let mut chars = name.chars();
400                    let pascal = match chars.next() {
401                        None => String::new(),
402                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
403                    };
404                    format!("create{pascal}")
405                };
406                if config_value.is_null()
407                    || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
408                {
409                    setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}(null);"));
410                } else {
411                    let json_str = serde_json::to_string(&config_value).unwrap_or_default();
412                    let config_var = format!("{name}Config");
413                    setup_lines.push(format!(
414                        "final {config_var} = CrawlConfig.fromJson(jsonDecode(r'{json_str}') as Map<String, dynamic>);"
415                    ));
416                    setup_lines.push(format!(
417                        "final {name} = await {bridge_class}.{create_fn}({config_var});"
418                    ));
419                }
420                args.push(name);
421                continue;
422            }
423            _ => {}
424        }
425
426        let arg_value = resolve_field(&fixture.input, &arg_def.field);
427        match arg_def.arg_type.as_str() {
428            "bytes" | "file_path" => {
429                // `bytes`: value is a file path string; load file contents at test-run time.
430                // `file_path`: also loaded as bytes for dart — extractBytes/extractBytesSync is
431                // the idiomatic Dart API since the Dart runtime cannot pass OS-level file paths
432                // through the FFI bridge.
433                if let serde_json::Value::String(file_path) = arg_value {
434                    args.push(format!("File('{}').readAsBytesSync()", file_path));
435                }
436            }
437            "string" => {
438                match arg_value {
439                    serde_json::Value::String(s) => {
440                        args.push(format!("'{}'", escape_dart(s)));
441                    }
442                    serde_json::Value::Null
443                        if arg_def.optional
444                        // Optional string absent from fixture — try to infer MIME from path
445                        // when the arg name looks like a MIME-type parameter.
446                        && arg_def.name == "mime_type" =>
447                    {
448                        let inferred = file_path_for_mime
449                            .and_then(mime_from_extension)
450                            .unwrap_or("application/octet-stream");
451                        args.push(format!("'{inferred}'"));
452                    }
453                    // Other optional strings with null value are omitted.
454                    _ => {}
455                }
456            }
457            "json_object" => {
458                // Handle batch item arrays (BatchBytesItem / BatchFileItem).
459                if let Some(elem_type) = &arg_def.element_type {
460                    if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
461                        let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
462                        args.push(dart_items);
463                    }
464                } else if arg_def.name == "config" {
465                    if let serde_json::Value::Object(map) = &arg_value {
466                        // Fixture provides config overrides — build an ExtractionConfig constructor
467                        // with defaults, overriding only the fields present in the fixture JSON.
468                        // This handles error-triggering configs like {force_ocr:true, disable_ocr:true}.
469                        if !map.is_empty() {
470                            args.push(emit_extraction_config_dart(map));
471                        }
472                    }
473                    // If config is null/absent, the wrapper supplies the default ExtractionConfig.
474                } else if arg_value.is_array() {
475                    // Generic JSON array (e.g. batch_urls: ["/page1", "/page2"]).
476                    // Decode via jsonDecode and cast to List<String> at test-run time.
477                    let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
478                    let var_name = arg_def.name.clone();
479                    setup_lines.push(format!(
480                        "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
481                    ));
482                    args.push(var_name);
483                }
484            }
485            _ => {}
486        }
487    }
488
489    // All bridge methods return Future<T> because FRB v2 wraps every Rust
490    // function as async in Dart — even "sync" Rust functions. Always emit an async
491    // test body and await the call so the test framework waits for the future.
492    let _ = writeln!(out, "  test('{description}', () async {{");
493
494    let args_str = args.join(", ");
495    let receiver_class = call_overrides
496        .and_then(|o| o.class.as_ref())
497        .cloned()
498        .unwrap_or_else(|| bridge_class.to_string());
499
500    if expects_error && !setup_lines.is_empty() {
501        // Wrap setup + call in an async lambda so any exception at any step is caught.
502        // flutter_rust_bridge 2.x decodes Rust errors as raw String values (not Exception
503        // subtypes), so throwsException will not match. Use throwsA(anything) instead.
504        let _ = writeln!(out, "    await expectLater(() async {{");
505        for line in &setup_lines {
506            let _ = writeln!(out, "      {line}");
507        }
508        let _ = writeln!(out, "      return {receiver_class}.{function_name}({args_str});");
509        let _ = writeln!(out, "    }}(), throwsA(anything));");
510    } else if expects_error {
511        // No setup lines, direct call — same throwsA(anything) rationale as above.
512        let _ = writeln!(
513            out,
514            "    await expectLater({receiver_class}.{function_name}({args_str}), throwsA(anything));"
515        );
516    } else {
517        for line in &setup_lines {
518            let _ = writeln!(out, "    {line}");
519        }
520        let _ = writeln!(
521            out,
522            "    final {result_var} = await {receiver_class}.{function_name}({args_str});"
523        );
524    }
525
526    let _ = writeln!(out, "  }});");
527    let _ = writeln!(out);
528}
529
530/// Converts a snake_case JSON key to Dart camelCase.
531fn snake_to_camel(s: &str) -> String {
532    let mut result = String::with_capacity(s.len());
533    let mut next_upper = false;
534    for ch in s.chars() {
535        if ch == '_' {
536            next_upper = true;
537        } else if next_upper {
538            result.extend(ch.to_uppercase());
539            next_upper = false;
540        } else {
541            result.push(ch);
542        }
543    }
544    result
545}
546
547/// Emits a Dart `ExtractionConfig(...)` constructor with default values, overriding
548/// fields present in `overrides` (from fixture JSON, snake_case keys).
549///
550/// Only simple scalar overrides (bool, int) are supported. Complex nested types
551/// (ocr, chunking, etc.) are left at their defaults (null).
552fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
553    // Collect scalar overrides; convert keys to camelCase.
554    let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
555    for (key, val) in overrides {
556        let camel = snake_to_camel(key);
557        let dart_val = match val {
558            serde_json::Value::Bool(b) => {
559                if *b {
560                    "true".to_string()
561                } else {
562                    "false".to_string()
563                }
564            }
565            serde_json::Value::Number(n) => n.to_string(),
566            serde_json::Value::String(s) => format!("'{s}'"),
567            _ => continue, // skip complex nested objects
568        };
569        field_overrides.insert(camel, dart_val);
570    }
571
572    let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
573    let enable_quality_processing = field_overrides
574        .remove("enableQualityProcessing")
575        .unwrap_or_else(|| "true".to_string());
576    let force_ocr = field_overrides
577        .remove("forceOcr")
578        .unwrap_or_else(|| "false".to_string());
579    let disable_ocr = field_overrides
580        .remove("disableOcr")
581        .unwrap_or_else(|| "false".to_string());
582    let include_document_structure = field_overrides
583        .remove("includeDocumentStructure")
584        .unwrap_or_else(|| "false".to_string());
585    let max_archive_depth = field_overrides
586        .remove("maxArchiveDepth")
587        .unwrap_or_else(|| "3".to_string());
588
589    format!(
590        "ExtractionConfig(useCache: {use_cache}, enableQualityProcessing: {enable_quality_processing}, forceOcr: {force_ocr}, disableOcr: {disable_ocr}, resultFormat: ResultFormat.unified, outputFormat: OutputFormat.plain(), includeDocumentStructure: {include_document_structure}, maxArchiveDepth: {max_archive_depth})"
591    )
592}
593
594// ---------------------------------------------------------------------------
595// HTTP server test rendering — DartTestClientRenderer impl + thin driver wrapper
596// ---------------------------------------------------------------------------
597
598/// Renderer that emits `package:test` `test(...)` blocks using `dart:io HttpClient`
599/// against the mock server (`Platform.environment['MOCK_SERVER_URL']`).
600///
601/// Skipped tests are emitted as self-contained stubs (complete test block with
602/// `markTestSkipped`) entirely inside `render_test_open`. `render_test_close` uses
603/// `in_skip` to emit the right closing token: nothing extra for skip stubs (already
604/// closed) vs. `})));` for regular tests.
605///
606/// `is_redirect` must be set to `true` before invoking the shared driver for 3xx
607/// fixtures so that `render_call` can inject `ioReq.followRedirects = false` after
608/// the `openUrl` call.
609struct DartTestClientRenderer {
610    /// Set to `true` when `render_test_open` is called with a skip reason so that
611    /// `render_test_close` can match the opening shape.
612    in_skip: Cell<bool>,
613    /// Pre-set to `true` by the thin wrapper when the fixture expects a 3xx response.
614    /// `render_call` injects `ioReq.followRedirects = false` when this is `true`.
615    is_redirect: Cell<bool>,
616}
617
618impl DartTestClientRenderer {
619    fn new(is_redirect: bool) -> Self {
620        Self {
621            in_skip: Cell::new(false),
622            is_redirect: Cell::new(is_redirect),
623        }
624    }
625}
626
627impl client::TestClientRenderer for DartTestClientRenderer {
628    fn language_name(&self) -> &'static str {
629        "dart"
630    }
631
632    /// Emit the test opening.
633    ///
634    /// For skipped fixtures: emit the entire self-contained stub (open + body +
635    /// close + blank line) and set `in_skip = true` so `render_test_close` is a
636    /// no-op.
637    ///
638    /// For active fixtures: emit `test('desc', () => _serialized(() => _withRetry(() async {`
639    /// leaving the block open for the assertion primitives.
640    fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
641        let escaped_desc = escape_dart(description);
642        if let Some(reason) = skip_reason {
643            let escaped_reason = escape_dart(reason);
644            let _ = writeln!(out, "  test('{escaped_desc}', () {{");
645            let _ = writeln!(out, "    markTestSkipped('{escaped_reason}');");
646            let _ = writeln!(out, "  }});");
647            let _ = writeln!(out);
648            self.in_skip.set(true);
649        } else {
650            let _ = writeln!(
651                out,
652                "  test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
653            );
654            self.in_skip.set(false);
655        }
656    }
657
658    /// Emit the test closing token.
659    ///
660    /// No-op for skip stubs (the stub was fully closed in `render_test_open`).
661    /// Emits `})));` followed by a blank line for regular tests.
662    fn render_test_close(&self, out: &mut String) {
663        if self.in_skip.get() {
664            // Stub was already closed in render_test_open.
665            return;
666        }
667        let _ = writeln!(out, "  }})));");
668        let _ = writeln!(out);
669    }
670
671    /// Emit the full `dart:io HttpClient` request scaffolding.
672    ///
673    /// Emits:
674    /// - URL construction from `MOCK_SERVER_URL`.
675    /// - `_httpClient.openUrl(method, uri)`.
676    /// - `followRedirects = false` when `is_redirect` is pre-set on the renderer.
677    /// - Content-Type header, request headers, cookies, optional body bytes.
678    /// - `ioReq.close()` → `ioResp`.
679    /// - Response-body drain into `bodyStr` (skipped for redirect responses).
680    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
681        // dart:io restricted headers (handled automatically by the HTTP stack).
682        const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
683
684        let method = ctx.method.to_uppercase();
685        let escaped_method = escape_dart(&method);
686
687        // Fixture path is `/fixtures/<id>` — extract the id portion for URL construction.
688        let fixture_path = escape_dart(ctx.path);
689
690        // Determine effective content-type.
691        let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
692        let effective_content_type = if has_explicit_content_type {
693            ctx.headers
694                .iter()
695                .find(|(k, _)| k.to_lowercase() == "content-type")
696                .map(|(_, v)| v.as_str())
697                .unwrap_or("application/json")
698        } else if ctx.body.is_some() {
699            ctx.content_type.unwrap_or("application/json")
700        } else {
701            ""
702        };
703
704        let _ = writeln!(
705            out,
706            "    final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
707        );
708        let _ = writeln!(out, "    final uri = Uri.parse('$baseUrl{fixture_path}');");
709        let _ = writeln!(
710            out,
711            "    final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
712        );
713
714        // Disable automatic redirect following for 3xx fixtures so the test can
715        // assert on the redirect status code itself.
716        if self.is_redirect.get() {
717            let _ = writeln!(out, "    ioReq.followRedirects = false;");
718        }
719
720        // Set content-type header.
721        if !effective_content_type.is_empty() {
722            let escaped_ct = escape_dart(effective_content_type);
723            let _ = writeln!(out, "    ioReq.headers.set('content-type', '{escaped_ct}');");
724        }
725
726        // Set request headers (skip dart:io restricted headers and content-type, already handled).
727        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
728        header_pairs.sort_by_key(|(k, _)| k.as_str());
729        for (name, value) in &header_pairs {
730            if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
731                continue;
732            }
733            if name.to_lowercase() == "content-type" {
734                continue; // Already handled above.
735            }
736            let escaped_name = escape_dart(&name.to_lowercase());
737            let escaped_value = escape_dart(value);
738            let _ = writeln!(out, "    ioReq.headers.set('{escaped_name}', '{escaped_value}');");
739        }
740
741        // Add cookies.
742        if !ctx.cookies.is_empty() {
743            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
744            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
745            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
746            let cookie_header = escape_dart(&cookie_str.join("; "));
747            let _ = writeln!(out, "    ioReq.headers.set('cookie', '{cookie_header}');");
748        }
749
750        // Write body bytes if present (bypass charset-based encoding issues).
751        if let Some(body) = ctx.body {
752            let json_str = serde_json::to_string(body).unwrap_or_default();
753            let escaped = escape_dart(&json_str);
754            let _ = writeln!(out, "    final bodyBytes = utf8.encode('{escaped}');");
755            let _ = writeln!(out, "    ioReq.add(bodyBytes);");
756        }
757
758        let _ = writeln!(out, "    final ioResp = await ioReq.close();");
759        // Drain the response body to bind `bodyStr` for assertion primitives and to
760        // allow the server to cleanly close the connection (prevents RST packets).
761        // Redirect responses have no body to drain — skip to avoid a potential hang.
762        if !self.is_redirect.get() {
763            let _ = writeln!(out, "    final bodyStr = await ioResp.transform(utf8.decoder).join();");
764        };
765    }
766
767    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
768        let _ = writeln!(
769            out,
770            "    expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
771        );
772    }
773
774    /// Emit a single header assertion, handling special tokens `<<present>>`,
775    /// `<<absent>>`, and `<<uuid>>`.
776    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
777        let escaped_name = escape_dart(&name.to_lowercase());
778        match expected {
779            "<<present>>" => {
780                let _ = writeln!(
781                    out,
782                    "    expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
783                );
784            }
785            "<<absent>>" => {
786                let _ = writeln!(
787                    out,
788                    "    expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
789                );
790            }
791            "<<uuid>>" => {
792                let _ = writeln!(
793                    out,
794                    "    expect(ioResp.headers.value('{escaped_name}'), matches(RegExp(r'^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$')), reason: 'header {escaped_name} should be a UUID');"
795                );
796            }
797            exact => {
798                let escaped_value = escape_dart(exact);
799                let _ = writeln!(
800                    out,
801                    "    expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
802                );
803            }
804        }
805    }
806
807    /// Emit an exact-equality body assertion.
808    ///
809    /// String bodies are compared as decoded text; structured JSON bodies are
810    /// compared via `jsonDecode`.
811    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
812        match expected {
813            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
814                let json_str = serde_json::to_string(expected).unwrap_or_default();
815                let escaped = escape_dart(&json_str);
816                let _ = writeln!(out, "    final bodyJson = jsonDecode(bodyStr);");
817                let _ = writeln!(out, "    final expectedJson = jsonDecode('{escaped}');");
818                let _ = writeln!(
819                    out,
820                    "    expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
821                );
822            }
823            serde_json::Value::String(s) => {
824                let escaped = escape_dart(s);
825                let _ = writeln!(
826                    out,
827                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
828                );
829            }
830            other => {
831                let escaped = escape_dart(&other.to_string());
832                let _ = writeln!(
833                    out,
834                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
835                );
836            }
837        }
838    }
839
840    /// Emit partial-body assertions — every key in `expected` must match the
841    /// corresponding field in the parsed JSON response.
842    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
843        let _ = writeln!(
844            out,
845            "    final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
846        );
847        if let Some(obj) = expected.as_object() {
848            for (idx, (key, val)) in obj.iter().enumerate() {
849                let escaped_key = escape_dart(key);
850                let json_val = serde_json::to_string(val).unwrap_or_default();
851                let escaped_val = escape_dart(&json_val);
852                // Use an index-based variable name so keys with special characters
853                // don't produce invalid Dart identifiers.
854                let _ = writeln!(out, "    final _expectedField{idx} = jsonDecode('{escaped_val}');");
855                let _ = writeln!(
856                    out,
857                    "    expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
858                );
859            }
860        }
861    }
862
863    /// Emit validation-error assertions for 422 responses.
864    fn render_assert_validation_errors(
865        &self,
866        out: &mut String,
867        _response_var: &str,
868        errors: &[ValidationErrorExpectation],
869    ) {
870        let _ = writeln!(out, "    final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
871        let _ = writeln!(out, "    final errList = (errBody['errors'] ?? []) as List<dynamic>;");
872        for ve in errors {
873            let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
874            let loc_str = loc_dart.join(", ");
875            let escaped_msg = escape_dart(&ve.msg);
876            let _ = writeln!(
877                out,
878                "    expect(errList.any((e) => e is Map && (e['loc'] as List?)?.join(',') == [{loc_str}].join(',') && (e['msg'] as String? ?? '').contains('{escaped_msg}')), isTrue, reason: 'validation error not found: {escaped_msg}');"
879            );
880        }
881    }
882}
883
884/// Render a `package:test` `test(...)` block for an HTTP server fixture.
885///
886/// Delegates to the shared [`client::http_call::render_http_test`] driver via
887/// [`DartTestClientRenderer`]. HTTP 101 (WebSocket upgrade) fixtures are emitted
888/// as skip stubs before reaching the driver because `dart:io HttpClient` cannot
889/// handle protocol-switch responses.
890fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
891    // HTTP 101 (WebSocket upgrade) — dart:io HttpClient cannot handle upgrade responses.
892    if http.expected_response.status_code == 101 {
893        let description = escape_dart(&fixture.description);
894        let _ = writeln!(out, "  test('{description}', () {{");
895        let _ = writeln!(
896            out,
897            "    markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
898        );
899        let _ = writeln!(out, "  }});");
900        let _ = writeln!(out);
901        return;
902    }
903
904    // Pre-set `is_redirect` on the renderer so `render_call` can inject
905    // `ioReq.followRedirects = false` for 3xx fixtures. The shared driver has no
906    // concept of expected status code so we thread it through renderer state.
907    let is_redirect = http.expected_response.status_code / 100 == 3;
908    client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
909}
910
911/// Infer a MIME type from a file path extension.
912///
913/// Returns `None` when the extension is unknown so the caller can supply a fallback.
914/// Used in dart e2e tests when a fixture omits `mime_type` but uses a `file_path` arg.
915fn mime_from_extension(path: &str) -> Option<&'static str> {
916    let ext = path.rsplit('.').next()?;
917    match ext.to_lowercase().as_str() {
918        "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
919        "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
920        "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
921        "pdf" => Some("application/pdf"),
922        "txt" | "text" => Some("text/plain"),
923        "html" | "htm" => Some("text/html"),
924        "json" => Some("application/json"),
925        "xml" => Some("application/xml"),
926        "csv" => Some("text/csv"),
927        "md" | "markdown" => Some("text/markdown"),
928        "png" => Some("image/png"),
929        "jpg" | "jpeg" => Some("image/jpeg"),
930        "gif" => Some("image/gif"),
931        "zip" => Some("application/zip"),
932        "odt" => Some("application/vnd.oasis.opendocument.text"),
933        "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
934        "odp" => Some("application/vnd.oasis.opendocument.presentation"),
935        "rtf" => Some("application/rtf"),
936        "epub" => Some("application/epub+zip"),
937        "msg" => Some("application/vnd.ms-outlook"),
938        "eml" => Some("message/rfc822"),
939        _ => None,
940    }
941}
942
943/// Emit Dart constructors for a batch item array (`BatchBytesItem` or `BatchFileItem`).
944///
945/// Returns a Dart list literal like:
946/// ```dart
947/// [BatchBytesItem(content: Uint8List.fromList([72, 101, ...]), mimeType: 'text/plain')]
948/// ```
949fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
950    let items: Vec<String> = arr
951        .as_array()
952        .map(|a| a.as_slice())
953        .unwrap_or_default()
954        .iter()
955        .filter_map(|item| {
956            let obj = item.as_object()?;
957            match elem_type {
958                "BatchBytesItem" => {
959                    let content_bytes = obj
960                        .get("content")
961                        .and_then(|v| v.as_array())
962                        .map(|arr| {
963                            let nums: Vec<String> =
964                                arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
965                            format!("Uint8List.fromList([{}])", nums.join(", "))
966                        })
967                        .unwrap_or_else(|| "Uint8List(0)".to_string());
968                    let mime_type = obj
969                        .get("mime_type")
970                        .and_then(|v| v.as_str())
971                        .unwrap_or("application/octet-stream");
972                    Some(format!(
973                        "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
974                        escape_dart(mime_type)
975                    ))
976                }
977                "BatchFileItem" => {
978                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
979                    Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
980                }
981                _ => None,
982            }
983        })
984        .collect();
985    format!("[{}]", items.join(", "))
986}
987
988/// Escape a string for embedding in a Dart single-quoted string literal.
989fn escape_dart(s: &str) -> String {
990    s.replace('\\', "\\\\")
991        .replace('\'', "\\'")
992        .replace('\n', "\\n")
993        .replace('\r', "\\r")
994        .replace('\t', "\\t")
995        .replace('$', "\\$")
996}