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 if has_http_fixtures {
158 let _ = writeln!(out, "import 'dart:async';");
159 let _ = writeln!(out, "import 'dart:convert';");
160 }
161 let _ = writeln!(out);
162
163 if has_http_fixtures {
173 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
174 let _ = writeln!(out);
175 let _ = writeln!(out, "var _lock = Future<void>.value();");
176 let _ = writeln!(out);
177 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
178 let _ = writeln!(out, " final current = _lock;");
179 let _ = writeln!(out, " final next = Completer<void>();");
180 let _ = writeln!(out, " _lock = next.future;");
181 let _ = writeln!(out, " try {{");
182 let _ = writeln!(out, " await current;");
183 let _ = writeln!(out, " return await fn();");
184 let _ = writeln!(out, " }} finally {{");
185 let _ = writeln!(out, " next.complete();");
186 let _ = writeln!(out, " }}");
187 let _ = writeln!(out, "}}");
188 let _ = writeln!(out);
189 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
192 let _ = writeln!(out, " try {{");
193 let _ = writeln!(out, " return await fn();");
194 let _ = writeln!(out, " }} on SocketException {{");
195 let _ = writeln!(out, " _httpClient.close(force: true);");
196 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
197 let _ = writeln!(out, " return fn();");
198 let _ = writeln!(out, " }} on HttpException {{");
199 let _ = writeln!(out, " _httpClient.close(force: true);");
200 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
201 let _ = writeln!(out, " return fn();");
202 let _ = writeln!(out, " }}");
203 let _ = writeln!(out, "}}");
204 let _ = writeln!(out);
205 }
206
207 let _ = writeln!(out, "// E2e tests for category: {category}");
208 let _ = writeln!(out, "void main() {{");
209
210 if has_http_fixtures {
212 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
213 let _ = writeln!(out);
214 }
215
216 for fixture in fixtures {
217 render_test_case(&mut out, fixture, e2e_config, lang);
218 }
219
220 let _ = writeln!(out, "}}");
221 out
222}
223
224fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) {
225 if let Some(http) = &fixture.http {
227 render_http_test_case(out, fixture, http);
228 return;
229 }
230
231 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
233 let call_overrides = call_config.overrides.get(lang);
234
235 if call_overrides.is_none() {
236 render_skip_stub(out, fixture);
238 return;
239 }
240
241 let function_name = call_overrides
243 .and_then(|o| o.function.as_ref())
244 .cloned()
245 .unwrap_or_else(|| call_config.function.clone());
246 let result_var = &call_config.result_var;
247 let description = escape_dart(&fixture.description);
248 let is_async = call_config.r#async;
249
250 if is_async {
251 let _ = writeln!(out, " test('{description}', () async {{");
252 } else {
253 let _ = writeln!(out, " test('{description}', () {{");
254 }
255
256 if is_async {
257 let _ = writeln!(out, " final {result_var} = await {function_name}();");
258 } else {
259 let _ = writeln!(out, " final {result_var} = {function_name}();");
260 }
261
262 let _ = writeln!(out, " }});");
263 let _ = writeln!(out);
264}
265
266struct DartTestClientRenderer {
282 in_skip: Cell<bool>,
285 is_redirect: Cell<bool>,
288}
289
290impl DartTestClientRenderer {
291 fn new(is_redirect: bool) -> Self {
292 Self {
293 in_skip: Cell::new(false),
294 is_redirect: Cell::new(is_redirect),
295 }
296 }
297}
298
299impl client::TestClientRenderer for DartTestClientRenderer {
300 fn language_name(&self) -> &'static str {
301 "dart"
302 }
303
304 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
313 let escaped_desc = escape_dart(description);
314 if let Some(reason) = skip_reason {
315 let escaped_reason = escape_dart(reason);
316 let _ = writeln!(out, " test('{escaped_desc}', () {{");
317 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
318 let _ = writeln!(out, " }});");
319 let _ = writeln!(out);
320 self.in_skip.set(true);
321 } else {
322 let _ = writeln!(
323 out,
324 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
325 );
326 self.in_skip.set(false);
327 }
328 }
329
330 fn render_test_close(&self, out: &mut String) {
335 if self.in_skip.get() {
336 return;
338 }
339 let _ = writeln!(out, " }})));");
340 let _ = writeln!(out);
341 }
342
343 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
353 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
355
356 let method = ctx.method.to_uppercase();
357 let escaped_method = escape_dart(&method);
358
359 let fixture_path = escape_dart(ctx.path);
361
362 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
364 let effective_content_type = if has_explicit_content_type {
365 ctx.headers
366 .iter()
367 .find(|(k, _)| k.to_lowercase() == "content-type")
368 .map(|(_, v)| v.as_str())
369 .unwrap_or("application/json")
370 } else if ctx.body.is_some() {
371 ctx.content_type.unwrap_or("application/json")
372 } else {
373 ""
374 };
375
376 let _ = writeln!(
377 out,
378 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
379 );
380 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
381 let _ = writeln!(
382 out,
383 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
384 );
385
386 if self.is_redirect.get() {
389 let _ = writeln!(out, " ioReq.followRedirects = false;");
390 }
391
392 if !effective_content_type.is_empty() {
394 let escaped_ct = escape_dart(effective_content_type);
395 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
396 }
397
398 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
400 header_pairs.sort_by_key(|(k, _)| k.as_str());
401 for (name, value) in &header_pairs {
402 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
403 continue;
404 }
405 if name.to_lowercase() == "content-type" {
406 continue; }
408 let escaped_name = escape_dart(&name.to_lowercase());
409 let escaped_value = escape_dart(value);
410 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
411 }
412
413 if !ctx.cookies.is_empty() {
415 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
416 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
417 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
418 let cookie_header = escape_dart(&cookie_str.join("; "));
419 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
420 }
421
422 if let Some(body) = ctx.body {
424 let json_str = serde_json::to_string(body).unwrap_or_default();
425 let escaped = escape_dart(&json_str);
426 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
427 let _ = writeln!(out, " ioReq.add(bodyBytes);");
428 }
429
430 let _ = writeln!(out, " final ioResp = await ioReq.close();");
431 if !self.is_redirect.get() {
435 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
436 };
437 }
438
439 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
440 let _ = writeln!(
441 out,
442 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
443 );
444 }
445
446 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
449 let escaped_name = escape_dart(&name.to_lowercase());
450 match expected {
451 "<<present>>" => {
452 let _ = writeln!(
453 out,
454 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
455 );
456 }
457 "<<absent>>" => {
458 let _ = writeln!(
459 out,
460 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
461 );
462 }
463 "<<uuid>>" => {
464 let _ = writeln!(
465 out,
466 " 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');"
467 );
468 }
469 exact => {
470 let escaped_value = escape_dart(exact);
471 let _ = writeln!(
472 out,
473 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
474 );
475 }
476 }
477 }
478
479 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
484 match expected {
485 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
486 let json_str = serde_json::to_string(expected).unwrap_or_default();
487 let escaped = escape_dart(&json_str);
488 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
489 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
490 let _ = writeln!(
491 out,
492 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
493 );
494 }
495 serde_json::Value::String(s) => {
496 let escaped = escape_dart(s);
497 let _ = writeln!(
498 out,
499 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
500 );
501 }
502 other => {
503 let escaped = escape_dart(&other.to_string());
504 let _ = writeln!(
505 out,
506 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
507 );
508 }
509 }
510 }
511
512 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
515 let _ = writeln!(
516 out,
517 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
518 );
519 if let Some(obj) = expected.as_object() {
520 for (idx, (key, val)) in obj.iter().enumerate() {
521 let escaped_key = escape_dart(key);
522 let json_val = serde_json::to_string(val).unwrap_or_default();
523 let escaped_val = escape_dart(&json_val);
524 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
527 let _ = writeln!(
528 out,
529 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
530 );
531 }
532 }
533 }
534
535 fn render_assert_validation_errors(
537 &self,
538 out: &mut String,
539 _response_var: &str,
540 errors: &[ValidationErrorExpectation],
541 ) {
542 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
543 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
544 for ve in errors {
545 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
546 let loc_str = loc_dart.join(", ");
547 let escaped_msg = escape_dart(&ve.msg);
548 let _ = writeln!(
549 out,
550 " 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}');"
551 );
552 }
553 }
554}
555
556fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
563 if http.expected_response.status_code == 101 {
565 let description = escape_dart(&fixture.description);
566 let _ = writeln!(out, " test('{description}', () {{");
567 let _ = writeln!(
568 out,
569 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
570 );
571 let _ = writeln!(out, " }});");
572 let _ = writeln!(out);
573 return;
574 }
575
576 let is_redirect = http.expected_response.status_code / 100 == 3;
580 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
581}
582
583fn render_skip_stub(out: &mut String, fixture: &Fixture) {
585 let description = escape_dart(&fixture.description);
586 let fixture_id = &fixture.id;
587 let _ = writeln!(out, " test('{description}', () {{");
588 let _ = writeln!(
589 out,
590 " markTestSkipped('TODO: implement Dart e2e test for fixture \\'{fixture_id}\\'');"
591 );
592 let _ = writeln!(out, " }});");
593 let _ = writeln!(out);
594}
595
596fn escape_dart(s: &str) -> String {
598 s.replace('\\', "\\\\")
599 .replace('\'', "\\'")
600 .replace('\n', "\\n")
601 .replace('\r', "\\r")
602 .replace('\t', "\\t")
603 .replace('$', "\\$")
604}