1use 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
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!(
499 "final {var_name} = await {dart_fn}(json: '{escaped_json}');"
500 ));
501 args.push(format!("req: {var_name}"));
504 }
505 }
506 } else if arg_def.name == "config" {
507 if let serde_json::Value::Object(map) = &arg_value {
508 if !map.is_empty() {
512 args.push(emit_extraction_config_dart(map));
513 }
514 }
515 } else if arg_value.is_array() {
517 let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
520 let var_name = arg_def.name.clone();
521 setup_lines.push(format!(
522 "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
523 ));
524 args.push(var_name);
525 }
526 }
527 _ => {}
528 }
529 }
530
531 let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
535 e2e_config
536 .call
537 .overrides
538 .get(lang)
539 .and_then(|o| o.client_factory.as_deref())
540 });
541
542 let client_factory_camel: Option<String> = client_factory.map(|f| {
544 f.split('_')
545 .enumerate()
546 .map(|(i, part)| {
547 if i == 0 {
548 part.to_string()
549 } else {
550 let mut chars = part.chars();
551 match chars.next() {
552 None => String::new(),
553 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
554 }
555 }
556 })
557 .collect::<Vec<_>>()
558 .join("")
559 });
560
561 let _ = writeln!(out, " test('{description}', () async {{");
565
566 let args_str = args.join(", ");
567 let receiver_class = call_overrides
568 .and_then(|o| o.class.as_ref())
569 .cloned()
570 .unwrap_or_else(|| bridge_class.to_string());
571
572 let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
576 let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
577 let mock_url_setup = if !has_mock_url {
578 if fixture.has_host_root_route() {
580 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
581 Some(format!(
582 "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
583 ))
584 } else {
585 Some(format!(
586 r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
587 ))
588 }
589 } else {
590 None
591 };
592 let url_expr = if has_mock_url {
593 call_config
596 .args
597 .iter()
598 .find(|a| a.arg_type == "mock_url")
599 .map(|a| a.name.clone())
600 .unwrap_or_else(|| "_mockUrl".to_string())
601 } else {
602 "_mockUrl".to_string()
603 };
604 let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
605 let full_setup = if let Some(url_line) = mock_url_setup {
606 Some(format!("{url_line}\n {create_line}"))
607 } else {
608 Some(create_line)
609 };
610 ("_client".to_string(), full_setup)
611 } else {
612 (receiver_class.clone(), None)
613 };
614
615 if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
616 let _ = writeln!(out, " await expectLater(() async {{");
620 for line in &setup_lines {
621 let _ = writeln!(out, " {line}");
622 }
623 if let Some(extra) = &extra_setup {
624 for line in extra.lines() {
625 let _ = writeln!(out, " {line}");
626 }
627 }
628 let _ = writeln!(out, " return {receiver}.{function_name}({args_str});");
629 let _ = writeln!(out, " }}(), throwsA(anything));");
630 } else if expects_error {
631 if let Some(extra) = &extra_setup {
633 for line in extra.lines() {
634 let _ = writeln!(out, " {line}");
635 }
636 }
637 let _ = writeln!(
638 out,
639 " await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
640 );
641 } else {
642 for line in &setup_lines {
643 let _ = writeln!(out, " {line}");
644 }
645 if let Some(extra) = &extra_setup {
646 for line in extra.lines() {
647 let _ = writeln!(out, " {line}");
648 }
649 }
650 let _ = writeln!(
651 out,
652 " final {result_var} = await {receiver}.{function_name}({args_str});"
653 );
654 }
655
656 let _ = writeln!(out, " }});");
657 let _ = writeln!(out);
658}
659
660fn snake_to_camel(s: &str) -> String {
662 let mut result = String::with_capacity(s.len());
663 let mut next_upper = false;
664 for ch in s.chars() {
665 if ch == '_' {
666 next_upper = true;
667 } else if next_upper {
668 result.extend(ch.to_uppercase());
669 next_upper = false;
670 } else {
671 result.push(ch);
672 }
673 }
674 result
675}
676
677fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
683 let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
685 for (key, val) in overrides {
686 let camel = snake_to_camel(key);
687 let dart_val = match val {
688 serde_json::Value::Bool(b) => {
689 if *b {
690 "true".to_string()
691 } else {
692 "false".to_string()
693 }
694 }
695 serde_json::Value::Number(n) => n.to_string(),
696 serde_json::Value::String(s) => format!("'{s}'"),
697 _ => continue, };
699 field_overrides.insert(camel, dart_val);
700 }
701
702 let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
703 let enable_quality_processing = field_overrides
704 .remove("enableQualityProcessing")
705 .unwrap_or_else(|| "true".to_string());
706 let force_ocr = field_overrides
707 .remove("forceOcr")
708 .unwrap_or_else(|| "false".to_string());
709 let disable_ocr = field_overrides
710 .remove("disableOcr")
711 .unwrap_or_else(|| "false".to_string());
712 let include_document_structure = field_overrides
713 .remove("includeDocumentStructure")
714 .unwrap_or_else(|| "false".to_string());
715 let max_archive_depth = field_overrides
716 .remove("maxArchiveDepth")
717 .unwrap_or_else(|| "3".to_string());
718
719 format!(
720 "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})"
721 )
722}
723
724struct DartTestClientRenderer {
740 in_skip: Cell<bool>,
743 is_redirect: Cell<bool>,
746}
747
748impl DartTestClientRenderer {
749 fn new(is_redirect: bool) -> Self {
750 Self {
751 in_skip: Cell::new(false),
752 is_redirect: Cell::new(is_redirect),
753 }
754 }
755}
756
757impl client::TestClientRenderer for DartTestClientRenderer {
758 fn language_name(&self) -> &'static str {
759 "dart"
760 }
761
762 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
771 let escaped_desc = escape_dart(description);
772 if let Some(reason) = skip_reason {
773 let escaped_reason = escape_dart(reason);
774 let _ = writeln!(out, " test('{escaped_desc}', () {{");
775 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
776 let _ = writeln!(out, " }});");
777 let _ = writeln!(out);
778 self.in_skip.set(true);
779 } else {
780 let _ = writeln!(
781 out,
782 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
783 );
784 self.in_skip.set(false);
785 }
786 }
787
788 fn render_test_close(&self, out: &mut String) {
793 if self.in_skip.get() {
794 return;
796 }
797 let _ = writeln!(out, " }})));");
798 let _ = writeln!(out);
799 }
800
801 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
811 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
813
814 let method = ctx.method.to_uppercase();
815 let escaped_method = escape_dart(&method);
816
817 let fixture_path = escape_dart(ctx.path);
819
820 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
822 let effective_content_type = if has_explicit_content_type {
823 ctx.headers
824 .iter()
825 .find(|(k, _)| k.to_lowercase() == "content-type")
826 .map(|(_, v)| v.as_str())
827 .unwrap_or("application/json")
828 } else if ctx.body.is_some() {
829 ctx.content_type.unwrap_or("application/json")
830 } else {
831 ""
832 };
833
834 let _ = writeln!(
835 out,
836 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
837 );
838 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
839 let _ = writeln!(
840 out,
841 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
842 );
843
844 if self.is_redirect.get() {
847 let _ = writeln!(out, " ioReq.followRedirects = false;");
848 }
849
850 if !effective_content_type.is_empty() {
852 let escaped_ct = escape_dart(effective_content_type);
853 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
854 }
855
856 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
858 header_pairs.sort_by_key(|(k, _)| k.as_str());
859 for (name, value) in &header_pairs {
860 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
861 continue;
862 }
863 if name.to_lowercase() == "content-type" {
864 continue; }
866 let escaped_name = escape_dart(&name.to_lowercase());
867 let escaped_value = escape_dart(value);
868 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
869 }
870
871 if !ctx.cookies.is_empty() {
873 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
874 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
875 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
876 let cookie_header = escape_dart(&cookie_str.join("; "));
877 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
878 }
879
880 if let Some(body) = ctx.body {
882 let json_str = serde_json::to_string(body).unwrap_or_default();
883 let escaped = escape_dart(&json_str);
884 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
885 let _ = writeln!(out, " ioReq.add(bodyBytes);");
886 }
887
888 let _ = writeln!(out, " final ioResp = await ioReq.close();");
889 if !self.is_redirect.get() {
893 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
894 };
895 }
896
897 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
898 let _ = writeln!(
899 out,
900 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
901 );
902 }
903
904 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
907 let escaped_name = escape_dart(&name.to_lowercase());
908 match expected {
909 "<<present>>" => {
910 let _ = writeln!(
911 out,
912 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
913 );
914 }
915 "<<absent>>" => {
916 let _ = writeln!(
917 out,
918 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
919 );
920 }
921 "<<uuid>>" => {
922 let _ = writeln!(
923 out,
924 " 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');"
925 );
926 }
927 exact => {
928 let escaped_value = escape_dart(exact);
929 let _ = writeln!(
930 out,
931 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
932 );
933 }
934 }
935 }
936
937 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
942 match expected {
943 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
944 let json_str = serde_json::to_string(expected).unwrap_or_default();
945 let escaped = escape_dart(&json_str);
946 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
947 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
948 let _ = writeln!(
949 out,
950 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
951 );
952 }
953 serde_json::Value::String(s) => {
954 let escaped = escape_dart(s);
955 let _ = writeln!(
956 out,
957 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
958 );
959 }
960 other => {
961 let escaped = escape_dart(&other.to_string());
962 let _ = writeln!(
963 out,
964 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
965 );
966 }
967 }
968 }
969
970 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
973 let _ = writeln!(
974 out,
975 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
976 );
977 if let Some(obj) = expected.as_object() {
978 for (idx, (key, val)) in obj.iter().enumerate() {
979 let escaped_key = escape_dart(key);
980 let json_val = serde_json::to_string(val).unwrap_or_default();
981 let escaped_val = escape_dart(&json_val);
982 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
985 let _ = writeln!(
986 out,
987 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
988 );
989 }
990 }
991 }
992
993 fn render_assert_validation_errors(
995 &self,
996 out: &mut String,
997 _response_var: &str,
998 errors: &[ValidationErrorExpectation],
999 ) {
1000 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1001 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1002 for ve in errors {
1003 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1004 let loc_str = loc_dart.join(", ");
1005 let escaped_msg = escape_dart(&ve.msg);
1006 let _ = writeln!(
1007 out,
1008 " 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}');"
1009 );
1010 }
1011 }
1012}
1013
1014fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1021 if http.expected_response.status_code == 101 {
1023 let description = escape_dart(&fixture.description);
1024 let _ = writeln!(out, " test('{description}', () {{");
1025 let _ = writeln!(
1026 out,
1027 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1028 );
1029 let _ = writeln!(out, " }});");
1030 let _ = writeln!(out);
1031 return;
1032 }
1033
1034 let is_redirect = http.expected_response.status_code / 100 == 3;
1038 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1039}
1040
1041fn mime_from_extension(path: &str) -> Option<&'static str> {
1046 let ext = path.rsplit('.').next()?;
1047 match ext.to_lowercase().as_str() {
1048 "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1049 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1050 "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1051 "pdf" => Some("application/pdf"),
1052 "txt" | "text" => Some("text/plain"),
1053 "html" | "htm" => Some("text/html"),
1054 "json" => Some("application/json"),
1055 "xml" => Some("application/xml"),
1056 "csv" => Some("text/csv"),
1057 "md" | "markdown" => Some("text/markdown"),
1058 "png" => Some("image/png"),
1059 "jpg" | "jpeg" => Some("image/jpeg"),
1060 "gif" => Some("image/gif"),
1061 "zip" => Some("application/zip"),
1062 "odt" => Some("application/vnd.oasis.opendocument.text"),
1063 "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1064 "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1065 "rtf" => Some("application/rtf"),
1066 "epub" => Some("application/epub+zip"),
1067 "msg" => Some("application/vnd.ms-outlook"),
1068 "eml" => Some("message/rfc822"),
1069 _ => None,
1070 }
1071}
1072
1073fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1080 let items: Vec<String> = arr
1081 .as_array()
1082 .map(|a| a.as_slice())
1083 .unwrap_or_default()
1084 .iter()
1085 .filter_map(|item| {
1086 let obj = item.as_object()?;
1087 match elem_type {
1088 "BatchBytesItem" => {
1089 let content_bytes = obj
1090 .get("content")
1091 .and_then(|v| v.as_array())
1092 .map(|arr| {
1093 let nums: Vec<String> =
1094 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1095 format!("Uint8List.fromList([{}])", nums.join(", "))
1096 })
1097 .unwrap_or_else(|| "Uint8List(0)".to_string());
1098 let mime_type = obj
1099 .get("mime_type")
1100 .and_then(|v| v.as_str())
1101 .unwrap_or("application/octet-stream");
1102 Some(format!(
1103 "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1104 escape_dart(mime_type)
1105 ))
1106 }
1107 "BatchFileItem" => {
1108 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1109 Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1110 }
1111 _ => None,
1112 }
1113 })
1114 .collect();
1115 format!("[{}]", items.join(", "))
1116}
1117
1118fn escape_dart(s: &str) -> String {
1120 s.replace('\\', "\\\\")
1121 .replace('\'', "\\'")
1122 .replace('\n', "\\n")
1123 .replace('\r', "\\r")
1124 .replace('\t', "\\t")
1125 .replace('$', "\\$")
1126}
1127
1128fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1136 let mut snake = String::with_capacity(type_name.len() + 8);
1138 for (i, ch) in type_name.char_indices() {
1139 if ch.is_uppercase() {
1140 if i > 0 {
1141 snake.push('_');
1142 }
1143 snake.extend(ch.to_lowercase());
1144 } else {
1145 snake.push(ch);
1146 }
1147 }
1148 let rust_fn = format!("create_{snake}_from_json");
1151 rust_fn
1153 .split('_')
1154 .enumerate()
1155 .map(|(i, part)| {
1156 if i == 0 {
1157 part.to_string()
1158 } else {
1159 let mut chars = part.chars();
1160 match chars.next() {
1161 None => String::new(),
1162 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1163 }
1164 }
1165 })
1166 .collect::<Vec<_>>()
1167 .join("")
1168}