1use crate::config::E2eConfig;
8use crate::escape::sanitize_filename;
9use crate::fixture::{Fixture, FixtureGroup, HttpFixture};
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::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20pub struct DartE2eCodegen;
22
23impl E2eCodegen for DartE2eCodegen {
24 fn generate(
25 &self,
26 groups: &[FixtureGroup],
27 e2e_config: &E2eConfig,
28 alef_config: &AlefConfig,
29 ) -> Result<Vec<GeneratedFile>> {
30 let lang = self.language_name();
31 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32
33 let mut files = Vec::new();
34
35 let dart_pkg = e2e_config.resolve_package("dart");
37 let pkg_name = dart_pkg
38 .as_ref()
39 .and_then(|p| p.name.as_ref())
40 .cloned()
41 .unwrap_or_else(|| alef_config.dart_pubspec_name());
42 let pkg_path = dart_pkg
43 .as_ref()
44 .and_then(|p| p.path.as_ref())
45 .cloned()
46 .unwrap_or_else(|| "../../packages/dart".to_string());
47 let pkg_version = dart_pkg
48 .as_ref()
49 .and_then(|p| p.version.as_ref())
50 .cloned()
51 .unwrap_or_else(|| "0.1.0".to_string());
52
53 files.push(GeneratedFile {
55 path: output_base.join("pubspec.yaml"),
56 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
57 generated_header: false,
58 });
59
60 files.push(GeneratedFile {
63 path: output_base.join("dart_test.yaml"),
64 content: concat!(
65 "# Generated by alef — DO NOT EDIT.\n",
66 "# Run test files sequentially to avoid overwhelming the mock server with\n",
67 "# concurrent keep-alive connections.\n",
68 "concurrency: 1\n",
69 )
70 .to_string(),
71 generated_header: false,
72 });
73
74 let test_base = output_base.join("test");
75
76 for group in groups {
78 let active: Vec<&Fixture> = group
79 .fixtures
80 .iter()
81 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
82 .collect();
83
84 if active.is_empty() {
85 continue;
86 }
87
88 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
89 let content = render_test_file(&group.category, &active, e2e_config, lang);
90 files.push(GeneratedFile {
91 path: test_base.join(filename),
92 content,
93 generated_header: true,
94 });
95 }
96
97 Ok(files)
98 }
99
100 fn language_name(&self) -> &'static str {
101 "dart"
102 }
103}
104
105fn render_pubspec(
110 pkg_name: &str,
111 pkg_path: &str,
112 pkg_version: &str,
113 dep_mode: crate::config::DependencyMode,
114) -> String {
115 let test_ver = pub_dev::TEST_PACKAGE;
116 let http_ver = pub_dev::HTTP_PACKAGE;
117
118 let dep_block = match dep_mode {
119 crate::config::DependencyMode::Registry => {
120 format!(" {pkg_name}: ^{pkg_version}")
121 }
122 crate::config::DependencyMode::Local => {
123 format!(" {pkg_name}:\n path: {pkg_path}")
124 }
125 };
126
127 format!(
128 r#"name: e2e_dart
129version: 0.1.0
130publish_to: none
131
132environment:
133 sdk: ">=3.0.0 <4.0.0"
134
135dependencies:
136{dep_block}
137
138dev_dependencies:
139 test: {test_ver}
140 http: {http_ver}
141"#
142 )
143}
144
145fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig, lang: &str) -> String {
146 let mut out = String::new();
147 out.push_str(&hash::header(CommentStyle::DoubleSlash));
148
149 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
151
152 let _ = writeln!(out, "import 'package:test/test.dart';");
153 let _ = writeln!(out, "import 'dart:io';");
154 if has_http_fixtures {
155 let _ = writeln!(out, "import 'dart:async';");
156 let _ = writeln!(out, "import 'dart:convert';");
157 }
158 let _ = writeln!(out);
159
160 if has_http_fixtures {
170 let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
171 let _ = writeln!(out);
172 let _ = writeln!(out, "var _lock = Future<void>.value();");
173 let _ = writeln!(out);
174 let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
175 let _ = writeln!(out, " final current = _lock;");
176 let _ = writeln!(out, " final next = Completer<void>();");
177 let _ = writeln!(out, " _lock = next.future;");
178 let _ = writeln!(out, " try {{");
179 let _ = writeln!(out, " await current;");
180 let _ = writeln!(out, " return await fn();");
181 let _ = writeln!(out, " }} finally {{");
182 let _ = writeln!(out, " next.complete();");
183 let _ = writeln!(out, " }}");
184 let _ = writeln!(out, "}}");
185 let _ = writeln!(out);
186 let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
189 let _ = writeln!(out, " try {{");
190 let _ = writeln!(out, " return await fn();");
191 let _ = writeln!(out, " }} on SocketException {{");
192 let _ = writeln!(out, " _httpClient.close(force: true);");
193 let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
194 let _ = writeln!(out, " return fn();");
195 let _ = writeln!(out, " }} on HttpException {{");
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, " }}");
200 let _ = writeln!(out, "}}");
201 let _ = writeln!(out);
202 }
203
204 let _ = writeln!(out, "// E2e tests for category: {category}");
205 let _ = writeln!(out, "void main() {{");
206
207 if has_http_fixtures {
209 let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
210 let _ = writeln!(out);
211 }
212
213 for fixture in fixtures {
214 render_test_case(&mut out, fixture, e2e_config, lang);
215 }
216
217 let _ = writeln!(out, "}}");
218 out
219}
220
221fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) {
222 if let Some(http) = &fixture.http {
224 render_http_test_case(out, fixture, http);
225 return;
226 }
227
228 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
230 let call_overrides = call_config.overrides.get(lang);
231
232 if call_overrides.is_none() {
233 render_skip_stub(out, fixture);
235 return;
236 }
237
238 let function_name = call_overrides
240 .and_then(|o| o.function.as_ref())
241 .cloned()
242 .unwrap_or_else(|| call_config.function.clone());
243 let result_var = &call_config.result_var;
244 let description = escape_dart(&fixture.description);
245 let is_async = call_config.r#async;
246
247 if is_async {
248 let _ = writeln!(out, " test('{description}', () async {{");
249 } else {
250 let _ = writeln!(out, " test('{description}', () {{");
251 }
252
253 if is_async {
254 let _ = writeln!(out, " final {result_var} = await {function_name}();");
255 } else {
256 let _ = writeln!(out, " final {result_var} = {function_name}();");
257 }
258
259 let _ = writeln!(out, " }});");
260 let _ = writeln!(out);
261}
262
263fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
274 let description = escape_dart(&fixture.description);
275 let request = &http.request;
276 let expected = &http.expected_response;
277 let method = request.method.to_uppercase();
278 let fixture_id = &fixture.id;
279 let expected_status = expected.status_code;
280
281 if expected_status == 101 {
283 let _ = writeln!(out, " test('{description}', () {{");
284 let _ = writeln!(
285 out,
286 " markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
287 );
288 let _ = writeln!(out, " }});");
289 let _ = writeln!(out);
290 return;
291 }
292
293 const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
295
296 let has_explicit_content_type = request.headers.keys().any(|k| k.to_lowercase() == "content-type");
300 let effective_content_type = if has_explicit_content_type {
301 request
302 .headers
303 .iter()
304 .find(|(k, _)| k.to_lowercase() == "content-type")
305 .map(|(_, v)| v.as_str())
306 .unwrap_or("application/json")
307 } else if request.body.is_some() {
308 request.content_type.as_deref().unwrap_or("application/json")
309 } else {
310 ""
311 };
312
313 let has_body = request.body.is_some();
314 let escaped_method = escape_dart(&method);
315 let is_redirect = expected_status / 100 == 3;
316
317 let _ = writeln!(
318 out,
319 " test('{description}', () => _serialized(() => _withRetry(() async {{"
320 );
321 let _ = writeln!(
322 out,
323 " final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
324 );
325 let _ = writeln!(out, " final uri = Uri.parse('$baseUrl/fixtures/{fixture_id}');");
326
327 let _ = writeln!(
329 out,
330 " final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
331 );
332 if is_redirect {
334 let _ = writeln!(out, " ioReq.followRedirects = false;");
335 }
336
337 if !effective_content_type.is_empty() {
339 let escaped_ct = escape_dart(effective_content_type);
340 let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
341 }
342 for (name, value) in &request.headers {
343 if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
344 continue;
345 }
346 if name.to_lowercase() == "content-type" {
347 continue; }
349 let escaped_name = escape_dart(&name.to_lowercase());
350 let escaped_value = escape_dart(value);
351 let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
352 }
353 if !request.cookies.is_empty() {
355 let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
356 let cookie_header = escape_dart(&cookie_str.join("; "));
357 let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
358 }
359
360 if has_body {
362 let json_str = serde_json::to_string(&request.body).unwrap_or_default();
363 let escaped = escape_dart(&json_str);
364 let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
365 let _ = writeln!(out, " ioReq.add(bodyBytes);");
366 }
367
368 let _ = writeln!(out, " final ioResp = await ioReq.close();");
369 let _ = writeln!(
370 out,
371 " expect(ioResp.statusCode, equals({expected_status}), reason: 'status code mismatch');"
372 );
373
374 let needs_body_read = !is_redirect && expected.body.is_some();
377 let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
378
379 if needs_body_read {
381 if let Some(expected_body) = &expected.body {
382 match expected_body {
383 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
384 let json_str = serde_json::to_string(expected_body).unwrap_or_default();
385 let escaped = escape_dart(&json_str);
386 let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
387 let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
388 let _ = writeln!(
389 out,
390 " expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
391 );
392 }
393 serde_json::Value::String(s) => {
394 let escaped = escape_dart(s);
395 let _ = writeln!(
396 out,
397 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
398 );
399 }
400 other => {
401 let escaped = escape_dart(&other.to_string());
402 let _ = writeln!(
403 out,
404 " expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
405 );
406 }
407 }
408 }
409 }
410
411 for (name, value) in &expected.headers {
413 if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
414 continue;
415 }
416 if name.to_lowercase() == "content-encoding" {
419 continue;
420 }
421 let escaped_name = escape_dart(&name.to_lowercase());
422 let escaped_value = escape_dart(value);
423 let _ = writeln!(
424 out,
425 " expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
426 );
427 }
428
429 let _ = writeln!(out, " }})));");
430 let _ = writeln!(out);
431}
432
433fn render_skip_stub(out: &mut String, fixture: &Fixture) {
435 let description = escape_dart(&fixture.description);
436 let fixture_id = &fixture.id;
437 let _ = writeln!(out, " test('{description}', () {{");
438 let _ = writeln!(
439 out,
440 " markTestSkipped('TODO: implement Dart e2e test for fixture \\'{fixture_id}\\'');"
441 );
442 let _ = writeln!(out, " }});");
443 let _ = writeln!(out);
444}
445
446fn escape_dart(s: &str) -> String {
448 s.replace('\\', "\\\\")
449 .replace('\'', "\\'")
450 .replace('\n', "\\n")
451 .replace('\r', "\\r")
452 .replace('\t', "\\t")
453 .replace('$', "\\$")
454}