use crate::config::E2eConfig;
use crate::escape::sanitize_filename;
use crate::fixture::{Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
use alef_core::backend::GeneratedFile;
use alef_core::config::ResolvedCrateConfig;
use alef_core::hash::{self, CommentStyle};
use alef_core::template_versions::pub_dev;
use anyhow::Result;
use std::cell::Cell;
use std::fmt::Write as FmtWrite;
use std::path::PathBuf;
use super::E2eCodegen;
use super::client;
pub struct DartE2eCodegen;
impl E2eCodegen for DartE2eCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let mut files = Vec::new();
let dart_pkg = e2e_config.resolve_package("dart");
let pkg_name = dart_pkg
.as_ref()
.and_then(|p| p.name.as_ref())
.cloned()
.unwrap_or_else(|| config.dart_pubspec_name());
let pkg_path = dart_pkg
.as_ref()
.and_then(|p| p.path.as_ref())
.cloned()
.unwrap_or_else(|| "../../packages/dart".to_string());
let pkg_version = dart_pkg
.as_ref()
.and_then(|p| p.version.as_ref())
.cloned()
.or_else(|| config.resolved_version())
.unwrap_or_else(|| "0.1.0".to_string());
files.push(GeneratedFile {
path: output_base.join("pubspec.yaml"),
content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
generated_header: false,
});
files.push(GeneratedFile {
path: output_base.join("dart_test.yaml"),
content: concat!(
"# Generated by alef — DO NOT EDIT.\n",
"# Run test files sequentially to avoid overwhelming the mock server with\n",
"# concurrent keep-alive connections.\n",
"concurrency: 1\n",
)
.to_string(),
generated_header: false,
});
let test_base = output_base.join("test");
for group in groups {
let active: Vec<&Fixture> = group
.fixtures
.iter()
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
.collect();
if active.is_empty() {
continue;
}
let filename = format!("{}_test.dart", sanitize_filename(&group.category));
let content = render_test_file(&group.category, &active, e2e_config, lang);
files.push(GeneratedFile {
path: test_base.join(filename),
content,
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"dart"
}
}
fn render_pubspec(
pkg_name: &str,
pkg_path: &str,
pkg_version: &str,
dep_mode: crate::config::DependencyMode,
) -> String {
let test_ver = pub_dev::TEST_PACKAGE;
let http_ver = pub_dev::HTTP_PACKAGE;
let dep_block = match dep_mode {
crate::config::DependencyMode::Registry => {
format!(" {pkg_name}: ^{pkg_version}")
}
crate::config::DependencyMode::Local => {
format!(" {pkg_name}:\n path: {pkg_path}")
}
};
format!(
r#"name: e2e_dart
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
{dep_block}
dev_dependencies:
test: {test_ver}
http: {http_ver}
"#
)
}
fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig, lang: &str) -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::DoubleSlash));
let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
let _ = writeln!(out, "import 'package:test/test.dart';");
let _ = writeln!(out, "import 'dart:io';");
let _ = writeln!(out, "import 'package:kreuzberg/kreuzberg.dart';");
if has_http_fixtures {
let _ = writeln!(out, "import 'dart:async';");
let _ = writeln!(out, "import 'dart:convert';");
}
let _ = writeln!(out);
if has_http_fixtures {
let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
let _ = writeln!(out);
let _ = writeln!(out, "var _lock = Future<void>.value();");
let _ = writeln!(out);
let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
let _ = writeln!(out, " final current = _lock;");
let _ = writeln!(out, " final next = Completer<void>();");
let _ = writeln!(out, " _lock = next.future;");
let _ = writeln!(out, " try {{");
let _ = writeln!(out, " await current;");
let _ = writeln!(out, " return await fn();");
let _ = writeln!(out, " }} finally {{");
let _ = writeln!(out, " next.complete();");
let _ = writeln!(out, " }}");
let _ = writeln!(out, "}}");
let _ = writeln!(out);
let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
let _ = writeln!(out, " try {{");
let _ = writeln!(out, " return await fn();");
let _ = writeln!(out, " }} on SocketException {{");
let _ = writeln!(out, " _httpClient.close(force: true);");
let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
let _ = writeln!(out, " return fn();");
let _ = writeln!(out, " }} on HttpException {{");
let _ = writeln!(out, " _httpClient.close(force: true);");
let _ = writeln!(out, " _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
let _ = writeln!(out, " return fn();");
let _ = writeln!(out, " }}");
let _ = writeln!(out, "}}");
let _ = writeln!(out);
}
let _ = writeln!(out, "// E2e tests for category: {category}");
let _ = writeln!(out, "void main() {{");
if has_http_fixtures {
let _ = writeln!(out, " tearDownAll(() => _httpClient.close());");
let _ = writeln!(out);
}
for fixture in fixtures {
render_test_case(&mut out, fixture, e2e_config, lang);
}
let _ = writeln!(out, "}}");
out
}
fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) {
if let Some(http) = &fixture.http {
render_http_test_case(out, fixture, http);
return;
}
let call_config = e2e_config.resolve_call(fixture.call.as_deref());
let call_overrides = call_config.overrides.get(lang);
let mut function_name = call_overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call_config.function.clone());
function_name = function_name
.split('_')
.enumerate()
.map(|(i, part)| {
if i == 0 {
part.to_string()
} else {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
})
.collect::<Vec<_>>()
.join("");
let result_var = &call_config.result_var;
let description = escape_dart(&fixture.description);
let is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
let mut args = Vec::new();
for arg_def in &call_config.args {
let arg_value = fixture.input.get(&arg_def.name);
match arg_def.arg_type.as_str() {
"file_path" | "bytes" => {
if let Some(serde_json::Value::String(file_path)) = arg_value {
args.push(format!("File('{}').readAsBytesSync()", file_path));
}
}
"string" => {
if let Some(serde_json::Value::String(s)) = arg_value {
args.push(format!("'{}'", escape_dart(s)));
}
}
_ => {}
}
}
if is_async {
let _ = writeln!(out, " test('{description}', () async {{");
} else {
let _ = writeln!(out, " test('{description}', () {{");
}
let args_str = args.join(", ");
let receiver_class = call_overrides
.and_then(|o| o.class.as_ref())
.cloned()
.unwrap_or_else(|| "KreuzbergBridge".to_string());
if is_async {
let _ = writeln!(
out,
" final {result_var} = await {receiver_class}.{function_name}({args_str});"
);
} else {
let _ = writeln!(
out,
" final {result_var} = {receiver_class}.{function_name}({args_str});"
);
}
let _ = writeln!(out, " }});");
let _ = writeln!(out);
}
struct DartTestClientRenderer {
in_skip: Cell<bool>,
is_redirect: Cell<bool>,
}
impl DartTestClientRenderer {
fn new(is_redirect: bool) -> Self {
Self {
in_skip: Cell::new(false),
is_redirect: Cell::new(is_redirect),
}
}
}
impl client::TestClientRenderer for DartTestClientRenderer {
fn language_name(&self) -> &'static str {
"dart"
}
fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
let escaped_desc = escape_dart(description);
if let Some(reason) = skip_reason {
let escaped_reason = escape_dart(reason);
let _ = writeln!(out, " test('{escaped_desc}', () {{");
let _ = writeln!(out, " markTestSkipped('{escaped_reason}');");
let _ = writeln!(out, " }});");
let _ = writeln!(out);
self.in_skip.set(true);
} else {
let _ = writeln!(
out,
" test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
);
self.in_skip.set(false);
}
}
fn render_test_close(&self, out: &mut String) {
if self.in_skip.get() {
return;
}
let _ = writeln!(out, " }})));");
let _ = writeln!(out);
}
fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
let method = ctx.method.to_uppercase();
let escaped_method = escape_dart(&method);
let fixture_path = escape_dart(ctx.path);
let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
let effective_content_type = if has_explicit_content_type {
ctx.headers
.iter()
.find(|(k, _)| k.to_lowercase() == "content-type")
.map(|(_, v)| v.as_str())
.unwrap_or("application/json")
} else if ctx.body.is_some() {
ctx.content_type.unwrap_or("application/json")
} else {
""
};
let _ = writeln!(
out,
" final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
);
let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
let _ = writeln!(
out,
" final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
);
if self.is_redirect.get() {
let _ = writeln!(out, " ioReq.followRedirects = false;");
}
if !effective_content_type.is_empty() {
let escaped_ct = escape_dart(effective_content_type);
let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
}
let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
header_pairs.sort_by_key(|(k, _)| k.as_str());
for (name, value) in &header_pairs {
if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
continue;
}
if name.to_lowercase() == "content-type" {
continue; }
let escaped_name = escape_dart(&name.to_lowercase());
let escaped_value = escape_dart(value);
let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
}
if !ctx.cookies.is_empty() {
let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
cookie_pairs.sort_by_key(|(k, _)| k.as_str());
let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
let cookie_header = escape_dart(&cookie_str.join("; "));
let _ = writeln!(out, " ioReq.headers.set('cookie', '{cookie_header}');");
}
if let Some(body) = ctx.body {
let json_str = serde_json::to_string(body).unwrap_or_default();
let escaped = escape_dart(&json_str);
let _ = writeln!(out, " final bodyBytes = utf8.encode('{escaped}');");
let _ = writeln!(out, " ioReq.add(bodyBytes);");
}
let _ = writeln!(out, " final ioResp = await ioReq.close();");
if !self.is_redirect.get() {
let _ = writeln!(out, " final bodyStr = await ioResp.transform(utf8.decoder).join();");
};
}
fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
let _ = writeln!(
out,
" expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
);
}
fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
let escaped_name = escape_dart(&name.to_lowercase());
match expected {
"<<present>>" => {
let _ = writeln!(
out,
" expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
);
}
"<<absent>>" => {
let _ = writeln!(
out,
" expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
);
}
"<<uuid>>" => {
let _ = writeln!(
out,
" 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');"
);
}
exact => {
let escaped_value = escape_dart(exact);
let _ = writeln!(
out,
" expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
);
}
}
}
fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
match expected {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
let json_str = serde_json::to_string(expected).unwrap_or_default();
let escaped = escape_dart(&json_str);
let _ = writeln!(out, " final bodyJson = jsonDecode(bodyStr);");
let _ = writeln!(out, " final expectedJson = jsonDecode('{escaped}');");
let _ = writeln!(
out,
" expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
);
}
serde_json::Value::String(s) => {
let escaped = escape_dart(s);
let _ = writeln!(
out,
" expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
);
}
other => {
let escaped = escape_dart(&other.to_string());
let _ = writeln!(
out,
" expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
);
}
}
}
fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
let _ = writeln!(
out,
" final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
);
if let Some(obj) = expected.as_object() {
for (idx, (key, val)) in obj.iter().enumerate() {
let escaped_key = escape_dart(key);
let json_val = serde_json::to_string(val).unwrap_or_default();
let escaped_val = escape_dart(&json_val);
let _ = writeln!(out, " final _expectedField{idx} = jsonDecode('{escaped_val}');");
let _ = writeln!(
out,
" expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
);
}
}
}
fn render_assert_validation_errors(
&self,
out: &mut String,
_response_var: &str,
errors: &[ValidationErrorExpectation],
) {
let _ = writeln!(out, " final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
let _ = writeln!(out, " final errList = (errBody['errors'] ?? []) as List<dynamic>;");
for ve in errors {
let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
let loc_str = loc_dart.join(", ");
let escaped_msg = escape_dart(&ve.msg);
let _ = writeln!(
out,
" 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}');"
);
}
}
}
fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
if http.expected_response.status_code == 101 {
let description = escape_dart(&fixture.description);
let _ = writeln!(out, " test('{description}', () {{");
let _ = writeln!(
out,
" markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
);
let _ = writeln!(out, " }});");
let _ = writeln!(out);
return;
}
let is_redirect = http.expected_response.status_code / 100 == 3;
client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
}
fn escape_dart(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
.replace('$', "\\$")
}