1use crate::config::E2eConfig;
8use crate::escape::sanitize_filename;
9use crate::fixture::{Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions::pub_dev;
14use anyhow::Result;
15use std::cell::Cell;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20use super::client;
21
22pub struct DartE2eCodegen;
24
25impl E2eCodegen for DartE2eCodegen {
26 fn generate(
27 &self,
28 groups: &[FixtureGroup],
29 e2e_config: &E2eConfig,
30 config: &ResolvedCrateConfig,
31 ) -> Result<Vec<GeneratedFile>> {
32 let lang = self.language_name();
33 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35 let mut files = Vec::new();
36
37 let dart_pkg = e2e_config.resolve_package("dart");
39 let pkg_name = dart_pkg
40 .as_ref()
41 .and_then(|p| p.name.as_ref())
42 .cloned()
43 .unwrap_or_else(|| config.dart_pubspec_name());
44 let pkg_path = dart_pkg
45 .as_ref()
46 .and_then(|p| p.path.as_ref())
47 .cloned()
48 .unwrap_or_else(|| "../../packages/dart".to_string());
49 let pkg_version = dart_pkg
50 .as_ref()
51 .and_then(|p| p.version.as_ref())
52 .cloned()
53 .or_else(|| config.resolved_version())
54 .unwrap_or_else(|| "0.1.0".to_string());
55
56 files.push(GeneratedFile {
58 path: output_base.join("pubspec.yaml"),
59 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
60 generated_header: false,
61 });
62
63 files.push(GeneratedFile {
66 path: output_base.join("dart_test.yaml"),
67 content: concat!(
68 "# Generated by alef — DO NOT EDIT.\n",
69 "# Run test files sequentially to avoid overwhelming the mock server with\n",
70 "# concurrent keep-alive connections.\n",
71 "concurrency: 1\n",
72 )
73 .to_string(),
74 generated_header: false,
75 });
76
77 let test_base = output_base.join("test");
78
79 for group in groups {
81 let active: Vec<&Fixture> = group
82 .fixtures
83 .iter()
84 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
85 .collect();
86
87 if active.is_empty() {
88 continue;
89 }
90
91 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
92 let content = render_test_file(&group.category, &active, e2e_config, lang);
93 files.push(GeneratedFile {
94 path: test_base.join(filename),
95 content,
96 generated_header: true,
97 });
98 }
99
100 Ok(files)
101 }
102
103 fn language_name(&self) -> &'static str {
104 "dart"
105 }
106}
107
108fn render_pubspec(
113 pkg_name: &str,
114 pkg_path: &str,
115 pkg_version: &str,
116 dep_mode: crate::config::DependencyMode,
117) -> String {
118 let test_ver = pub_dev::TEST_PACKAGE;
119 let http_ver = pub_dev::HTTP_PACKAGE;
120
121 let dep_block = match dep_mode {
122 crate::config::DependencyMode::Registry => {
123 format!(" {pkg_name}: ^{pkg_version}")
124 }
125 crate::config::DependencyMode::Local => {
126 format!(" {pkg_name}:\n path: {pkg_path}")
127 }
128 };
129
130 format!(
131 r#"name: e2e_dart
132version: 0.1.0
133publish_to: none
134
135environment:
136 sdk: ">=3.0.0 <4.0.0"
137
138dependencies:
139{dep_block}
140
141dev_dependencies:
142 test: {test_ver}
143 http: {http_ver}
144"#
145 )
146}
147
148fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig, lang: &str) -> String {
149 let mut out = String::new();
150 out.push_str(&hash::header(CommentStyle::DoubleSlash));
151
152 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
154
155 let _ = writeln!(out, "import 'package:test/test.dart';");
156 let _ = writeln!(out, "import 'dart:io';");
157 let _ = writeln!(out, "import 'package:kreuzberg/kreuzberg.dart';");
158 if has_http_fixtures {
159 let _ = writeln!(out, "import 'dart:async';");
160 let _ = writeln!(out, "import 'dart:convert';");
161 }
162 let _ = writeln!(out);
163
164 if has_http_fixtures {
174 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
175 let _ = writeln!(out);
176 let _ = writeln!(out, "var _lock = Future<void>.value();");
177 let _ = writeln!(out);
178 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
179 let _ = writeln!(out, " final current = _lock;");
180 let _ = writeln!(out, " final next = Completer<void>();");
181 let _ = writeln!(out, " _lock = next.future;");
182 let _ = writeln!(out, " try {{");
183 let _ = writeln!(out, " await current;");
184 let _ = writeln!(out, " return await fn();");
185 let _ = writeln!(out, " }} finally {{");
186 let _ = writeln!(out, " next.complete();");
187 let _ = writeln!(out, " }}");
188 let _ = writeln!(out, "}}");
189 let _ = writeln!(out);
190 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
193 let _ = writeln!(out, " try {{");
194 let _ = writeln!(out, " return await fn();");
195 let _ = writeln!(out, " }} on SocketException {{");
196 let _ = writeln!(out, " _httpClient.close(force: true);");
197 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
198 let _ = writeln!(out, " return fn();");
199 let _ = writeln!(out, " }} on HttpException {{");
200 let _ = writeln!(out, " _httpClient.close(force: true);");
201 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
202 let _ = writeln!(out, " return fn();");
203 let _ = writeln!(out, " }}");
204 let _ = writeln!(out, "}}");
205 let _ = writeln!(out);
206 }
207
208 let _ = writeln!(out, "// E2e tests for category: {category}");
209 let _ = writeln!(out, "void main() {{");
210
211 if has_http_fixtures {
213 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
214 let _ = writeln!(out);
215 }
216
217 for fixture in fixtures {
218 render_test_case(&mut out, fixture, e2e_config, lang);
219 }
220
221 let _ = writeln!(out, "}}");
222 out
223}
224
225fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) {
226 if let Some(http) = &fixture.http {
228 render_http_test_case(out, fixture, http);
229 return;
230 }
231
232 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
234 let call_overrides = call_config.overrides.get(lang);
235 let mut function_name = call_overrides
236 .and_then(|o| o.function.as_ref())
237 .cloned()
238 .unwrap_or_else(|| call_config.function.clone());
239 function_name = function_name
241 .split('_')
242 .enumerate()
243 .map(|(i, part)| {
244 if i == 0 {
245 part.to_string()
246 } else {
247 let mut chars = part.chars();
248 match chars.next() {
249 None => String::new(),
250 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
251 }
252 }
253 })
254 .collect::<Vec<_>>()
255 .join("");
256 let result_var = &call_config.result_var;
257 let description = escape_dart(&fixture.description);
258 let is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
259
260 let mut args = Vec::new();
262 for arg_def in &call_config.args {
263 let arg_value = fixture.input.get(&arg_def.name);
264 match arg_def.arg_type.as_str() {
265 "file_path" | "bytes" => {
266 if let Some(serde_json::Value::String(file_path)) = arg_value {
267 args.push(format!("File('{}').readAsBytesSync()", file_path));
268 }
269 }
270 "string" => {
271 if let Some(serde_json::Value::String(s)) = arg_value {
272 args.push(format!("'{}'", escape_dart(s)));
273 }
274 }
275 _ => {}
276 }
277 }
278
279 if is_async {
280 let _ = writeln!(out, " test('{description}', () async {{");
281 } else {
282 let _ = writeln!(out, " test('{description}', () {{");
283 }
284
285 let args_str = args.join(", ");
287 let receiver_class = call_overrides
288 .and_then(|o| o.class.as_ref())
289 .cloned()
290 .unwrap_or_else(|| "KreuzbergBridge".to_string());
291
292 if is_async {
293 let _ = writeln!(
294 out,
295 " final {result_var} = await {receiver_class}.{function_name}({args_str});"
296 );
297 } else {
298 let _ = writeln!(
299 out,
300 " final {result_var} = {receiver_class}.{function_name}({args_str});"
301 );
302 }
303
304 let _ = writeln!(out, " }});");
305 let _ = writeln!(out);
306}
307
308struct DartTestClientRenderer {
324 in_skip: Cell<bool>,
327 is_redirect: Cell<bool>,
330}
331
332impl DartTestClientRenderer {
333 fn new(is_redirect: bool) -> Self {
334 Self {
335 in_skip: Cell::new(false),
336 is_redirect: Cell::new(is_redirect),
337 }
338 }
339}
340
341impl client::TestClientRenderer for DartTestClientRenderer {
342 fn language_name(&self) -> &'static str {
343 "dart"
344 }
345
346 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
355 let escaped_desc = escape_dart(description);
356 if let Some(reason) = skip_reason {
357 let escaped_reason = escape_dart(reason);
358 let _ = writeln!(out, " test('{escaped_desc}', () {{");
359 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
360 let _ = writeln!(out, " }});");
361 let _ = writeln!(out);
362 self.in_skip.set(true);
363 } else {
364 let _ = writeln!(
365 out,
366 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
367 );
368 self.in_skip.set(false);
369 }
370 }
371
372 fn render_test_close(&self, out: &mut String) {
377 if self.in_skip.get() {
378 return;
380 }
381 let _ = writeln!(out, " }})));");
382 let _ = writeln!(out);
383 }
384
385 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
395 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
397
398 let method = ctx.method.to_uppercase();
399 let escaped_method = escape_dart(&method);
400
401 let fixture_path = escape_dart(ctx.path);
403
404 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
406 let effective_content_type = if has_explicit_content_type {
407 ctx.headers
408 .iter()
409 .find(|(k, _)| k.to_lowercase() == "content-type")
410 .map(|(_, v)| v.as_str())
411 .unwrap_or("application/json")
412 } else if ctx.body.is_some() {
413 ctx.content_type.unwrap_or("application/json")
414 } else {
415 ""
416 };
417
418 let _ = writeln!(
419 out,
420 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
421 );
422 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
423 let _ = writeln!(
424 out,
425 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
426 );
427
428 if self.is_redirect.get() {
431 let _ = writeln!(out, " ioReq.followRedirects = false;");
432 }
433
434 if !effective_content_type.is_empty() {
436 let escaped_ct = escape_dart(effective_content_type);
437 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
438 }
439
440 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
442 header_pairs.sort_by_key(|(k, _)| k.as_str());
443 for (name, value) in &header_pairs {
444 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
445 continue;
446 }
447 if name.to_lowercase() == "content-type" {
448 continue; }
450 let escaped_name = escape_dart(&name.to_lowercase());
451 let escaped_value = escape_dart(value);
452 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
453 }
454
455 if !ctx.cookies.is_empty() {
457 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
458 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
459 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
460 let cookie_header = escape_dart(&cookie_str.join("; "));
461 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
462 }
463
464 if let Some(body) = ctx.body {
466 let json_str = serde_json::to_string(body).unwrap_or_default();
467 let escaped = escape_dart(&json_str);
468 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
469 let _ = writeln!(out, " ioReq.add(bodyBytes);");
470 }
471
472 let _ = writeln!(out, " final ioResp = await ioReq.close();");
473 if !self.is_redirect.get() {
477 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
478 };
479 }
480
481 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
482 let _ = writeln!(
483 out,
484 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
485 );
486 }
487
488 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
491 let escaped_name = escape_dart(&name.to_lowercase());
492 match expected {
493 "<<present>>" => {
494 let _ = writeln!(
495 out,
496 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
497 );
498 }
499 "<<absent>>" => {
500 let _ = writeln!(
501 out,
502 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
503 );
504 }
505 "<<uuid>>" => {
506 let _ = writeln!(
507 out,
508 " 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');"
509 );
510 }
511 exact => {
512 let escaped_value = escape_dart(exact);
513 let _ = writeln!(
514 out,
515 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
516 );
517 }
518 }
519 }
520
521 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
526 match expected {
527 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
528 let json_str = serde_json::to_string(expected).unwrap_or_default();
529 let escaped = escape_dart(&json_str);
530 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
531 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
532 let _ = writeln!(
533 out,
534 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
535 );
536 }
537 serde_json::Value::String(s) => {
538 let escaped = escape_dart(s);
539 let _ = writeln!(
540 out,
541 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
542 );
543 }
544 other => {
545 let escaped = escape_dart(&other.to_string());
546 let _ = writeln!(
547 out,
548 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
549 );
550 }
551 }
552 }
553
554 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
557 let _ = writeln!(
558 out,
559 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
560 );
561 if let Some(obj) = expected.as_object() {
562 for (idx, (key, val)) in obj.iter().enumerate() {
563 let escaped_key = escape_dart(key);
564 let json_val = serde_json::to_string(val).unwrap_or_default();
565 let escaped_val = escape_dart(&json_val);
566 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
569 let _ = writeln!(
570 out,
571 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
572 );
573 }
574 }
575 }
576
577 fn render_assert_validation_errors(
579 &self,
580 out: &mut String,
581 _response_var: &str,
582 errors: &[ValidationErrorExpectation],
583 ) {
584 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
585 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
586 for ve in errors {
587 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
588 let loc_str = loc_dart.join(", ");
589 let escaped_msg = escape_dart(&ve.msg);
590 let _ = writeln!(
591 out,
592 " 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}');"
593 );
594 }
595 }
596}
597
598fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
605 if http.expected_response.status_code == 101 {
607 let description = escape_dart(&fixture.description);
608 let _ = writeln!(out, " test('{description}', () {{");
609 let _ = writeln!(
610 out,
611 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
612 );
613 let _ = writeln!(out, " }});");
614 let _ = writeln!(out);
615 return;
616 }
617
618 let is_redirect = http.expected_response.status_code / 100 == 3;
622 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
623}
624
625fn escape_dart(s: &str) -> String {
627 s.replace('\\', "\\\\")
628 .replace('\'', "\\'")
629 .replace('\n', "\\n")
630 .replace('\r', "\\r")
631 .replace('\t', "\\t")
632 .replace('$', "\\$")
633}