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