Skip to main content

alef_e2e/codegen/
dart.rs

1//! Dart e2e test generator using package:test and package:http.
2//!
3//! Generates `e2e/dart/test/<category>_test.dart` files from JSON fixtures.
4//! HTTP fixtures hit the mock server at `MOCK_SERVER_URL/fixtures/<id>`.
5//! Non-HTTP fixtures without a dart-specific call override emit a skip stub.
6
7use 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
20/// Dart e2e code generator.
21pub 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        // Resolve package config.
36        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        // Generate pubspec.yaml with http dependency for HTTP client tests.
54        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        // Generate dart_test.yaml to limit parallelism — the mock server uses keep-alive
61        // connections and gets overwhelmed when test files run in parallel.
62        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        // One test file per fixture group.
77        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
105// ---------------------------------------------------------------------------
106// Rendering
107// ---------------------------------------------------------------------------
108
109fn 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    // Check if any fixture needs the http package (HTTP server tests).
150    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    // Emit file-level HTTP client and serialization mutex.
161    //
162    // The shared HttpClient reuses keep-alive connections to minimize TCP overhead.
163    // The mutex (_lock) ensures requests are serialized within the file so the
164    // connection pool is not exercised concurrently by dart:test's async runner.
165    //
166    // _withRetry wraps the entire request closure with one automatic retry on
167    // transient connection errors (keep-alive connections can be silently closed
168    // by the server just as the client tries to reuse them).
169    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        // The `fn` here should be the full request closure — on socket failure we
187        // recreate the HttpClient (drops old pooled connections) and retry once.
188        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    // Close the shared client after all tests in this file complete.
208    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    // HTTP fixtures: hit the mock server.
223    if let Some(http) = &fixture.http {
224        render_http_test_case(out, fixture, http);
225        return;
226    }
227
228    // Non-HTTP fixtures: check if there is a dart-specific call override.
229    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        // No dart-specific call override — emit a skip stub.
234        render_skip_stub(out, fixture);
235        return;
236    }
237
238    // Has a dart call override — render a call-based test.
239    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
263/// Render an HTTP server test using `dart:io` `HttpClient` against MOCK_SERVER_URL.
264///
265/// The mock server registers each fixture at `/fixtures/<fixture_id>` and returns
266/// the pre-canned response. Tests send the correct HTTP method and headers to that
267/// endpoint.
268///
269/// Uses `dart:io` `HttpClient` directly (not `package:http`) with
270/// `persistentConnection = false` on every request to avoid keep-alive connection
271/// reuse issues: when tests run concurrently, stale pooled connections from previous
272/// tests get reset by the mock server.
273fn 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    // Skip 101 Switching Protocols — dart:io HttpClient cannot handle protocol-switch responses.
282    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    // dart:io restricted headers (handled automatically by the HTTP stack).
294    const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
295
296    // Determine effective content-type:
297    // - If the fixture has an explicit Content-Type header, use that.
298    // - Otherwise, if there's a body, default to application/json.
299    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    // Use the shared client (keep-alive connection reuse with retry on failure).
328    let _ = writeln!(
329        out,
330        "    final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
331    );
332    // Disable automatic redirect following for redirect tests.
333    if is_redirect {
334        let _ = writeln!(out, "    ioReq.followRedirects = false;");
335    }
336
337    // Set headers.
338    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; // Already handled above.
348        }
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    // Add cookies.
354    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    // Write body bytes if present (bypass charset-based encoding issues).
361    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    // Always drain the response body to allow the server to cleanly close the connection.
375    // This prevents RST packets that corrupt subsequent requests.
376    let needs_body_read = !is_redirect && expected.body.is_some();
377    let _ = writeln!(out, "    final bodyStr = await ioResp.transform(utf8.decoder).join();");
378
379    // Assert body if expected (not for redirects — body is empty).
380    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    // Assert response headers if specified.
412    for (name, value) in &expected.headers {
413        if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
414            continue;
415        }
416        // content-encoding is set by the real server's compression middleware
417        // but the mock server doesn't compress bodies, so skip this assertion.
418        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
433/// Emit a compilable skip stub for non-HTTP fixtures without a dart call override.
434fn 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
446/// Escape a string for embedding in a Dart single-quoted string literal.
447fn 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}