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, 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
22/// Dart e2e code generator.
23pub 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        // Resolve package config.
38        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        // Generate pubspec.yaml with http dependency for HTTP client tests.
57        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        // Generate dart_test.yaml to limit parallelism — the mock server uses keep-alive
64        // connections and gets overwhelmed when test files run in parallel.
65        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        // One test file per fixture group.
80        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
108// ---------------------------------------------------------------------------
109// Rendering
110// ---------------------------------------------------------------------------
111
112fn 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    // Check if any fixture needs the http package (HTTP server tests).
153    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    let _ = writeln!(out, "import 'package:kreuzberg/kreuzberg.dart';");
158    if has_http_fixtures {
159        let _ = writeln!(out, "import 'dart:async';");
160        let _ = writeln!(out, "import 'dart:convert';");
161    }
162    let _ = writeln!(out);
163
164    // Emit file-level HTTP client and serialization mutex.
165    //
166    // The shared HttpClient reuses keep-alive connections to minimize TCP overhead.
167    // The mutex (_lock) ensures requests are serialized within the file so the
168    // connection pool is not exercised concurrently by dart:test's async runner.
169    //
170    // _withRetry wraps the entire request closure with one automatic retry on
171    // transient connection errors (keep-alive connections can be silently closed
172    // by the server just as the client tries to reuse them).
173    if has_http_fixtures {
174        let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
175        let _ = writeln!(out);
176        let _ = writeln!(out, "var _lock = Future<void>.value();");
177        let _ = writeln!(out);
178        let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
179        let _ = writeln!(out, "  final current = _lock;");
180        let _ = writeln!(out, "  final next = Completer<void>();");
181        let _ = writeln!(out, "  _lock = next.future;");
182        let _ = writeln!(out, "  try {{");
183        let _ = writeln!(out, "    await current;");
184        let _ = writeln!(out, "    return await fn();");
185        let _ = writeln!(out, "  }} finally {{");
186        let _ = writeln!(out, "    next.complete();");
187        let _ = writeln!(out, "  }}");
188        let _ = writeln!(out, "}}");
189        let _ = writeln!(out);
190        // The `fn` here should be the full request closure — on socket failure we
191        // recreate the HttpClient (drops old pooled connections) and retry once.
192        let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
193        let _ = writeln!(out, "  try {{");
194        let _ = writeln!(out, "    return await fn();");
195        let _ = writeln!(out, "  }} on SocketException {{");
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, "  }} on HttpException {{");
200        let _ = writeln!(out, "    _httpClient.close(force: true);");
201        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
202        let _ = writeln!(out, "    return fn();");
203        let _ = writeln!(out, "  }}");
204        let _ = writeln!(out, "}}");
205        let _ = writeln!(out);
206    }
207
208    let _ = writeln!(out, "// E2e tests for category: {category}");
209    let _ = writeln!(out, "void main() {{");
210
211    // Close the shared client after all tests in this file complete.
212    if has_http_fixtures {
213        let _ = writeln!(out, "  tearDownAll(() => _httpClient.close());");
214        let _ = writeln!(out);
215    }
216
217    for fixture in fixtures {
218        render_test_case(&mut out, fixture, e2e_config, lang);
219    }
220
221    let _ = writeln!(out, "}}");
222    out
223}
224
225fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) {
226    // HTTP fixtures: hit the mock server.
227    if let Some(http) = &fixture.http {
228        render_http_test_case(out, fixture, http);
229        return;
230    }
231
232    // Non-HTTP fixtures: render a call-based test using the resolved call config.
233    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
234    let call_overrides = call_config.overrides.get(lang);
235    let mut function_name = call_overrides
236        .and_then(|o| o.function.as_ref())
237        .cloned()
238        .unwrap_or_else(|| call_config.function.clone());
239    // Convert snake_case function names to camelCase for Dart conventions.
240    function_name = function_name
241        .split('_')
242        .enumerate()
243        .map(|(i, part)| {
244            if i == 0 {
245                part.to_string()
246            } else {
247                let mut chars = part.chars();
248                match chars.next() {
249                    None => String::new(),
250                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
251                }
252            }
253        })
254        .collect::<Vec<_>>()
255        .join("");
256    let result_var = &call_config.result_var;
257    let description = escape_dart(&fixture.description);
258    let is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
259
260    // Build argument list from fixture.input and call_config.args
261    let mut args = Vec::new();
262    for arg_def in &call_config.args {
263        let arg_value = fixture.input.get(&arg_def.name);
264        match arg_def.arg_type.as_str() {
265            "file_path" | "bytes" => {
266                if let Some(serde_json::Value::String(file_path)) = arg_value {
267                    args.push(format!("File('{}').readAsBytesSync()", file_path));
268                }
269            }
270            "string" => {
271                if let Some(serde_json::Value::String(s)) = arg_value {
272                    args.push(format!("'{}'", escape_dart(s)));
273                }
274            }
275            _ => {}
276        }
277    }
278
279    if is_async {
280        let _ = writeln!(out, "  test('{description}', () async {{");
281    } else {
282        let _ = writeln!(out, "  test('{description}', () {{");
283    }
284
285    // Emit the receiver class name and arguments
286    let args_str = args.join(", ");
287    let receiver_class = call_overrides
288        .and_then(|o| o.class.as_ref())
289        .cloned()
290        .unwrap_or_else(|| "KreuzbergBridge".to_string());
291
292    if is_async {
293        let _ = writeln!(
294            out,
295            "    final {result_var} = await {receiver_class}.{function_name}({args_str});"
296        );
297    } else {
298        let _ = writeln!(
299            out,
300            "    final {result_var} = {receiver_class}.{function_name}({args_str});"
301        );
302    }
303
304    let _ = writeln!(out, "  }});");
305    let _ = writeln!(out);
306}
307
308// ---------------------------------------------------------------------------
309// HTTP server test rendering — DartTestClientRenderer impl + thin driver wrapper
310// ---------------------------------------------------------------------------
311
312/// Renderer that emits `package:test` `test(...)` blocks using `dart:io HttpClient`
313/// against the mock server (`Platform.environment['MOCK_SERVER_URL']`).
314///
315/// Skipped tests are emitted as self-contained stubs (complete test block with
316/// `markTestSkipped`) entirely inside `render_test_open`. `render_test_close` uses
317/// `in_skip` to emit the right closing token: nothing extra for skip stubs (already
318/// closed) vs. `})));` for regular tests.
319///
320/// `is_redirect` must be set to `true` before invoking the shared driver for 3xx
321/// fixtures so that `render_call` can inject `ioReq.followRedirects = false` after
322/// the `openUrl` call.
323struct DartTestClientRenderer {
324    /// Set to `true` when `render_test_open` is called with a skip reason so that
325    /// `render_test_close` can match the opening shape.
326    in_skip: Cell<bool>,
327    /// Pre-set to `true` by the thin wrapper when the fixture expects a 3xx response.
328    /// `render_call` injects `ioReq.followRedirects = false` when this is `true`.
329    is_redirect: Cell<bool>,
330}
331
332impl DartTestClientRenderer {
333    fn new(is_redirect: bool) -> Self {
334        Self {
335            in_skip: Cell::new(false),
336            is_redirect: Cell::new(is_redirect),
337        }
338    }
339}
340
341impl client::TestClientRenderer for DartTestClientRenderer {
342    fn language_name(&self) -> &'static str {
343        "dart"
344    }
345
346    /// Emit the test opening.
347    ///
348    /// For skipped fixtures: emit the entire self-contained stub (open + body +
349    /// close + blank line) and set `in_skip = true` so `render_test_close` is a
350    /// no-op.
351    ///
352    /// For active fixtures: emit `test('desc', () => _serialized(() => _withRetry(() async {`
353    /// leaving the block open for the assertion primitives.
354    fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
355        let escaped_desc = escape_dart(description);
356        if let Some(reason) = skip_reason {
357            let escaped_reason = escape_dart(reason);
358            let _ = writeln!(out, "  test('{escaped_desc}', () {{");
359            let _ = writeln!(out, "    markTestSkipped('{escaped_reason}');");
360            let _ = writeln!(out, "  }});");
361            let _ = writeln!(out);
362            self.in_skip.set(true);
363        } else {
364            let _ = writeln!(
365                out,
366                "  test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
367            );
368            self.in_skip.set(false);
369        }
370    }
371
372    /// Emit the test closing token.
373    ///
374    /// No-op for skip stubs (the stub was fully closed in `render_test_open`).
375    /// Emits `})));` followed by a blank line for regular tests.
376    fn render_test_close(&self, out: &mut String) {
377        if self.in_skip.get() {
378            // Stub was already closed in render_test_open.
379            return;
380        }
381        let _ = writeln!(out, "  }})));");
382        let _ = writeln!(out);
383    }
384
385    /// Emit the full `dart:io HttpClient` request scaffolding.
386    ///
387    /// Emits:
388    /// - URL construction from `MOCK_SERVER_URL`.
389    /// - `_httpClient.openUrl(method, uri)`.
390    /// - `followRedirects = false` when `is_redirect` is pre-set on the renderer.
391    /// - Content-Type header, request headers, cookies, optional body bytes.
392    /// - `ioReq.close()` → `ioResp`.
393    /// - Response-body drain into `bodyStr` (skipped for redirect responses).
394    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
395        // dart:io restricted headers (handled automatically by the HTTP stack).
396        const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
397
398        let method = ctx.method.to_uppercase();
399        let escaped_method = escape_dart(&method);
400
401        // Fixture path is `/fixtures/<id>` — extract the id portion for URL construction.
402        let fixture_path = escape_dart(ctx.path);
403
404        // Determine effective content-type.
405        let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
406        let effective_content_type = if has_explicit_content_type {
407            ctx.headers
408                .iter()
409                .find(|(k, _)| k.to_lowercase() == "content-type")
410                .map(|(_, v)| v.as_str())
411                .unwrap_or("application/json")
412        } else if ctx.body.is_some() {
413            ctx.content_type.unwrap_or("application/json")
414        } else {
415            ""
416        };
417
418        let _ = writeln!(
419            out,
420            "    final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
421        );
422        let _ = writeln!(out, "    final uri = Uri.parse('$baseUrl{fixture_path}');");
423        let _ = writeln!(
424            out,
425            "    final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
426        );
427
428        // Disable automatic redirect following for 3xx fixtures so the test can
429        // assert on the redirect status code itself.
430        if self.is_redirect.get() {
431            let _ = writeln!(out, "    ioReq.followRedirects = false;");
432        }
433
434        // Set content-type header.
435        if !effective_content_type.is_empty() {
436            let escaped_ct = escape_dart(effective_content_type);
437            let _ = writeln!(out, "    ioReq.headers.set('content-type', '{escaped_ct}');");
438        }
439
440        // Set request headers (skip dart:io restricted headers and content-type, already handled).
441        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
442        header_pairs.sort_by_key(|(k, _)| k.as_str());
443        for (name, value) in &header_pairs {
444            if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
445                continue;
446            }
447            if name.to_lowercase() == "content-type" {
448                continue; // Already handled above.
449            }
450            let escaped_name = escape_dart(&name.to_lowercase());
451            let escaped_value = escape_dart(value);
452            let _ = writeln!(out, "    ioReq.headers.set('{escaped_name}', '{escaped_value}');");
453        }
454
455        // Add cookies.
456        if !ctx.cookies.is_empty() {
457            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
458            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
459            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
460            let cookie_header = escape_dart(&cookie_str.join("; "));
461            let _ = writeln!(out, "    ioReq.headers.set('cookie', '{cookie_header}');");
462        }
463
464        // Write body bytes if present (bypass charset-based encoding issues).
465        if let Some(body) = ctx.body {
466            let json_str = serde_json::to_string(body).unwrap_or_default();
467            let escaped = escape_dart(&json_str);
468            let _ = writeln!(out, "    final bodyBytes = utf8.encode('{escaped}');");
469            let _ = writeln!(out, "    ioReq.add(bodyBytes);");
470        }
471
472        let _ = writeln!(out, "    final ioResp = await ioReq.close();");
473        // Drain the response body to bind `bodyStr` for assertion primitives and to
474        // allow the server to cleanly close the connection (prevents RST packets).
475        // Redirect responses have no body to drain — skip to avoid a potential hang.
476        if !self.is_redirect.get() {
477            let _ = writeln!(out, "    final bodyStr = await ioResp.transform(utf8.decoder).join();");
478        };
479    }
480
481    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
482        let _ = writeln!(
483            out,
484            "    expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
485        );
486    }
487
488    /// Emit a single header assertion, handling special tokens `<<present>>`,
489    /// `<<absent>>`, and `<<uuid>>`.
490    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
491        let escaped_name = escape_dart(&name.to_lowercase());
492        match expected {
493            "<<present>>" => {
494                let _ = writeln!(
495                    out,
496                    "    expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
497                );
498            }
499            "<<absent>>" => {
500                let _ = writeln!(
501                    out,
502                    "    expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
503                );
504            }
505            "<<uuid>>" => {
506                let _ = writeln!(
507                    out,
508                    "    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');"
509                );
510            }
511            exact => {
512                let escaped_value = escape_dart(exact);
513                let _ = writeln!(
514                    out,
515                    "    expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
516                );
517            }
518        }
519    }
520
521    /// Emit an exact-equality body assertion.
522    ///
523    /// String bodies are compared as decoded text; structured JSON bodies are
524    /// compared via `jsonDecode`.
525    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
526        match expected {
527            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
528                let json_str = serde_json::to_string(expected).unwrap_or_default();
529                let escaped = escape_dart(&json_str);
530                let _ = writeln!(out, "    final bodyJson = jsonDecode(bodyStr);");
531                let _ = writeln!(out, "    final expectedJson = jsonDecode('{escaped}');");
532                let _ = writeln!(
533                    out,
534                    "    expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
535                );
536            }
537            serde_json::Value::String(s) => {
538                let escaped = escape_dart(s);
539                let _ = writeln!(
540                    out,
541                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
542                );
543            }
544            other => {
545                let escaped = escape_dart(&other.to_string());
546                let _ = writeln!(
547                    out,
548                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
549                );
550            }
551        }
552    }
553
554    /// Emit partial-body assertions — every key in `expected` must match the
555    /// corresponding field in the parsed JSON response.
556    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
557        let _ = writeln!(
558            out,
559            "    final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
560        );
561        if let Some(obj) = expected.as_object() {
562            for (idx, (key, val)) in obj.iter().enumerate() {
563                let escaped_key = escape_dart(key);
564                let json_val = serde_json::to_string(val).unwrap_or_default();
565                let escaped_val = escape_dart(&json_val);
566                // Use an index-based variable name so keys with special characters
567                // don't produce invalid Dart identifiers.
568                let _ = writeln!(out, "    final _expectedField{idx} = jsonDecode('{escaped_val}');");
569                let _ = writeln!(
570                    out,
571                    "    expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
572                );
573            }
574        }
575    }
576
577    /// Emit validation-error assertions for 422 responses.
578    fn render_assert_validation_errors(
579        &self,
580        out: &mut String,
581        _response_var: &str,
582        errors: &[ValidationErrorExpectation],
583    ) {
584        let _ = writeln!(out, "    final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
585        let _ = writeln!(out, "    final errList = (errBody['errors'] ?? []) as List<dynamic>;");
586        for ve in errors {
587            let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
588            let loc_str = loc_dart.join(", ");
589            let escaped_msg = escape_dart(&ve.msg);
590            let _ = writeln!(
591                out,
592                "    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}');"
593            );
594        }
595    }
596}
597
598/// Render a `package:test` `test(...)` block for an HTTP server fixture.
599///
600/// Delegates to the shared [`client::http_call::render_http_test`] driver via
601/// [`DartTestClientRenderer`]. HTTP 101 (WebSocket upgrade) fixtures are emitted
602/// as skip stubs before reaching the driver because `dart:io HttpClient` cannot
603/// handle protocol-switch responses.
604fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
605    // HTTP 101 (WebSocket upgrade) — dart:io HttpClient cannot handle upgrade responses.
606    if http.expected_response.status_code == 101 {
607        let description = escape_dart(&fixture.description);
608        let _ = writeln!(out, "  test('{description}', () {{");
609        let _ = writeln!(
610            out,
611            "    markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
612        );
613        let _ = writeln!(out, "  }});");
614        let _ = writeln!(out);
615        return;
616    }
617
618    // Pre-set `is_redirect` on the renderer so `render_call` can inject
619    // `ioReq.followRedirects = false` for 3xx fixtures. The shared driver has no
620    // concept of expected status code so we thread it through renderer state.
621    let is_redirect = http.expected_response.status_code / 100 == 3;
622    client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
623}
624
625/// Escape a string for embedding in a Dart single-quoted string literal.
626fn escape_dart(s: &str) -> String {
627    s.replace('\\', "\\\\")
628        .replace('\'', "\\'")
629        .replace('\n', "\\n")
630        .replace('\r', "\\r")
631        .replace('\t', "\\t")
632        .replace('$', "\\$")
633}