1use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::fixture::{Assertion, 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
23pub 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 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 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 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 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
112fn 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 let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
135 format!(
136 r#"name: e2e_dart
137version: 0.1.0
138publish_to: none
139
140environment:
141 sdk: "{sdk}"
142
143dependencies:
144{dep_block}
145
146dev_dependencies:
147 test: {test_ver}
148 http: {http_ver}
149"#
150 )
151}
152
153fn render_test_file(
154 category: &str,
155 fixtures: &[&Fixture],
156 e2e_config: &E2eConfig,
157 lang: &str,
158 pkg_name: &str,
159 bridge_class: &str,
160) -> String {
161 let mut out = String::new();
162 out.push_str(&hash::header(CommentStyle::DoubleSlash));
163
164 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
166
167 let has_batch_byte_items = fixtures.iter().any(|f| {
169 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
170 call_config.args.iter().any(|a| {
171 a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
172 })
173 });
174
175 let needs_chdir = fixtures.iter().any(|f| {
179 if f.is_http_test() {
180 return false;
181 }
182 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
183 call_config
184 .args
185 .iter()
186 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
187 });
188
189 let has_handle_args = fixtures.iter().any(|f| {
192 if f.is_http_test() {
193 return false;
194 }
195 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
196 call_config.args.iter().any(|a| a.arg_type == "handle")
197 });
198
199 let _ = writeln!(out, "import 'package:test/test.dart';");
200 let _ = writeln!(out, "import 'dart:io';");
201 if has_batch_byte_items {
202 let _ = writeln!(out, "import 'dart:typed_data';");
203 }
204 let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
205 let _ = writeln!(
208 out,
209 "import 'package:{pkg_name}/src/{pkg_name}_bridge_generated/frb_generated.dart' show RustLib;"
210 );
211 if has_http_fixtures {
212 let _ = writeln!(out, "import 'dart:async';");
213 }
214 if has_http_fixtures || has_handle_args {
216 let _ = writeln!(out, "import 'dart:convert';");
217 }
218 let _ = writeln!(out);
219
220 if has_http_fixtures {
230 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
231 let _ = writeln!(out);
232 let _ = writeln!(out, "var _lock = Future<void>.value();");
233 let _ = writeln!(out);
234 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
235 let _ = writeln!(out, " final current = _lock;");
236 let _ = writeln!(out, " final next = Completer<void>();");
237 let _ = writeln!(out, " _lock = next.future;");
238 let _ = writeln!(out, " try {{");
239 let _ = writeln!(out, " await current;");
240 let _ = writeln!(out, " return await fn();");
241 let _ = writeln!(out, " }} finally {{");
242 let _ = writeln!(out, " next.complete();");
243 let _ = writeln!(out, " }}");
244 let _ = writeln!(out, "}}");
245 let _ = writeln!(out);
246 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
249 let _ = writeln!(out, " try {{");
250 let _ = writeln!(out, " return await fn();");
251 let _ = writeln!(out, " }} on SocketException {{");
252 let _ = writeln!(out, " _httpClient.close(force: true);");
253 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
254 let _ = writeln!(out, " return fn();");
255 let _ = writeln!(out, " }} on HttpException {{");
256 let _ = writeln!(out, " _httpClient.close(force: true);");
257 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
258 let _ = writeln!(out, " return fn();");
259 let _ = writeln!(out, " }}");
260 let _ = writeln!(out, "}}");
261 let _ = writeln!(out);
262 }
263
264 let _ = writeln!(out, "// E2e tests for category: {category}");
265 let _ = writeln!(out, "void main() {{");
266
267 let _ = writeln!(out, " setUpAll(() async {{");
274 let _ = writeln!(out, " await RustLib.init();");
275 if needs_chdir {
276 let test_docs_path = e2e_config.test_documents_relative_from(0);
277 let _ = writeln!(
278 out,
279 " final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
280 );
281 let _ = writeln!(out, " final _dir = Directory(_testDocs);");
282 let _ = writeln!(out, " if (_dir.existsSync()) Directory.current = _dir;");
283 }
284 let _ = writeln!(out, " }});");
285 let _ = writeln!(out);
286
287 if has_http_fixtures {
289 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
290 let _ = writeln!(out);
291 }
292
293 for fixture in fixtures {
294 render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
295 }
296
297 let _ = writeln!(out, "}}");
298 out
299}
300
301fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
302 if let Some(http) = &fixture.http {
304 render_http_test_case(out, fixture, http);
305 return;
306 }
307
308 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
310 let call_overrides = call_config.overrides.get(lang);
311 let mut function_name = call_overrides
312 .and_then(|o| o.function.as_ref())
313 .cloned()
314 .unwrap_or_else(|| call_config.function.clone());
315 function_name = function_name
317 .split('_')
318 .enumerate()
319 .map(|(i, part)| {
320 if i == 0 {
321 part.to_string()
322 } else {
323 let mut chars = part.chars();
324 match chars.next() {
325 None => String::new(),
326 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
327 }
328 }
329 })
330 .collect::<Vec<_>>()
331 .join("");
332 let result_var = &call_config.result_var;
333 let description = escape_dart(&fixture.description);
334 let fixture_id = &fixture.id;
335 let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
338
339 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
340
341 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
348 let options_via: &str = call_overrides
349 .and_then(|o| o.options_via.as_deref())
350 .unwrap_or("kwargs");
351
352 let file_path_for_mime: Option<&str> = call_config
360 .args
361 .iter()
362 .find(|a| a.arg_type == "file_path")
363 .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
364
365 let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
372 let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
375 if has_file_path_arg && !caller_supplied_override {
376 function_name = match function_name.as_str() {
377 "extractFile" => "extractBytes".to_string(),
378 "extractFileSync" => "extractBytesSync".to_string(),
379 other => other.to_string(),
380 };
381 }
382
383 let mut setup_lines: Vec<String> = Vec::new();
386 let mut args = Vec::new();
387
388 for arg_def in &call_config.args {
389 match arg_def.arg_type.as_str() {
390 "mock_url" => {
391 let name = arg_def.name.clone();
392 if fixture.has_host_root_route() {
393 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
394 setup_lines.push(format!(
395 r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
396 ));
397 } else {
398 setup_lines.push(format!(
399 r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
400 ));
401 }
402 args.push(name);
403 continue;
404 }
405 "handle" => {
406 let name = arg_def.name.clone();
407 let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
408 let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
409 let create_fn = {
411 let mut chars = name.chars();
412 let pascal = match chars.next() {
413 None => String::new(),
414 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
415 };
416 format!("create{pascal}")
417 };
418 if config_value.is_null()
419 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
420 {
421 setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}(null);"));
422 } else {
423 let json_str = serde_json::to_string(&config_value).unwrap_or_default();
424 let config_var = format!("{name}Config");
425 setup_lines.push(format!(
426 "final {config_var} = CrawlConfig.fromJson(jsonDecode(r'{json_str}') as Map<String, dynamic>);"
427 ));
428 setup_lines.push(format!(
429 "final {name} = await {bridge_class}.{create_fn}({config_var});"
430 ));
431 }
432 args.push(name);
433 continue;
434 }
435 _ => {}
436 }
437
438 let arg_value = resolve_field(&fixture.input, &arg_def.field);
439 match arg_def.arg_type.as_str() {
440 "bytes" | "file_path" => {
441 if let serde_json::Value::String(file_path) = arg_value {
446 args.push(format!("File('{}').readAsBytesSync()", file_path));
447 }
448 }
449 "string" => {
450 let dart_param_name = snake_to_camel(&arg_def.name);
454 match arg_value {
455 serde_json::Value::String(s) => {
456 args.push(format!("{dart_param_name}: '{}'", escape_dart(s)));
457 }
458 serde_json::Value::Null
459 if arg_def.optional
460 && arg_def.name == "mime_type" =>
463 {
464 let inferred = file_path_for_mime
465 .and_then(mime_from_extension)
466 .unwrap_or("application/octet-stream");
467 args.push(format!("{dart_param_name}: '{inferred}'"));
468 }
469 _ => {}
471 }
472 }
473 "json_object" => {
474 if let Some(elem_type) = &arg_def.element_type {
476 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
477 let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
478 args.push(dart_items);
479 }
480 } else if options_via == "from_json" {
481 if let Some(opts_type) = options_type {
491 if !arg_value.is_null() {
492 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
493 let escaped_json = escape_dart(&json_str);
496 let var_name = format!("_{}", arg_def.name);
497 let dart_fn = type_name_to_create_from_json_dart(opts_type);
498 setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
499 args.push(format!("req: {var_name}"));
502 }
503 }
504 } else if arg_def.name == "config" {
505 if let serde_json::Value::Object(map) = &arg_value {
506 if !map.is_empty() {
510 args.push(emit_extraction_config_dart(map));
511 }
512 }
513 } else if arg_value.is_array() {
515 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
518 let var_name = arg_def.name.clone();
519 setup_lines.push(format!(
520 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
521 ));
522 args.push(var_name);
523 }
524 }
525 _ => {}
526 }
527 }
528
529 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
533 e2e_config
534 .call
535 .overrides
536 .get(lang)
537 .and_then(|o| o.client_factory.as_deref())
538 });
539
540 let client_factory_camel: Option<String> = client_factory.map(|f| {
542 f.split('_')
543 .enumerate()
544 .map(|(i, part)| {
545 if i == 0 {
546 part.to_string()
547 } else {
548 let mut chars = part.chars();
549 match chars.next() {
550 None => String::new(),
551 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
552 }
553 }
554 })
555 .collect::<Vec<_>>()
556 .join("")
557 });
558
559 let _ = writeln!(out, " test('{description}', () async {{");
563
564 let args_str = args.join(", ");
565 let receiver_class = call_overrides
566 .and_then(|o| o.class.as_ref())
567 .cloned()
568 .unwrap_or_else(|| bridge_class.to_string());
569
570 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
574 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
575 let mock_url_setup = if !has_mock_url {
576 if fixture.has_host_root_route() {
578 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
579 Some(format!(
580 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
581 ))
582 } else {
583 Some(format!(
584 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
585 ))
586 }
587 } else {
588 None
589 };
590 let url_expr = if has_mock_url {
591 call_config
594 .args
595 .iter()
596 .find(|a| a.arg_type == "mock_url")
597 .map(|a| a.name.clone())
598 .unwrap_or_else(|| "_mockUrl".to_string())
599 } else {
600 "_mockUrl".to_string()
601 };
602 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
603 let full_setup = if let Some(url_line) = mock_url_setup {
604 Some(format!("{url_line}\n {create_line}"))
605 } else {
606 Some(create_line)
607 };
608 ("_client".to_string(), full_setup)
609 } else {
610 (receiver_class.clone(), None)
611 };
612
613 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
614 let _ = writeln!(out, " await expectLater(() async {{");
618 for line in &setup_lines {
619 let _ = writeln!(out, " {line}");
620 }
621 if let Some(extra) = &extra_setup {
622 for line in extra.lines() {
623 let _ = writeln!(out, " {line}");
624 }
625 }
626 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
627 let _ = writeln!(out, " }}(), throwsA(anything));");
628 } else if expects_error {
629 if let Some(extra) = &extra_setup {
631 for line in extra.lines() {
632 let _ = writeln!(out, " {line}");
633 }
634 }
635 let _ = writeln!(
636 out,
637 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
638 );
639 } else {
640 for line in &setup_lines {
641 let _ = writeln!(out, " {line}");
642 }
643 if let Some(extra) = &extra_setup {
644 for line in extra.lines() {
645 let _ = writeln!(out, " {line}");
646 }
647 }
648 let _ = writeln!(
649 out,
650 " final {result_var} = await {receiver}.{function_name}({args_str});"
651 );
652 for assertion in &fixture.assertions {
653 render_assertion_dart(out, assertion, result_var);
654 }
655 }
656
657 let _ = writeln!(out, " }});");
658 let _ = writeln!(out);
659}
660
661fn render_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
670 let field_accessor = match assertion.field.as_deref() {
671 Some(f) if !f.is_empty() => format!("{result_var}.{}", snake_to_camel(f)),
672 _ => result_var.to_string(),
673 };
674
675 let format_value = |val: &serde_json::Value| -> String {
676 match val {
677 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
678 serde_json::Value::Bool(b) => b.to_string(),
679 serde_json::Value::Number(n) => n.to_string(),
680 serde_json::Value::Null => "null".to_string(),
681 other => format!("'{}'", escape_dart(&other.to_string())),
682 }
683 };
684
685 match assertion.assertion_type.as_str() {
686 "equals" | "field_equals" => {
687 if let Some(expected) = &assertion.value {
688 let dart_val = format_value(expected);
689 let _ = writeln!(out, " expect({field_accessor}, equals({dart_val}));");
690 } else {
691 let _ = writeln!(
692 out,
693 " // skipped: '{}' assertion missing value",
694 assertion.assertion_type
695 );
696 }
697 }
698 "contains" => {
699 if let Some(expected) = &assertion.value {
700 let dart_val = format_value(expected);
701 let _ = writeln!(out, " expect({field_accessor}, contains({dart_val}));");
702 } else {
703 let _ = writeln!(out, " // skipped: 'contains' assertion missing value");
704 }
705 }
706 "not_null" => {
707 let _ = writeln!(out, " expect({field_accessor}, isNotNull);");
708 }
709 "not_error" => {
710 }
712 other => {
713 let _ = writeln!(out, " // skipped: unknown assertion type '{other}'");
714 }
715 }
716}
717
718fn snake_to_camel(s: &str) -> String {
720 let mut result = String::with_capacity(s.len());
721 let mut next_upper = false;
722 for ch in s.chars() {
723 if ch == '_' {
724 next_upper = true;
725 } else if next_upper {
726 result.extend(ch.to_uppercase());
727 next_upper = false;
728 } else {
729 result.push(ch);
730 }
731 }
732 result
733}
734
735fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
741 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
743 for (key, val) in overrides {
744 let camel = snake_to_camel(key);
745 let dart_val = match val {
746 serde_json::Value::Bool(b) => {
747 if *b {
748 "true".to_string()
749 } else {
750 "false".to_string()
751 }
752 }
753 serde_json::Value::Number(n) => n.to_string(),
754 serde_json::Value::String(s) => format!("'{s}'"),
755 _ => continue, };
757 field_overrides.insert(camel, dart_val);
758 }
759
760 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
761 let enable_quality_processing = field_overrides
762 .remove("enableQualityProcessing")
763 .unwrap_or_else(|| "true".to_string());
764 let force_ocr = field_overrides
765 .remove("forceOcr")
766 .unwrap_or_else(|| "false".to_string());
767 let disable_ocr = field_overrides
768 .remove("disableOcr")
769 .unwrap_or_else(|| "false".to_string());
770 let include_document_structure = field_overrides
771 .remove("includeDocumentStructure")
772 .unwrap_or_else(|| "false".to_string());
773 let max_archive_depth = field_overrides
774 .remove("maxArchiveDepth")
775 .unwrap_or_else(|| "3".to_string());
776
777 format!(
778 "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})"
779 )
780}
781
782struct DartTestClientRenderer {
798 in_skip: Cell<bool>,
801 is_redirect: Cell<bool>,
804}
805
806impl DartTestClientRenderer {
807 fn new(is_redirect: bool) -> Self {
808 Self {
809 in_skip: Cell::new(false),
810 is_redirect: Cell::new(is_redirect),
811 }
812 }
813}
814
815impl client::TestClientRenderer for DartTestClientRenderer {
816 fn language_name(&self) -> &'static str {
817 "dart"
818 }
819
820 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
829 let escaped_desc = escape_dart(description);
830 if let Some(reason) = skip_reason {
831 let escaped_reason = escape_dart(reason);
832 let _ = writeln!(out, " test('{escaped_desc}', () {{");
833 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
834 let _ = writeln!(out, " }});");
835 let _ = writeln!(out);
836 self.in_skip.set(true);
837 } else {
838 let _ = writeln!(
839 out,
840 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
841 );
842 self.in_skip.set(false);
843 }
844 }
845
846 fn render_test_close(&self, out: &mut String) {
851 if self.in_skip.get() {
852 return;
854 }
855 let _ = writeln!(out, " }})));");
856 let _ = writeln!(out);
857 }
858
859 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
869 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
871
872 let method = ctx.method.to_uppercase();
873 let escaped_method = escape_dart(&method);
874
875 let fixture_path = escape_dart(ctx.path);
877
878 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
880 let effective_content_type = if has_explicit_content_type {
881 ctx.headers
882 .iter()
883 .find(|(k, _)| k.to_lowercase() == "content-type")
884 .map(|(_, v)| v.as_str())
885 .unwrap_or("application/json")
886 } else if ctx.body.is_some() {
887 ctx.content_type.unwrap_or("application/json")
888 } else {
889 ""
890 };
891
892 let _ = writeln!(
893 out,
894 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
895 );
896 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
897 let _ = writeln!(
898 out,
899 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
900 );
901
902 if self.is_redirect.get() {
905 let _ = writeln!(out, " ioReq.followRedirects = false;");
906 }
907
908 if !effective_content_type.is_empty() {
910 let escaped_ct = escape_dart(effective_content_type);
911 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
912 }
913
914 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
916 header_pairs.sort_by_key(|(k, _)| k.as_str());
917 for (name, value) in &header_pairs {
918 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
919 continue;
920 }
921 if name.to_lowercase() == "content-type" {
922 continue; }
924 let escaped_name = escape_dart(&name.to_lowercase());
925 let escaped_value = escape_dart(value);
926 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
927 }
928
929 if !ctx.cookies.is_empty() {
931 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
932 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
933 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
934 let cookie_header = escape_dart(&cookie_str.join("; "));
935 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
936 }
937
938 if let Some(body) = ctx.body {
940 let json_str = serde_json::to_string(body).unwrap_or_default();
941 let escaped = escape_dart(&json_str);
942 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
943 let _ = writeln!(out, " ioReq.add(bodyBytes);");
944 }
945
946 let _ = writeln!(out, " final ioResp = await ioReq.close();");
947 if !self.is_redirect.get() {
951 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
952 };
953 }
954
955 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
956 let _ = writeln!(
957 out,
958 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
959 );
960 }
961
962 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
965 let escaped_name = escape_dart(&name.to_lowercase());
966 match expected {
967 "<<present>>" => {
968 let _ = writeln!(
969 out,
970 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
971 );
972 }
973 "<<absent>>" => {
974 let _ = writeln!(
975 out,
976 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
977 );
978 }
979 "<<uuid>>" => {
980 let _ = writeln!(
981 out,
982 " 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');"
983 );
984 }
985 exact => {
986 let escaped_value = escape_dart(exact);
987 let _ = writeln!(
988 out,
989 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
990 );
991 }
992 }
993 }
994
995 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1000 match expected {
1001 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1002 let json_str = serde_json::to_string(expected).unwrap_or_default();
1003 let escaped = escape_dart(&json_str);
1004 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
1005 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
1006 let _ = writeln!(
1007 out,
1008 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1009 );
1010 }
1011 serde_json::Value::String(s) => {
1012 let escaped = escape_dart(s);
1013 let _ = writeln!(
1014 out,
1015 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1016 );
1017 }
1018 other => {
1019 let escaped = escape_dart(&other.to_string());
1020 let _ = writeln!(
1021 out,
1022 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1023 );
1024 }
1025 }
1026 }
1027
1028 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1031 let _ = writeln!(
1032 out,
1033 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1034 );
1035 if let Some(obj) = expected.as_object() {
1036 for (idx, (key, val)) in obj.iter().enumerate() {
1037 let escaped_key = escape_dart(key);
1038 let json_val = serde_json::to_string(val).unwrap_or_default();
1039 let escaped_val = escape_dart(&json_val);
1040 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
1043 let _ = writeln!(
1044 out,
1045 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1046 );
1047 }
1048 }
1049 }
1050
1051 fn render_assert_validation_errors(
1053 &self,
1054 out: &mut String,
1055 _response_var: &str,
1056 errors: &[ValidationErrorExpectation],
1057 ) {
1058 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1059 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1060 for ve in errors {
1061 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1062 let loc_str = loc_dart.join(", ");
1063 let escaped_msg = escape_dart(&ve.msg);
1064 let _ = writeln!(
1065 out,
1066 " 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}');"
1067 );
1068 }
1069 }
1070}
1071
1072fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1079 if http.expected_response.status_code == 101 {
1081 let description = escape_dart(&fixture.description);
1082 let _ = writeln!(out, " test('{description}', () {{");
1083 let _ = writeln!(
1084 out,
1085 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1086 );
1087 let _ = writeln!(out, " }});");
1088 let _ = writeln!(out);
1089 return;
1090 }
1091
1092 let is_redirect = http.expected_response.status_code / 100 == 3;
1096 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1097}
1098
1099fn mime_from_extension(path: &str) -> Option<&'static str> {
1104 let ext = path.rsplit('.').next()?;
1105 match ext.to_lowercase().as_str() {
1106 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1107 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1108 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1109 "pdf" => Some("application/pdf"),
1110 "txt" | "text" => Some("text/plain"),
1111 "html" | "htm" => Some("text/html"),
1112 "json" => Some("application/json"),
1113 "xml" => Some("application/xml"),
1114 "csv" => Some("text/csv"),
1115 "md" | "markdown" => Some("text/markdown"),
1116 "png" => Some("image/png"),
1117 "jpg" | "jpeg" => Some("image/jpeg"),
1118 "gif" => Some("image/gif"),
1119 "zip" => Some("application/zip"),
1120 "odt" => Some("application/vnd.oasis.opendocument.text"),
1121 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1122 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1123 "rtf" => Some("application/rtf"),
1124 "epub" => Some("application/epub+zip"),
1125 "msg" => Some("application/vnd.ms-outlook"),
1126 "eml" => Some("message/rfc822"),
1127 _ => None,
1128 }
1129}
1130
1131fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1138 let items: Vec<String> = arr
1139 .as_array()
1140 .map(|a| a.as_slice())
1141 .unwrap_or_default()
1142 .iter()
1143 .filter_map(|item| {
1144 let obj = item.as_object()?;
1145 match elem_type {
1146 "BatchBytesItem" => {
1147 let content_bytes = obj
1148 .get("content")
1149 .and_then(|v| v.as_array())
1150 .map(|arr| {
1151 let nums: Vec<String> =
1152 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1153 format!("Uint8List.fromList([{}])", nums.join(", "))
1154 })
1155 .unwrap_or_else(|| "Uint8List(0)".to_string());
1156 let mime_type = obj
1157 .get("mime_type")
1158 .and_then(|v| v.as_str())
1159 .unwrap_or("application/octet-stream");
1160 Some(format!(
1161 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1162 escape_dart(mime_type)
1163 ))
1164 }
1165 "BatchFileItem" => {
1166 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1167 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1168 }
1169 _ => None,
1170 }
1171 })
1172 .collect();
1173 format!("[{}]", items.join(", "))
1174}
1175
1176fn escape_dart(s: &str) -> String {
1178 s.replace('\\', "\\\\")
1179 .replace('\'', "\\'")
1180 .replace('\n', "\\n")
1181 .replace('\r', "\\r")
1182 .replace('\t', "\\t")
1183 .replace('$', "\\$")
1184}
1185
1186fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1194 let mut snake = String::with_capacity(type_name.len() + 8);
1196 for (i, ch) in type_name.char_indices() {
1197 if ch.is_uppercase() {
1198 if i > 0 {
1199 snake.push('_');
1200 }
1201 snake.extend(ch.to_lowercase());
1202 } else {
1203 snake.push(ch);
1204 }
1205 }
1206 let rust_fn = format!("create_{snake}_from_json");
1209 rust_fn
1211 .split('_')
1212 .enumerate()
1213 .map(|(i, part)| {
1214 if i == 0 {
1215 part.to_string()
1216 } else {
1217 let mut chars = part.chars();
1218 match chars.next() {
1219 None => String::new(),
1220 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1221 }
1222 }
1223 })
1224 .collect::<Vec<_>>()
1225 .join("")
1226}