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