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::AlefConfig;
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 alef_config: &AlefConfig,
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(|| alef_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 .unwrap_or_else(|| "0.1.0".to_string());
54
55 files.push(GeneratedFile {
57 path: output_base.join("pubspec.yaml"),
58 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
59 generated_header: false,
60 });
61
62 files.push(GeneratedFile {
65 path: output_base.join("dart_test.yaml"),
66 content: concat!(
67 "# Generated by alef — DO NOT EDIT.\n",
68 "# Run test files sequentially to avoid overwhelming the mock server with\n",
69 "# concurrent keep-alive connections.\n",
70 "concurrency: 1\n",
71 )
72 .to_string(),
73 generated_header: false,
74 });
75
76 let test_base = output_base.join("test");
77
78 for group in groups {
80 let active: Vec<&Fixture> = group
81 .fixtures
82 .iter()
83 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
84 .collect();
85
86 if active.is_empty() {
87 continue;
88 }
89
90 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
91 let content = render_test_file(&group.category, &active, e2e_config, lang);
92 files.push(GeneratedFile {
93 path: test_base.join(filename),
94 content,
95 generated_header: true,
96 });
97 }
98
99 Ok(files)
100 }
101
102 fn language_name(&self) -> &'static str {
103 "dart"
104 }
105}
106
107fn render_pubspec(
112 pkg_name: &str,
113 pkg_path: &str,
114 pkg_version: &str,
115 dep_mode: crate::config::DependencyMode,
116) -> String {
117 let test_ver = pub_dev::TEST_PACKAGE;
118 let http_ver = pub_dev::HTTP_PACKAGE;
119
120 let dep_block = match dep_mode {
121 crate::config::DependencyMode::Registry => {
122 format!(" {pkg_name}: ^{pkg_version}")
123 }
124 crate::config::DependencyMode::Local => {
125 format!(" {pkg_name}:\n path: {pkg_path}")
126 }
127 };
128
129 format!(
130 r#"name: e2e_dart
131version: 0.1.0
132publish_to: none
133
134environment:
135 sdk: ">=3.0.0 <4.0.0"
136
137dependencies:
138{dep_block}
139
140dev_dependencies:
141 test: {test_ver}
142 http: {http_ver}
143"#
144 )
145}
146
147fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig, lang: &str) -> String {
148 let mut out = String::new();
149 out.push_str(&hash::header(CommentStyle::DoubleSlash));
150
151 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
153
154 let _ = writeln!(out, "import 'package:test/test.dart';");
155 let _ = writeln!(out, "import 'dart:io';");
156 if has_http_fixtures {
157 let _ = writeln!(out, "import 'dart:async';");
158 let _ = writeln!(out, "import 'dart:convert';");
159 }
160 let _ = writeln!(out);
161
162 if has_http_fixtures {
172 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
173 let _ = writeln!(out);
174 let _ = writeln!(out, "var _lock = Future<void>.value();");
175 let _ = writeln!(out);
176 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
177 let _ = writeln!(out, " final current = _lock;");
178 let _ = writeln!(out, " final next = Completer<void>();");
179 let _ = writeln!(out, " _lock = next.future;");
180 let _ = writeln!(out, " try {{");
181 let _ = writeln!(out, " await current;");
182 let _ = writeln!(out, " return await fn();");
183 let _ = writeln!(out, " }} finally {{");
184 let _ = writeln!(out, " next.complete();");
185 let _ = writeln!(out, " }}");
186 let _ = writeln!(out, "}}");
187 let _ = writeln!(out);
188 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
191 let _ = writeln!(out, " try {{");
192 let _ = writeln!(out, " return await fn();");
193 let _ = writeln!(out, " }} on SocketException {{");
194 let _ = writeln!(out, " _httpClient.close(force: true);");
195 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
196 let _ = writeln!(out, " return fn();");
197 let _ = writeln!(out, " }} on HttpException {{");
198 let _ = writeln!(out, " _httpClient.close(force: true);");
199 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
200 let _ = writeln!(out, " return fn();");
201 let _ = writeln!(out, " }}");
202 let _ = writeln!(out, "}}");
203 let _ = writeln!(out);
204 }
205
206 let _ = writeln!(out, "// E2e tests for category: {category}");
207 let _ = writeln!(out, "void main() {{");
208
209 if has_http_fixtures {
211 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
212 let _ = writeln!(out);
213 }
214
215 for fixture in fixtures {
216 render_test_case(&mut out, fixture, e2e_config, lang);
217 }
218
219 let _ = writeln!(out, "}}");
220 out
221}
222
223fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) {
224 if let Some(http) = &fixture.http {
226 render_http_test_case(out, fixture, http);
227 return;
228 }
229
230 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
232 let call_overrides = call_config.overrides.get(lang);
233
234 if call_overrides.is_none() {
235 render_skip_stub(out, fixture);
237 return;
238 }
239
240 let function_name = call_overrides
242 .and_then(|o| o.function.as_ref())
243 .cloned()
244 .unwrap_or_else(|| call_config.function.clone());
245 let result_var = &call_config.result_var;
246 let description = escape_dart(&fixture.description);
247 let is_async = call_config.r#async;
248
249 if is_async {
250 let _ = writeln!(out, " test('{description}', () async {{");
251 } else {
252 let _ = writeln!(out, " test('{description}', () {{");
253 }
254
255 if is_async {
256 let _ = writeln!(out, " final {result_var} = await {function_name}();");
257 } else {
258 let _ = writeln!(out, " final {result_var} = {function_name}();");
259 }
260
261 let _ = writeln!(out, " }});");
262 let _ = writeln!(out);
263}
264
265struct DartTestClientRenderer {
281 in_skip: Cell<bool>,
284 is_redirect: Cell<bool>,
287}
288
289impl DartTestClientRenderer {
290 fn new(is_redirect: bool) -> Self {
291 Self {
292 in_skip: Cell::new(false),
293 is_redirect: Cell::new(is_redirect),
294 }
295 }
296}
297
298impl client::TestClientRenderer for DartTestClientRenderer {
299 fn language_name(&self) -> &'static str {
300 "dart"
301 }
302
303 fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
312 let escaped_desc = escape_dart(description);
313 if let Some(reason) = skip_reason {
314 let escaped_reason = escape_dart(reason);
315 let _ = writeln!(out, " test('{escaped_desc}', () {{");
316 let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
317 let _ = writeln!(out, " }});");
318 let _ = writeln!(out);
319 self.in_skip.set(true);
320 } else {
321 let _ = writeln!(
322 out,
323 " test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
324 );
325 self.in_skip.set(false);
326 }
327 }
328
329 fn render_test_close(&self, out: &mut String) {
334 if self.in_skip.get() {
335 return;
337 }
338 let _ = writeln!(out, " }})));");
339 let _ = writeln!(out);
340 }
341
342 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
352 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
354
355 let method = ctx.method.to_uppercase();
356 let escaped_method = escape_dart(&method);
357
358 let fixture_path = escape_dart(ctx.path);
360
361 let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
363 let effective_content_type = if has_explicit_content_type {
364 ctx.headers
365 .iter()
366 .find(|(k, _)| k.to_lowercase() == "content-type")
367 .map(|(_, v)| v.as_str())
368 .unwrap_or("application/json")
369 } else if ctx.body.is_some() {
370 ctx.content_type.unwrap_or("application/json")
371 } else {
372 ""
373 };
374
375 let _ = writeln!(
376 out,
377 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
378 );
379 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
380 let _ = writeln!(
381 out,
382 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
383 );
384
385 if self.is_redirect.get() {
388 let _ = writeln!(out, " ioReq.followRedirects = false;");
389 }
390
391 if !effective_content_type.is_empty() {
393 let escaped_ct = escape_dart(effective_content_type);
394 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
395 }
396
397 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
399 header_pairs.sort_by_key(|(k, _)| k.as_str());
400 for (name, value) in &header_pairs {
401 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
402 continue;
403 }
404 if name.to_lowercase() == "content-type" {
405 continue; }
407 let escaped_name = escape_dart(&name.to_lowercase());
408 let escaped_value = escape_dart(value);
409 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
410 }
411
412 if !ctx.cookies.is_empty() {
414 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
415 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
416 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
417 let cookie_header = escape_dart(&cookie_str.join("; "));
418 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
419 }
420
421 if let Some(body) = ctx.body {
423 let json_str = serde_json::to_string(body).unwrap_or_default();
424 let escaped = escape_dart(&json_str);
425 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
426 let _ = writeln!(out, " ioReq.add(bodyBytes);");
427 }
428
429 let _ = writeln!(out, " final ioResp = await ioReq.close();");
430 if !self.is_redirect.get() {
434 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
435 };
436 }
437
438 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
439 let _ = writeln!(
440 out,
441 " expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
442 );
443 }
444
445 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
448 let escaped_name = escape_dart(&name.to_lowercase());
449 match expected {
450 "<<present>>" => {
451 let _ = writeln!(
452 out,
453 " expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
454 );
455 }
456 "<<absent>>" => {
457 let _ = writeln!(
458 out,
459 " expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
460 );
461 }
462 "<<uuid>>" => {
463 let _ = writeln!(
464 out,
465 " 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');"
466 );
467 }
468 exact => {
469 let escaped_value = escape_dart(exact);
470 let _ = writeln!(
471 out,
472 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
473 );
474 }
475 }
476 }
477
478 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
483 match expected {
484 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
485 let json_str = serde_json::to_string(expected).unwrap_or_default();
486 let escaped = escape_dart(&json_str);
487 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
488 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
489 let _ = writeln!(
490 out,
491 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
492 );
493 }
494 serde_json::Value::String(s) => {
495 let escaped = escape_dart(s);
496 let _ = writeln!(
497 out,
498 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
499 );
500 }
501 other => {
502 let escaped = escape_dart(&other.to_string());
503 let _ = writeln!(
504 out,
505 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
506 );
507 }
508 }
509 }
510
511 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
514 let _ = writeln!(
515 out,
516 " final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
517 );
518 if let Some(obj) = expected.as_object() {
519 for (idx, (key, val)) in obj.iter().enumerate() {
520 let escaped_key = escape_dart(key);
521 let json_val = serde_json::to_string(val).unwrap_or_default();
522 let escaped_val = escape_dart(&json_val);
523 let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
526 let _ = writeln!(
527 out,
528 " expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
529 );
530 }
531 }
532 }
533
534 fn render_assert_validation_errors(
536 &self,
537 out: &mut String,
538 _response_var: &str,
539 errors: &[ValidationErrorExpectation],
540 ) {
541 let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
542 let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
543 for ve in errors {
544 let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
545 let loc_str = loc_dart.join(", ");
546 let escaped_msg = escape_dart(&ve.msg);
547 let _ = writeln!(
548 out,
549 " 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}');"
550 );
551 }
552 }
553}
554
555fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
562 if http.expected_response.status_code == 101 {
564 let description = escape_dart(&fixture.description);
565 let _ = writeln!(out, " test('{description}', () {{");
566 let _ = writeln!(
567 out,
568 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
569 );
570 let _ = writeln!(out, " }});");
571 let _ = writeln!(out);
572 return;
573 }
574
575 let is_redirect = http.expected_response.status_code / 100 == 3;
579 client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
580}
581
582fn render_skip_stub(out: &mut String, fixture: &Fixture) {
584 let description = escape_dart(&fixture.description);
585 let fixture_id = &fixture.id;
586 let _ = writeln!(out, " test('{description}', () {{");
587 let _ = writeln!(
588 out,
589 " markTestSkipped('TODO: implement Dart e2e test for fixture \\'{fixture_id}\\'');"
590 );
591 let _ = writeln!(out, " }});");
592 let _ = writeln!(out);
593}
594
595fn escape_dart(s: &str) -> String {
597 s.replace('\\', "\\\\")
598 .replace('\'', "\\'")
599 .replace('\n', "\\n")
600 .replace('\r', "\\r")
601 .replace('\t', "\\t")
602 .replace('$', "\\$")
603}