1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
use std::cell::Cell;
use std::fmt::Write as FmtWrite;
use crate::e2e::fixture::{Fixture, HttpFixture, ValidationErrorExpectation};
use super::values::escape_dart;
use crate::e2e::codegen::client;
// ---------------------------------------------------------------------------
// HTTP server test rendering — DartTestClientRenderer impl + thin driver wrapper
// ---------------------------------------------------------------------------
/// Renderer that emits `package:test` `test(...)` blocks using `dart:io HttpClient`
/// against the mock server (`Platform.environment['MOCK_SERVER_URL']`).
///
/// Skipped tests are emitted as self-contained stubs (complete test block with
/// `markTestSkipped`) entirely inside `render_test_open`. `render_test_close` uses
/// `in_skip` to emit the right closing token: nothing extra for skip stubs (already
/// closed) vs. `})));` for regular tests.
///
/// `is_redirect` must be set to `true` before invoking the shared driver for 3xx
/// fixtures so that `render_call` can inject `ioReq.followRedirects = false` after
/// the `openUrl` call.
pub(super) struct DartTestClientRenderer {
/// Set to `true` when `render_test_open` is called with a skip reason so that
/// `render_test_close` can match the opening shape.
in_skip: Cell<bool>,
/// Pre-set to `true` by the thin wrapper when the fixture expects a 3xx response.
/// `render_call` injects `ioReq.followRedirects = false` when this is `true`.
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"
}
/// Emit the test opening.
///
/// For skipped fixtures: emit the entire self-contained stub (open + body +
/// close + blank line) and set `in_skip = true` so `render_test_close` is a
/// no-op.
///
/// For active fixtures: emit `test('desc', () => _serialized(() => _withRetry(() async {`
/// leaving the block open for the assertion primitives.
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);
}
}
/// Emit the test closing token.
///
/// No-op for skip stubs (the stub was fully closed in `render_test_open`).
/// Emits `})));` followed by a blank line for regular tests.
fn render_test_close(&self, out: &mut String) {
if self.in_skip.get() {
// Stub was already closed in render_test_open.
return;
}
let _ = writeln!(out, " }})));");
let _ = writeln!(out);
}
/// Emit the full `dart:io HttpClient` request scaffolding.
///
/// Emits:
/// - URL construction from `MOCK_SERVER_URL`.
/// - `_httpClient.openUrl(method, uri)`.
/// - `followRedirects = false` when `is_redirect` is pre-set on the renderer.
/// - Content-Type header, request headers, cookies, optional body bytes.
/// - `ioReq.contentLength` when a body is present (avoids chunked encoding).
/// - `ioReq.close()` → `ioResp`.
/// - Response-body drain into `bodyStr` (always emitted, including for 3xx).
fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
// dart:io restricted headers (handled automatically by the HTTP stack).
const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
let method = ctx.method.to_uppercase();
let escaped_method = escape_dart(&method);
// Fixture path is `/fixtures/<id>` — extract the id portion for URL construction.
let fixture_path = escape_dart(ctx.path);
// Determine effective content-type.
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 = _sutUrl();");
let _ = writeln!(out, " final uri = Uri.parse('$baseUrl{fixture_path}');");
let _ = writeln!(
out,
" final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
);
// Use a fresh (non-persistent) connection per request. Dart's HttpClient keeps
// connections alive and reuses them; when the mock server closes an idle keep-alive
// socket, the next reused request races into a "Connection reset by peer". Disabling
// persistence trades a little speed for deterministic, reset-free runs.
let _ = writeln!(out, " ioReq.persistentConnection = false;");
// Disable automatic redirect following for 3xx fixtures so the test can
// assert on the redirect status code itself.
if self.is_redirect.get() {
let _ = writeln!(out, " ioReq.followRedirects = false;");
}
// Set content-type header.
if !effective_content_type.is_empty() {
let escaped_ct = escape_dart(effective_content_type);
let _ = writeln!(out, " ioReq.headers.set('content-type', '{escaped_ct}');");
}
// Set request headers (skip dart:io restricted headers and content-type, already handled).
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; // Already handled above.
}
let escaped_name = escape_dart(&name.to_lowercase());
let escaped_value = escape_dart(value);
let _ = writeln!(out, " ioReq.headers.set('{escaped_name}', '{escaped_value}');");
}
// Add cookies.
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}');");
}
// Write body bytes if present (bypass charset-based encoding issues).
// Set contentLength explicitly so Dart sends Content-Length rather than
// chunked Transfer-Encoding — consistent with Python (urllib) and Go (http)
// which both set Content-Length automatically. Chunked encoding is valid
// HTTP/1.1 but some server configurations respond with a connection reset.
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.contentLength = bodyBytes.length;");
let _ = writeln!(out, " ioReq.add(bodyBytes);");
}
let _ = writeln!(out, " final ioResp = await ioReq.close();");
// Always drain the response body into `bodyStr` so assertion primitives
// (render_assert_json_body, render_assert_partial_body, etc.) can reference
// it unconditionally. For 3xx redirect responses with followRedirects=false,
// the mock server still sends a response body (e.g. `{}`) — draining it is
// safe and necessary when the fixture has a body assertion.
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');"
);
}
/// Emit a single header assertion, handling special tokens `<<present>>`,
/// `<<absent>>`, and `<<uuid>>`.
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');"
);
}
}
}
/// Emit an exact-equality body assertion.
///
/// String bodies are compared as decoded text; structured JSON bodies are
/// compared via `jsonDecode`.
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');"
);
}
}
}
/// Emit partial-body assertions — every key in `expected` must match the
/// corresponding field in the parsed JSON response.
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);
// Use an index-based variable name so keys with special characters
// don't produce invalid Dart identifiers.
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');"
);
}
}
}
/// Emit validation-error assertions for 422 responses.
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}');"
);
}
}
}
/// Render a `package:test` `test(...)` block for an HTTP server fixture.
///
/// Delegates to the shared [`client::http_call::render_http_test`] driver via
/// [`DartTestClientRenderer`]. HTTP 101 (WebSocket upgrade) fixtures are emitted
/// as skip stubs before reaching the driver because `dart:io HttpClient` cannot
/// handle protocol-switch responses.
pub(super) fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
// HTTP 101 (WebSocket upgrade) — dart:io HttpClient cannot handle upgrade responses.
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;
}
// Pre-set `is_redirect` on the renderer so `render_call` can inject
// `ioReq.followRedirects = false` for 3xx fixtures. The shared driver has no
// concept of expected status code so we thread it through renderer state.
let is_redirect = http.expected_response.status_code / 100 == 3;
client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
}