• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gripmock / grpctestify-rust / 30248040547

27 Jul 2026 07:57AM UTC coverage: 80.574% (+6.4%) from 74.202%
30248040547

Pull #81

github

web-flow
Merge 55b8756ce into 67c08fb25
Pull Request #81: rhai

47513 of 58968 relevant lines covered (80.57%)

23229.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.56
/src/execution/runner.rs
1
use super::super::parser::GctfDocument;
2
use super::runner_helpers;
3
use super::{AssertionHandler, RequestHandler, RequestSendResult, ResponseHandler};
4
use crate::assert::{AssertionEngine, JsonComparator, get_json_diff};
5
use crate::grpc::{GrpcClient, GrpcClientConfig};
6
use crate::optimizer;
7
use crate::parser::ast::{SectionContent, SectionType};
8
use crate::plugins::AssertionTiming;
9
use crate::report::CoverageCollector;
10
use anyhow::Result;
11
use serde::{Deserialize, Serialize};
12
use serde_json::Value;
13
use std::collections::HashMap;
14
use std::path::Path;
15
use std::sync::Arc;
16
use std::sync::LazyLock;
17
use tokio::sync::mpsc;
18
use tokio_stream::StreamExt;
19
use tokio_stream::wrappers::ReceiverStream;
20

21
/// Global plugin registry used by all assertion engines.
22
static PLUGIN_REGISTRY: LazyLock<Arc<dyn apif_assert::registry::PluginRegistry>> =
23
    LazyLock::new(|| Arc::new(crate::execution::plugin_dir::build_plugin_manager()));
62✔
24

25
/// Execution plan for inspect workflow visualization
26
#[derive(Debug, Clone, Serialize, Deserialize)]
27
pub struct ExecutionPlan {
28
    pub file_path: String,
29
    pub connection: ConnectionInfo,
30
    pub target: TargetInfo,
31
    pub headers: Option<HeadersInfo>,
32
    pub requests: Vec<RequestInfo>,
33
    pub expectations: Vec<ExpectationInfo>,
34
    pub assertions: Vec<AssertionInfo>,
35
    pub extractions: Vec<ExtractionInfo>,
36
    pub rpc_mode: RpcMode,
37
    pub summary: ExecutionSummary,
38
}
39

40
#[derive(Debug, Clone, Serialize, Deserialize)]
41
pub struct ConnectionInfo {
42
    pub address: String,
43
    pub source: String,
44
    pub backend: String,
45
}
46

47
#[derive(Debug, Clone, Serialize, Deserialize)]
48
pub struct TargetInfo {
49
    pub endpoint: String,
50
    pub package: Option<String>,
51
    pub service: Option<String>,
52
    pub method: Option<String>,
53
}
54

55
#[derive(Debug, Clone, Serialize, Deserialize)]
56
pub struct HeadersInfo {
57
    pub count: usize,
58
    pub headers: crate::parser::OrderedStringMap,
59
}
60

61
#[derive(Debug, Clone, Serialize, Deserialize)]
62
pub struct RequestInfo {
63
    pub index: usize,
64
    pub content: Value,
65
    pub content_type: String,
66
    pub line_start: usize,
67
    pub line_end: usize,
68
}
69

70
#[derive(Debug, Clone, Serialize, Deserialize)]
71
pub struct ExpectationInfo {
72
    pub index: usize,
73
    pub expectation_type: String, // "response" or "error"
74
    pub content: Option<Value>,
75
    pub message_count: Option<usize>,
76
    pub comparison_options: ComparisonOptions,
77
    pub line_start: usize,
78
    pub line_end: usize,
79
}
80

81
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
82
pub struct ComparisonOptions {
83
    pub partial: bool,
84
    pub redact: Vec<String>,
85
    pub tolerance: Option<f64>,
86
    pub unordered_arrays: bool,
87
    pub with_asserts: bool,
88
}
89

90
#[derive(Debug, Clone, Serialize, Deserialize)]
91
pub struct AssertionInfo {
92
    pub index: usize,
93
    pub assertions: Vec<String>,
94
    pub line_start: usize,
95
    pub line_end: usize,
96
    pub response_index: Option<usize>,
97
}
98

99
#[derive(Debug, Clone, Serialize, Deserialize)]
100
pub struct ExtractionInfo {
101
    pub index: usize,
102
    pub variables: crate::parser::OrderedStringMap,
103
    pub line_start: usize,
104
    pub line_end: usize,
105
    pub response_index: Option<usize>,
106
}
107

108
#[derive(Debug, Clone, Serialize, Deserialize)]
109
#[serde(rename_all = "snake_case")]
110
pub enum RpcMode {
111
    Unary,
112
    UnaryError,
113
    ServerStreaming {
114
        response_count: usize,
115
    },
116
    ClientStreaming {
117
        request_count: usize,
118
    },
119
    BidirectionalStreaming {
120
        request_count: usize,
121
        response_count: usize,
122
    },
123
    Unknown,
124
}
125

126
/// Actual RPC mode from proto descriptor (for runtime validation)
127
#[derive(Debug, Clone, PartialEq, Eq)]
128
pub enum RpcModeInfo {
129
    Unary,
130
    ServerStreaming,
131
    ClientStreaming,
132
    BidirectionalStreaming,
133
    Unknown,
134
}
135

136
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137
pub struct ExecutionSummary {
138
    pub total_requests: usize,
139
    pub total_responses: usize,
140
    pub total_errors: usize,
141
    pub error_expected: bool,
142
    pub assertion_blocks: usize,
143
    pub variable_extractions: usize,
144
    pub rpc_mode_name: String,
145
}
146

147
struct AssertionContext<'a> {
148
    headers: &'a HashMap<String, String>,
149
    trailers: &'a HashMap<String, String>,
150
    timing: Option<&'a AssertionTiming>,
151
    /// EXTRACT-bound variables, so `$name` references in ASSERTS resolve.
152
    variables: &'a HashMap<String, Value>,
153
    /// Wire protocol that produced this response (`"grpc"`/`"grpc-web"`/
154
    /// `"connectrpc"`) — forwarded to assertion plugins via `PluginContext`.
155
    protocol: &'static str,
156
}
157

158
impl ExecutionPlan {
159
    /// Build execution plan from a GctfDocument
160
    pub fn from_document(doc: &GctfDocument) -> Self {
251✔
161
        let file_path = doc.file_path.clone();
251✔
162

163
        let connection = if let Some(section) = doc.first_section(SectionType::Address) {
251✔
164
            if let SectionContent::Single(addr) = &section.content {
24✔
165
                ConnectionInfo {
24✔
166
                    address: addr.clone(),
24✔
167
                    source: format!(
24✔
168
                        "ADDRESS section [Line {}-{}]",
24✔
169
                        section.start_line, section.end_line
24✔
170
                    ),
24✔
171
                    backend: "default".to_string(),
24✔
172
                }
24✔
173
            } else {
174
                ConnectionInfo {
×
175
                    address: "<env:GRPCTESTIFY_ADDRESS>".to_string(),
×
176
                    source: "Environment variable (implicit)".to_string(),
×
177
                    backend: "default".to_string(),
×
178
                }
×
179
            }
180
        } else {
181
            ConnectionInfo {
227✔
182
                address: "<env:GRPCTESTIFY_ADDRESS>".to_string(),
227✔
183
                source: "Environment variable (implicit)".to_string(),
227✔
184
                backend: "default".to_string(),
227✔
185
            }
227✔
186
        };
187

188
        let target = if let Some(section) = doc.first_section(SectionType::Endpoint) {
251✔
189
            if let SectionContent::Single(endpoint) = &section.content {
250✔
190
                let (package, service, method) = doc
250✔
191
                    .parse_endpoint()
250✔
192
                    .map(|(p, s, m)| (Some(p), Some(s), Some(m)))
250✔
193
                    .unwrap_or((None, None, None));
250✔
194
                TargetInfo {
250✔
195
                    endpoint: endpoint.clone(),
250✔
196
                    package,
250✔
197
                    service,
250✔
198
                    method,
250✔
199
                }
250✔
200
            } else {
201
                TargetInfo {
×
202
                    endpoint: "<missing>".to_string(),
×
203
                    package: None,
×
204
                    service: None,
×
205
                    method: None,
×
206
                }
×
207
            }
208
        } else {
209
            TargetInfo {
1✔
210
                endpoint: "<missing>".to_string(),
1✔
211
                package: None,
1✔
212
                service: None,
1✔
213
                method: None,
1✔
214
            }
1✔
215
        };
216

217
        let headers = doc
251✔
218
            .first_section(SectionType::RequestHeaders)
251✔
219
            .and_then(|section| {
251✔
220
                if let SectionContent::KeyValues(headers) = &section.content {
4✔
221
                    Some(HeadersInfo {
4✔
222
                        count: headers.len(),
4✔
223
                        headers: headers.clone(),
4✔
224
                    })
4✔
225
                } else {
226
                    None
×
227
                }
228
            });
4✔
229

230
        let request_sections = doc.sections_by_type(SectionType::Request);
251✔
231
        let requests: Vec<RequestInfo> = request_sections
251✔
232
            .iter()
251✔
233
            .enumerate()
251✔
234
            .map(|(i, section)| {
251✔
235
                let (content, content_type) = match &section.content {
249✔
236
                    SectionContent::Json(j) => (j.clone(), "json"),
248✔
237
                    SectionContent::JsonLines(vals) => (Value::Array(vals.clone()), "json-lines"),
1✔
238
                    SectionContent::Empty => (Value::Object(serde_json::Map::new()), "empty"),
×
239
                    _ => (Value::Null, "unknown"),
×
240
                };
241
                RequestInfo {
249✔
242
                    index: i + 1,
249✔
243
                    content,
249✔
244
                    content_type: content_type.to_string(),
249✔
245
                    line_start: section.start_line,
249✔
246
                    line_end: section.end_line,
249✔
247
                }
249✔
248
            })
249✔
249
            .collect();
251✔
250

251
        let response_sections = doc.sections_by_type(SectionType::Response);
251✔
252
        let error_section = doc.first_section(SectionType::Error);
251✔
253

254
        let expectations: Vec<ExpectationInfo> = if !response_sections.is_empty() {
251✔
255
            response_sections
197✔
256
                .iter()
197✔
257
                .enumerate()
197✔
258
                .map(|(i, section)| {
207✔
259
                    let (content, message_count) = match &section.content {
207✔
260
                        SectionContent::Json(j) => (Some(j.clone()), None),
207✔
261
                        SectionContent::JsonLines(vals) => (None, Some(vals.len())),
×
262
                        _ => (None, None),
×
263
                    };
264
                    ExpectationInfo {
207✔
265
                        index: i + 1,
207✔
266
                        expectation_type: "response".to_string(),
207✔
267
                        content,
207✔
268
                        message_count,
207✔
269
                        comparison_options: ComparisonOptions {
207✔
270
                            partial: section.inline_options.partial,
207✔
271
                            redact: section.inline_options.redact.clone(),
207✔
272
                            tolerance: section.inline_options.tolerance,
207✔
273
                            unordered_arrays: section.inline_options.unordered_arrays,
207✔
274
                            with_asserts: section.inline_options.with_asserts,
207✔
275
                        },
207✔
276
                        line_start: section.start_line,
207✔
277
                        line_end: section.end_line,
207✔
278
                    }
207✔
279
                })
207✔
280
                .collect()
197✔
281
        } else if let Some(section) = error_section {
54✔
282
            let content = match &section.content {
19✔
283
                SectionContent::Json(j) => Some(j.clone()),
19✔
284
                _ => None,
×
285
            };
286
            vec![ExpectationInfo {
19✔
287
                index: 1,
19✔
288
                expectation_type: "error".to_string(),
19✔
289
                content,
19✔
290
                message_count: None,
19✔
291
                comparison_options: ComparisonOptions {
19✔
292
                    partial: section.inline_options.partial,
19✔
293
                    redact: section.inline_options.redact.clone(),
19✔
294
                    tolerance: section.inline_options.tolerance,
19✔
295
                    unordered_arrays: section.inline_options.unordered_arrays,
19✔
296
                    with_asserts: section.inline_options.with_asserts,
19✔
297
                },
19✔
298
                line_start: section.start_line,
19✔
299
                line_end: section.end_line,
19✔
300
            }]
19✔
301
        } else {
302
            vec![]
35✔
303
        };
304

305
        let assert_sections = doc.sections_by_type(SectionType::Asserts);
251✔
306
        let assertions: Vec<AssertionInfo> = assert_sections
251✔
307
            .iter()
251✔
308
            .enumerate()
251✔
309
            .map(|(i, section)| {
251✔
310
                let assertions = if let SectionContent::Assertions(lines) = &section.content {
71✔
311
                    lines
70✔
312
                        .iter()
70✔
313
                        .map(|line| {
88✔
314
                            optimizer::rewrite_assertion_expression_fixed_point_if_changed_with_level(line, optimizer::OptimizeLevel::Safe)
88✔
315
                                .unwrap_or_else(|| line.clone())
88✔
316
                        })
88✔
317
                        .collect()
70✔
318
                } else {
319
                    vec![]
1✔
320
                };
321
                AssertionInfo {
71✔
322
                    index: i + 1,
71✔
323
                    assertions,
71✔
324
                    line_start: section.start_line,
71✔
325
                    line_end: section.end_line,
71✔
326
                    response_index: None,
71✔
327
                }
71✔
328
            })
71✔
329
            .collect();
251✔
330

331
        let extract_sections = doc.sections_by_type(SectionType::Extract);
251✔
332
        let extractions: Vec<ExtractionInfo> = extract_sections
251✔
333
            .iter()
251✔
334
            .enumerate()
251✔
335
            .map(|(i, section)| {
251✔
336
                let variables = if let SectionContent::Extract(vars) = &section.content {
24✔
337
                    vars.clone()
20✔
338
                } else {
339
                    crate::parser::OrderedStringMap::new()
4✔
340
                };
341
                ExtractionInfo {
24✔
342
                    index: i + 1,
24✔
343
                    variables,
24✔
344
                    line_start: section.start_line,
24✔
345
                    line_end: section.end_line,
24✔
346
                    response_index: None,
24✔
347
                }
24✔
348
            })
24✔
349
            .collect();
251✔
350

351
        let has_json_lines = response_sections
251✔
352
            .iter()
251✔
353
            .any(|s| matches!(&s.content, SectionContent::JsonLines(vals) if vals.len() > 1));
251✔
354
        let rpc_mode = infer_rpc_mode(
251✔
355
            &requests,
251✔
356
            &expectations,
251✔
357
            error_section.is_some(),
251✔
358
            has_json_lines,
251✔
359
        );
360

361
        let rpc_mode_name = match &rpc_mode {
251✔
362
            RpcMode::Unary => "Unary",
187✔
363
            RpcMode::UnaryError => "Unary Error",
19✔
364
            RpcMode::ServerStreaming { .. } => "Server Streaming",
5✔
365
            RpcMode::ClientStreaming { .. } => "Client Streaming",
5✔
366
            RpcMode::BidirectionalStreaming { .. } => "Bidirectional Streaming",
×
367
            RpcMode::Unknown => "Unknown",
35✔
368
        };
369

370
        let summary = ExecutionSummary {
251✔
371
            total_requests: requests.len(),
251✔
372
            total_responses: expectations
251✔
373
                .iter()
251✔
374
                .filter(|e| e.expectation_type == "response")
251✔
375
                .count(),
251✔
376
            total_errors: expectations
251✔
377
                .iter()
251✔
378
                .filter(|e| e.expectation_type == "error")
251✔
379
                .count(),
251✔
380
            error_expected: expectations.iter().any(|e| e.expectation_type == "error"),
251✔
381
            assertion_blocks: assertions.len(),
251✔
382
            variable_extractions: extractions.len(),
251✔
383
            rpc_mode_name: rpc_mode_name.to_string(),
251✔
384
        };
385

386
        ExecutionPlan {
251✔
387
            file_path,
251✔
388
            connection,
251✔
389
            target,
251✔
390
            headers,
251✔
391
            requests,
251✔
392
            expectations,
251✔
393
            assertions,
251✔
394
            extractions,
251✔
395
            rpc_mode,
251✔
396
            summary,
251✔
397
        }
251✔
398
    }
251✔
399
}
400

401
/// Get actual RPC mode from method descriptor
402
fn get_actual_rpc_mode(
77✔
403
    client: &crate::grpc::GrpcClient,
77✔
404
    full_service: &str,
77✔
405
    method_name: &str,
77✔
406
) -> RpcModeInfo {
77✔
407
    client
77✔
408
        .descriptor_pool()
77✔
409
        .get_service_by_name(full_service)
77✔
410
        .and_then(|s| s.methods().find(|m| m.name() == method_name))
77✔
411
        .map(|m| {
77✔
412
            if m.is_client_streaming() && m.is_server_streaming() {
77✔
413
                RpcModeInfo::BidirectionalStreaming
×
414
            } else if m.is_server_streaming() {
77✔
415
                RpcModeInfo::ServerStreaming
×
416
            } else if m.is_client_streaming() {
77✔
417
                RpcModeInfo::ClientStreaming
×
418
            } else {
419
                RpcModeInfo::Unary
77✔
420
            }
421
        })
77✔
422
        .unwrap_or(RpcModeInfo::Unknown)
77✔
423
}
77✔
424

425
/// Format protocol name for display in verbose output.
426
fn protocol_display(protocol: crate::grpc::WireProtocol) -> &'static str {
24✔
427
    match protocol {
24✔
428
        crate::grpc::WireProtocol::Grpc => "gRPC",
24✔
429
        crate::grpc::WireProtocol::GrpcWeb => "gRPC-Web",
×
430
        crate::grpc::WireProtocol::ConnectRpc => "ConnectRPC",
×
431
    }
432
}
24✔
433

434
/// Canonical machine-readable protocol string — the same form
435
/// `OPTIONS.protocol:` accepts (`WireProtocol::from_str`) — forwarded to
436
/// assertion plugins via `PluginContext::protocol`, distinct from
437
/// [`protocol_display`]'s human-facing form.
438
fn protocol_str(protocol: crate::grpc::WireProtocol) -> &'static str {
50✔
439
    match protocol {
50✔
440
        crate::grpc::WireProtocol::Grpc => "grpc",
50✔
441
        crate::grpc::WireProtocol::GrpcWeb => "grpc-web",
×
442
        crate::grpc::WireProtocol::ConnectRpc => "connectrpc",
×
443
    }
444
}
50✔
445

446
/// Infer RPC mode from GCTF section structure (without proto descriptor)
447
pub(crate) fn infer_rpc_mode_for_section_types(document: &GctfDocument) -> RpcModeInfo {
78✔
448
    // A single JsonLines REQUEST section sends N messages on one stream, same
449
    // as N separate REQUEST sections — count messages, not sections, so
450
    // client/bidi-streaming inference works either way.
451
    let request_count: usize = document
78✔
452
        .sections_by_type(SectionType::Request)
78✔
453
        .iter()
78✔
454
        .map(|s| match &s.content {
78✔
455
            SectionContent::JsonLines(values) => values.len(),
1✔
456
            _ => 1,
77✔
457
        })
78✔
458
        .sum();
78✔
459
    let response_sections = document.sections_by_type(SectionType::Response);
78✔
460
    let has_json_lines = response_sections
78✔
461
        .iter()
78✔
462
        .any(|s| matches!(&s.content, SectionContent::JsonLines(vals) if vals.len() > 1));
78✔
463
    let has_error = document.first_section(SectionType::Error).is_some();
78✔
464

465
    if has_error {
78✔
466
        if request_count > 1 {
×
467
            RpcModeInfo::ClientStreaming
×
468
        } else {
469
            RpcModeInfo::Unary
×
470
        }
471
    } else if has_json_lines || response_sections.len() > 1 {
78✔
472
        if request_count > 1 {
×
473
            RpcModeInfo::BidirectionalStreaming
×
474
        } else {
475
            RpcModeInfo::ServerStreaming
×
476
        }
477
    } else if request_count > 1 {
78✔
478
        RpcModeInfo::ClientStreaming
1✔
479
    } else {
480
        RpcModeInfo::Unary
77✔
481
    }
482
}
78✔
483

484
/// Check compatibility between inferred and actual RPC mode
485
fn check_rpc_mode_compatibility(inferred: &RpcModeInfo, actual: &RpcModeInfo) -> Option<String> {
77✔
486
    match (inferred, actual) {
77✔
487
        // Compatible pairs
488
        (RpcModeInfo::Unary, RpcModeInfo::Unary) => None,
77✔
489
        (RpcModeInfo::ServerStreaming, RpcModeInfo::ServerStreaming) => None,
×
490
        (RpcModeInfo::ClientStreaming, RpcModeInfo::ClientStreaming) => None,
×
491
        (RpcModeInfo::BidirectionalStreaming, RpcModeInfo::BidirectionalStreaming) => None,
×
492
        (RpcModeInfo::Unknown, _) => None, // Can't validate unknown
×
493
        (_, RpcModeInfo::Unknown) => None,  // Actual unknown means no descriptor
×
494

495
        // Incompatible: inferred Unary but actual is streaming
496
        (RpcModeInfo::Unary, RpcModeInfo::ServerStreaming) => {
497
            Some("gCTF defines Unary RPC but proto expects Server Streaming. Client will send ONE request and expect multiple responses.".to_string())
×
498
        }
499
        (RpcModeInfo::Unary, RpcModeInfo::ClientStreaming) => {
500
            Some("gCTF defines Unary RPC but proto expects Client Streaming. Client will send ONE request but server expects multiple.".to_string())
×
501
        }
502
        (RpcModeInfo::Unary, RpcModeInfo::BidirectionalStreaming) => {
503
            Some("gCTF defines Unary RPC but proto expects Bidirectional Streaming. Client will send ONE request but server expects stream.".to_string())
×
504
        }
505

506
        // Incompatible: inferred streaming but actual is Unary
507
        (RpcModeInfo::ServerStreaming, RpcModeInfo::Unary) => {
508
            Some("gCTF defines Server Streaming (multiple RESPONSE sections) but proto expects Unary. gCTF may fail.".to_string())
×
509
        }
510
        (RpcModeInfo::ClientStreaming, RpcModeInfo::Unary) => {
511
            Some("gCTF defines Client Streaming (multiple REQUEST sections) but proto expects Unary. gCTF may fail.".to_string())
×
512
        }
513
        (RpcModeInfo::BidirectionalStreaming, RpcModeInfo::Unary) => {
514
            Some("gCTF defines Bidirectional Streaming but proto expects Unary. gCTF may fail.".to_string())
×
515
        }
516

517
        // Cross-streaming mismatches
518
        (RpcModeInfo::ServerStreaming, RpcModeInfo::ClientStreaming) => {
519
            Some("gCTF expects Server Streaming but proto declares Client Streaming. Behavior may be incorrect.".to_string())
×
520
        }
521
        (RpcModeInfo::ServerStreaming, RpcModeInfo::BidirectionalStreaming) => {
522
            Some("gCTF expects Server Streaming but proto declares Bidirectional Streaming. Behavior may be incorrect.".to_string())
×
523
        }
524
        (RpcModeInfo::ClientStreaming, RpcModeInfo::ServerStreaming) => {
525
            Some("gCTF expects Client Streaming but proto declares Server Streaming. Behavior may be incorrect.".to_string())
×
526
        }
527
        (RpcModeInfo::ClientStreaming, RpcModeInfo::BidirectionalStreaming) => {
528
            Some("gCTF expects Client Streaming but proto declares Bidirectional Streaming. Behavior may be incorrect.".to_string())
×
529
        }
530
        (RpcModeInfo::BidirectionalStreaming, RpcModeInfo::ServerStreaming) => {
531
            Some("gCTF expects Bidirectional Streaming but proto declares Server Streaming. Behavior may be incorrect.".to_string())
×
532
        }
533
        (RpcModeInfo::BidirectionalStreaming, RpcModeInfo::ClientStreaming) => {
534
            Some("gCTF expects Bidirectional Streaming but proto declares Client Streaming. Behavior may be incorrect.".to_string())
×
535
        }
536
    }
537
}
77✔
538

539
fn infer_rpc_mode(
251✔
540
    requests: &[RequestInfo],
251✔
541
    expectations: &[ExpectationInfo],
251✔
542
    has_error: bool,
251✔
543
    has_json_lines: bool,
251✔
544
) -> RpcMode {
251✔
545
    let req_count = requests.len();
251✔
546
    let resp_count = expectations
251✔
547
        .iter()
251✔
548
        .filter(|e| e.expectation_type == "response")
251✔
549
        .count();
251✔
550

551
    if has_error {
251✔
552
        RpcMode::UnaryError
19✔
553
    } else if has_json_lines || resp_count > 1 {
232✔
554
        if req_count > 1 {
5✔
555
            RpcMode::BidirectionalStreaming {
×
556
                request_count: req_count,
×
557
                response_count: resp_count,
×
558
            }
×
559
        } else {
560
            RpcMode::ServerStreaming {
5✔
561
                response_count: resp_count,
5✔
562
            }
5✔
563
        }
564
    } else if req_count > 1 {
227✔
565
        RpcMode::ClientStreaming {
5✔
566
            request_count: req_count,
5✔
567
        }
5✔
568
    } else if req_count == 1 && resp_count == 1 {
222✔
569
        RpcMode::Unary
187✔
570
    } else if req_count == 0 && resp_count > 0 {
35✔
571
        RpcMode::ServerStreaming {
×
572
            response_count: resp_count,
×
573
        }
×
574
    } else {
575
        RpcMode::Unknown
35✔
576
    }
577
}
251✔
578

579
#[derive(Debug, Clone, PartialEq, Eq)]
580
pub enum TestExecutionStatus {
581
    Pass,
582
    Fail(String),
583
}
584

585
/// Classifies *why* a test failed, so callers (e.g. retry logic) can key off the
586
/// real error kind instead of pattern-matching the failure message text.
587
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
588
pub enum FailureKind {
589
    /// Connection/stream/transport-level failure. Potentially retryable
590
    /// (subject to the gRPC status code).
591
    Transport,
592
    /// Assertion mismatch, validation, parse, or configuration error.
593
    /// Never retryable.
594
    Assertion,
595
}
596

597
#[derive(Debug, Clone, PartialEq)]
598
pub struct TestExecutionResult {
599
    pub status: TestExecutionStatus,
600
    pub call_duration_ms: Option<u64>,
601
    pub captured_response: Option<crate::grpc::GrpcResponse>,
602
    pub meta: crate::state::TestMeta,
603
    /// What this test declared (sections, TLS, DATASET rows, PROTO files) —
604
    /// see [`apif_state::ConfigSummary`].
605
    pub config_summary: apif_state::ConfigSummary,
606
    /// Set only when `status` is `Fail`; classifies the failure for retry logic.
607
    pub failure_kind: Option<FailureKind>,
608
    /// Numeric gRPC status code observed for the call, when one is available.
609
    /// `Some(0)` for an OK response, `Some(code)` when the server/transport
610
    /// returned a gRPC status, and `None` when the request never produced a
611
    /// gRPC status (e.g. a pure assertion/config failure).
612
    pub grpc_status: Option<u32>,
613
    /// Per-assertion outcome + timing, in source order. Empty for pre-flight
614
    /// failures (e.g. bad OPTIONS) that never reach assertion evaluation.
615
    pub assertions: Vec<apif_state::AssertionRecord>,
616
    /// `true` when at least one REQUEST needed more than one attempt to
617
    /// succeed — a Pass with `retried = true` is flaky, not a clean pass.
618
    pub retried: bool,
619
    /// Real wall-clock duration of each document in the chain, in source
620
    /// order — only populated on the whole-chain result [`ChainAccumulator`]
621
    /// produces (per-document results have their own duration in
622
    /// `call_duration_ms` instead). Lets reporters give each document its
623
    /// actual timing instead of splitting the total evenly.
624
    pub document_durations_ms: Vec<u64>,
625
}
626

627
#[derive(Debug, Default, Clone)]
628
struct AssertionScopeTimingState {
629
    last_message_elapsed_ms: Option<u64>,
630
    total_scope_elapsed_ms: u64,
631
    scope_index: usize,
632
}
633

634
impl AssertionScopeTimingState {
635
    fn finish_scope(
54✔
636
        &mut self,
54✔
637
        scope_start_ms: u64,
54✔
638
        scope_end_ms: u64,
54✔
639
        scope_message_count: usize,
54✔
640
    ) -> Option<AssertionTiming> {
54✔
641
        if scope_message_count == 0 {
54✔
642
            return None;
×
643
        }
54✔
644

645
        let elapsed_ms = scope_end_ms.saturating_sub(scope_start_ms);
54✔
646
        self.scope_index += 1;
54✔
647
        self.total_scope_elapsed_ms = self.total_scope_elapsed_ms.saturating_add(elapsed_ms);
54✔
648

649
        let timing = AssertionTiming {
54✔
650
            elapsed_ms,
54✔
651
            total_elapsed_ms: self.total_scope_elapsed_ms,
54✔
652
            scope_message_count,
54✔
653
            scope_index: self.scope_index,
54✔
654
        };
54✔
655

656
        Some(timing)
54✔
657
    }
54✔
658
}
659

660
impl TestExecutionResult {
661
    pub fn pass(call_duration_ms: Option<u64>) -> Self {
85✔
662
        Self {
85✔
663
            status: TestExecutionStatus::Pass,
85✔
664
            call_duration_ms,
85✔
665
            captured_response: None,
85✔
666
            meta: crate::state::TestMeta::default(),
85✔
667
            config_summary: apif_state::ConfigSummary::default(),
85✔
668
            failure_kind: None,
85✔
669
            grpc_status: None,
85✔
670
            assertions: Vec::new(),
85✔
671
            retried: false,
85✔
672
            document_durations_ms: Vec::new(),
85✔
673
        }
85✔
674
    }
85✔
675

676
    /// Build a failure. Defaults to `Assertion` (non-retryable); transport-level
677
    /// failures should override via [`with_failure_kind`].
678
    pub fn fail(message: String, call_duration_ms: Option<u64>) -> Self {
29✔
679
        Self {
29✔
680
            status: TestExecutionStatus::Fail(message),
29✔
681
            call_duration_ms,
29✔
682
            captured_response: None,
29✔
683
            meta: crate::state::TestMeta::default(),
29✔
684
            config_summary: apif_state::ConfigSummary::default(),
29✔
685
            failure_kind: Some(FailureKind::Assertion),
29✔
686
            grpc_status: None,
29✔
687
            assertions: Vec::new(),
29✔
688
            retried: false,
29✔
689
            document_durations_ms: Vec::new(),
29✔
690
        }
29✔
691
    }
29✔
692

693
    pub fn with_failure_kind(mut self, kind: FailureKind) -> Self {
20✔
694
        self.failure_kind = Some(kind);
20✔
695
        self
20✔
696
    }
20✔
697

698
    pub fn with_retried(mut self, retried: bool) -> Self {
80✔
699
        self.retried = retried;
80✔
700
        self
80✔
701
    }
80✔
702

703
    pub fn with_grpc_status(mut self, code: u32) -> Self {
59✔
704
        self.grpc_status = Some(code);
59✔
705
        self
59✔
706
    }
59✔
707

708
    pub fn with_response(mut self, response: crate::grpc::GrpcResponse) -> Self {
25✔
709
        self.captured_response = Some(response);
25✔
710
        self
25✔
711
    }
25✔
712

713
    pub fn with_meta(mut self, meta: crate::state::TestMeta) -> Self {
53✔
714
        self.meta = meta;
53✔
715
        self
53✔
716
    }
53✔
717

718
    pub fn with_config_summary(mut self, config_summary: apif_state::ConfigSummary) -> Self {
53✔
719
        self.config_summary = config_summary;
53✔
720
        self
53✔
721
    }
53✔
722

723
    pub fn with_assertions(mut self, assertions: Vec<apif_state::AssertionRecord>) -> Self {
79✔
724
        self.assertions = assertions;
79✔
725
        self
79✔
726
    }
79✔
727
}
728

729
/// Folds a multi-document chain's per-document [`TestExecutionResult`]s into
730
/// one aggregate. Shared by `run_test_with_variables` and
731
/// `run_test_capturing_vars`, which differ only in whether they also return
732
/// the final variable map.
733
#[derive(Default)]
734
struct ChainAccumulator {
735
    status: Option<TestExecutionStatus>,
736
    failure_kind: Option<FailureKind>,
737
    grpc_status: Option<u32>,
738
    total_duration_ms: f64,
739
    assertions: Vec<apif_state::AssertionRecord>,
740
    /// Last document's captured response wins — write mode/exchange capture
741
    /// always target a single physical file, so only the final call's
742
    /// response (the one that matters for snapshotting) is kept.
743
    captured_response: Option<crate::grpc::GrpcResponse>,
744
    /// `true` once any document in the chain retried — sticky across the
745
    /// whole chain, since one flaky request makes the overall test flaky.
746
    retried: bool,
747
    /// Each absorbed document's own wall-clock duration, in chain order —
748
    /// including the failing document's, when the chain stops early.
749
    document_durations_ms: Vec<u64>,
750
}
751

752
impl ChainAccumulator {
753
    /// Fold one document's result in. Returns `true` when the chain should
754
    /// stop (this document failed — fail-fast).
755
    fn absorb(&mut self, mut result: TestExecutionResult) -> bool {
94✔
756
        if let Some(dur) = result.call_duration_ms {
94✔
757
            self.total_duration_ms += dur as f64;
82✔
758
        }
82✔
759
        self.document_durations_ms
94✔
760
            .push(result.call_duration_ms.unwrap_or(0));
94✔
761
        self.grpc_status = result.grpc_status;
94✔
762
        self.retried |= result.retried;
94✔
763
        self.assertions.append(&mut result.assertions);
94✔
764
        if result.captured_response.is_some() {
94✔
765
            self.captured_response = result.captured_response.take();
25✔
766
        }
69✔
767

768
        if matches!(result.status, TestExecutionStatus::Fail(_)) {
94✔
769
            self.failure_kind = result.failure_kind;
19✔
770
            self.status = Some(result.status);
19✔
771
            return true;
19✔
772
        }
75✔
773
        false
75✔
774
    }
94✔
775

776
    fn into_result(self) -> TestExecutionResult {
90✔
777
        TestExecutionResult {
90✔
778
            status: self.status.unwrap_or(TestExecutionStatus::Pass),
90✔
779
            call_duration_ms: Some(self.total_duration_ms as u64),
90✔
780
            captured_response: self.captured_response,
90✔
781
            meta: crate::state::TestMeta::default(),
90✔
782
            config_summary: apif_state::ConfigSummary::default(),
90✔
783
            failure_kind: self.failure_kind,
90✔
784
            grpc_status: self.grpc_status,
90✔
785
            assertions: self.assertions,
90✔
786
            retried: self.retried,
90✔
787
            document_durations_ms: self.document_durations_ms,
90✔
788
        }
90✔
789
    }
90✔
790
}
791

792
pub struct TestRunner {
793
    dry_run: bool,
794
    timeout_seconds: u64,
795
    no_assert: bool,
796
    write_mode: bool,
797
    verbose: bool,
798
    protocol_override: Option<crate::grpc::WireProtocol>,
799
    /// Client-side connection pool slot. Threaded into `GrpcClientConfig` so the
800
    /// transport channel cache keys by it — distinct ids open distinct channels.
801
    connection_id: u64,
802
    /// When true, capture headers/trailers/response messages even outside
803
    /// write mode, so reports (e.g. Allure attachments) can show what actually
804
    /// happened. Off by default: skips the extra buffering unless requested.
805
    capture_exchange: bool,
806
    assertion_engine: AssertionEngine,
807
    coverage_collector: Option<Arc<CoverageCollector>>,
808
    request_handler: RequestHandler,
809
    response_handler: ResponseHandler,
810
    assertion_handler: AssertionHandler,
811
}
812

813
impl TestRunner {
814
    /// Create expected values from a response section.
815
    pub fn expected_values_for_response_section(
27✔
816
        section: &crate::parser::ast::Section,
27✔
817
    ) -> Vec<Value> {
27✔
818
        match &section.content {
27✔
819
            SectionContent::Json(v) => vec![v.clone()],
26✔
820
            SectionContent::JsonLines(values) => values.clone(),
1✔
821
            _ => Vec::new(),
×
822
        }
823
    }
27✔
824

825
    pub fn grpc_code_name_from_numeric(code: i64) -> Option<&'static str> {
33✔
826
        super::error_handler::ErrorHandler::grpc_code_name_from_numeric(code)
33✔
827
    }
33✔
828

829
    pub fn error_matches_expected(error_text: &str, expected: &Value) -> bool {
5✔
830
        super::error_handler::ErrorHandler::error_matches_expected(error_text, expected)
5✔
831
    }
5✔
832

833
    fn has_required_followup_asserts(
2✔
834
        section: &crate::parser::ast::Section,
2✔
835
        sections: &[crate::parser::ast::Section],
2✔
836
        index: usize,
2✔
837
        effective_no_assert: bool,
2✔
838
        failure_reasons: &mut Vec<String>,
2✔
839
    ) -> bool {
2✔
840
        if !section.inline_options.with_asserts {
2✔
841
            return false;
×
842
        }
2✔
843

844
        if sections
2✔
845
            .get(index + 1)
2✔
846
            .is_some_and(|next| next.section_type == SectionType::Asserts)
2✔
847
        {
848
            return true;
1✔
849
        }
1✔
850

851
        if !effective_no_assert {
1✔
852
            failure_reasons.push(format!(
1✔
853
                "{} at line {} has 'with_asserts' but is not followed by ASSERTS",
1✔
854
                section.section_type.as_str(),
1✔
855
                section.start_line
1✔
856
            ));
1✔
857
        }
1✔
858

859
        false
1✔
860
    }
2✔
861

862
    pub fn new(
104✔
863
        dry_run: bool,
104✔
864
        timeout_seconds: u64,
104✔
865
        no_assert: bool,
104✔
866
        write_mode: bool,
104✔
867
        verbose: bool,
104✔
868
        coverage_collector: Option<Arc<CoverageCollector>>,
104✔
869
    ) -> Self {
104✔
870
        Self {
104✔
871
            dry_run,
104✔
872
            timeout_seconds,
104✔
873
            no_assert,
104✔
874
            write_mode,
104✔
875
            verbose,
104✔
876
            protocol_override: None,
104✔
877
            connection_id: 0,
104✔
878
            capture_exchange: false,
104✔
879
            assertion_engine: AssertionEngine::with_registry(PLUGIN_REGISTRY.clone()),
104✔
880
            coverage_collector: coverage_collector.clone(),
104✔
881
            request_handler: RequestHandler::new(no_assert, verbose, coverage_collector.clone()),
104✔
882
            response_handler: ResponseHandler::new(no_assert),
104✔
883
            assertion_handler: AssertionHandler::new(verbose),
104✔
884
        }
104✔
885
    }
104✔
886

887
    /// Set protocol override. When set, this takes priority over the GCTF file's OPTIONS.protocol.
888
    pub fn with_protocol(mut self, protocol: crate::grpc::WireProtocol) -> Self {
78✔
889
        self.protocol_override = Some(protocol);
78✔
890
        self
78✔
891
    }
78✔
892

893
    /// Ask the runner to capture headers/trailers/response messages for every
894
    /// test, not just in write mode, so reports can show what actually
895
    /// happened. Capped internally — see [`apif_state::CapturedExchange`].
896
    pub fn with_capture_exchange(mut self, capture: bool) -> Self {
52✔
897
        self.capture_exchange = capture;
52✔
898
        self
52✔
899
    }
52✔
900

901
    /// Assign the connection-pool slot for this runner. Distinct ids map to
902
    /// distinct cached transport channels (see `GrpcClientConfig::connection_id`).
903
    pub fn with_connection_id(mut self, connection_id: u64) -> Self {
29✔
904
        self.connection_id = connection_id;
29✔
905
        self
29✔
906
    }
29✔
907

908
    /// Run a test document chain.
909
    /// Walks the `next_document` linked list, accumulating EXTRACT variables
910
    /// between documents. Fail-fast: stops on first failure.
911
    pub async fn run_test(&self, document: &GctfDocument) -> Result<TestExecutionResult> {
3✔
912
        self.run_test_with_variables(document, HashMap::new()).await
3✔
913
    }
3✔
914

915
    /// Run a test document chain with pre-populated variables (for data-driven bench).
916
    pub async fn run_test_with_variables(
91✔
917
        &self,
91✔
918
        document: &GctfDocument,
91✔
919
        initial_variables: HashMap<String, Value>,
91✔
920
    ) -> Result<TestExecutionResult> {
91✔
921
        let mut variables = initial_variables;
91✔
922
        let mut acc = ChainAccumulator::default();
91✔
923

924
        for doc in document.iter_chain() {
91✔
925
            let result = self.run_one(doc, &mut variables).await?;
91✔
926
            if acc.absorb(result) {
82✔
927
                break;
18✔
928
            }
64✔
929
        }
930

931
        Ok(acc.into_result())
82✔
932
    }
91✔
933

934
    /// Run a test document chain like [`run_test_with_variables`], but also
935
    /// return the final accumulated variable map (post-EXTRACT). Used to seed
936
    /// per-directory fixtures: a `_setup.gctf`'s EXTRACT bindings become the
937
    /// initial variables for the tests in that directory. Execution semantics
938
    /// are identical to a normal chain run started with no initial variables.
939
    pub async fn run_test_capturing_vars(
1✔
940
        &self,
1✔
941
        document: &GctfDocument,
1✔
942
    ) -> Result<(TestExecutionResult, HashMap<String, Value>)> {
1✔
943
        let mut variables: HashMap<String, Value> = HashMap::new();
1✔
944
        let mut acc = ChainAccumulator::default();
1✔
945

946
        for doc in document.iter_chain() {
1✔
947
            let result = self.run_one(doc, &mut variables).await?;
1✔
948
            if acc.absorb(result) {
1✔
949
                break;
×
950
            }
1✔
951
        }
952

953
        Ok((acc.into_result(), variables))
1✔
954
    }
1✔
955

956
    /// Run a single document, sharing variables with the chain.
957
    async fn run_one(
92✔
958
        &self,
92✔
959
        document: &GctfDocument,
92✔
960
        variables: &mut HashMap<String, Value>,
92✔
961
    ) -> Result<TestExecutionResult> {
92✔
962
        let effective_dry_run = self.dry_run;
92✔
963
        let effective_no_assert = self.no_assert;
92✔
964
        let effective_write_mode = self.write_mode;
92✔
965

966
        let options = document.get_options().unwrap_or_default();
92✔
967
        let effective_timeout_seconds = match options.get("timeout") {
92✔
968
            Some(value) => match value.trim().parse::<u64>() {
×
969
                Ok(v) if v > 0 => v,
×
970
                _ => {
971
                    return Ok(TestExecutionResult::fail(
×
972
                        format!(
×
973
                            "OPTIONS.timeout must be a positive integer, got '{}'",
×
974
                            value
×
975
                        ),
×
976
                        None,
×
977
                    ));
×
978
                }
979
            },
980
            None => self.timeout_seconds,
92✔
981
        };
982

983
        // Canonical precedence: section attribute > OPTIONS > env default.
984
        // An explicit-but-invalid value is a configuration error, not a fall-back.
985
        let compression = match runner_helpers::resolve_compression(
92✔
986
            document,
92✔
987
            &options,
92✔
988
            crate::config::compression_from_env(),
92✔
989
        ) {
92✔
990
            Ok(c) => c,
92✔
991
            Err(e) => return Ok(TestExecutionResult::fail(e, None)),
×
992
        };
993

994
        if effective_write_mode {
92✔
995
            let file_path = Path::new(&document.file_path);
1✔
996
            if !file_path.exists() {
1✔
997
                return Ok(TestExecutionResult::fail(
×
998
                    format!("Update mode: file '{}' does not exist", document.file_path),
×
999
                    None,
×
1000
                ));
×
1001
            }
1✔
1002

1003
            use std::fs::OpenOptions;
1004
            if OpenOptions::new().write(true).open(file_path).is_err() {
1✔
1005
                return Ok(TestExecutionResult::fail(
×
1006
                    format!("Update mode: file '{}' is not writable", document.file_path),
×
1007
                    None,
×
1008
                ));
×
1009
            }
1✔
1010
        }
91✔
1011

1012
        let address = runner_helpers::effective_address(document, self.protocol_override);
92✔
1013

1014
        let (package, service, method) = match document.parse_endpoint() {
92✔
1015
            Some(e) => e,
92✔
1016
            None => {
1017
                return Ok(TestExecutionResult::fail(
×
1018
                    "Invalid or missing endpoint".to_string(),
×
1019
                    None,
×
1020
                ));
×
1021
            }
1022
        };
1023

1024
        if document.sections.is_empty() {
92✔
1025
            return Ok(TestExecutionResult::fail(
×
1026
                "No sections found".to_string(),
×
1027
                None,
×
1028
            ));
×
1029
        }
92✔
1030

1031
        if effective_dry_run {
92✔
1032
            self.print_dry_run_preview(document, &address, &package, &service, &method);
6✔
1033
            return Ok(TestExecutionResult::pass(None));
6✔
1034
        }
86✔
1035

1036
        let document_path = Path::new(&document.file_path);
86✔
1037

1038
        let tls_config = runner_helpers::build_tls_config(document, document_path);
86✔
1039

1040
        let proto_config = runner_helpers::build_proto_config(document, document_path);
86✔
1041

1042
        let full_service = runner_helpers::full_service_name(&package, &service);
86✔
1043

1044
        // Substitute variables in request header values, then guard against any
1045
        // undefined/typo'd placeholder being shipped verbatim in metadata.
1046
        let request_metadata = match document.get_request_headers() {
86✔
1047
            Some(headers) => {
×
1048
                let mut substituted = HashMap::with_capacity(headers.len());
×
1049
                let mut unresolved = Vec::new();
×
1050
                for (key, val) in headers {
×
1051
                    let new_val =
×
1052
                        runner_helpers::interpolate_variables(&val, variables).unwrap_or(val);
×
1053
                    runner_helpers::find_unresolved_placeholders(
×
1054
                        &new_val,
×
1055
                        variables,
×
1056
                        &mut unresolved,
×
1057
                    );
×
1058
                    substituted.insert(key, new_val);
×
1059
                }
×
1060
                if !unresolved.is_empty() {
×
1061
                    return Ok(TestExecutionResult::fail(
×
1062
                        format!(
×
1063
                            "Unresolved variable placeholder(s) in REQUEST_HEADERS: {}",
×
1064
                            runner_helpers::format_unresolved_placeholders(&unresolved)
×
1065
                        ),
×
1066
                        None,
×
1067
                    )
×
1068
                    .with_failure_kind(FailureKind::Assertion));
×
1069
                }
×
1070
                Some(substituted)
×
1071
            }
1072
            None => None,
86✔
1073
        };
1074

1075
        let client_config = GrpcClientConfig {
86✔
1076
            address,
86✔
1077
            timeout_seconds: effective_timeout_seconds,
86✔
1078
            tls_config,
86✔
1079
            proto_config,
86✔
1080
            metadata: request_metadata,
86✔
1081
            target_service: Some(full_service.clone()),
86✔
1082
            compression,
86✔
1083
            connection_id: self.connection_id,
86✔
1084
            protocol: self.protocol_override.unwrap_or_else(|| {
86✔
1085
                document
3✔
1086
                    .get_options()
3✔
1087
                    .and_then(|o| {
3✔
1088
                        o.get("protocol").map(|s| {
×
1089
                            s.parse::<crate::grpc::WireProtocol>()
×
1090
                                .unwrap_or(crate::grpc::WireProtocol::Grpc)
×
1091
                        })
×
1092
                    })
×
1093
                    .unwrap_or(crate::grpc::WireProtocol::Grpc)
3✔
1094
            }),
3✔
1095
            version: env!("CARGO_PKG_VERSION").to_string(),
86✔
1096
        };
1097

1098
        let client_protocol = client_config.protocol;
86✔
1099
        let client_address = client_config.address.clone();
86✔
1100
        let client = GrpcClient::new(client_config).await?;
86✔
1101

1102
        // Get input/output message types for field coverage tracking
1103
        let input_message_type = client
77✔
1104
            .descriptor_pool()
77✔
1105
            .get_service_by_name(&full_service)
77✔
1106
            .and_then(|s| s.methods().find(|m| m.name() == method))
77✔
1107
            .map(|m| m.input().full_name().to_string());
77✔
1108
        let output_message_type = client
77✔
1109
            .descriptor_pool()
77✔
1110
            .get_service_by_name(&full_service)
77✔
1111
            .and_then(|s| s.methods().find(|m| m.name() == method))
77✔
1112
            .map(|m| m.output().full_name().to_string());
77✔
1113

1114
        // Phase 1: RPC mode validation - runtime warning if inferred != actual
1115
        let inferred_rpc_mode = infer_rpc_mode_for_section_types(document);
77✔
1116
        let actual_rpc_mode = get_actual_rpc_mode(&client, &full_service, &method);
77✔
1117
        if let Some(warning) = check_rpc_mode_compatibility(&inferred_rpc_mode, &actual_rpc_mode) {
77✔
1118
            tracing::debug!("{} (service={}, method={})", warning, full_service, method);
×
1119
        }
77✔
1120

1121
        let (tx, rx) = mpsc::channel::<Value>(runner_helpers::REQUEST_CHANNEL_BUFFER);
77✔
1122
        let request_stream = ReceiverStream::new(rx);
77✔
1123
        let mut tx = Some(tx);
77✔
1124

1125
        if let Some(collector) = &self.coverage_collector {
77✔
1126
            collector.register_pool(client.descriptor_pool());
×
1127
            collector.record_call(&full_service, &method);
×
1128
        }
77✔
1129

1130
        let start_time = std::time::Instant::now();
77✔
1131

1132
        // Determine RPC mode for HTTP transport (connectrpc/grpc-web needs to know streaming type)
1133
        let rpc_mode: Option<crate::grpc::RpcMode> = match inferred_rpc_mode {
77✔
1134
            RpcModeInfo::Unary => Some(crate::grpc::RpcMode::Unary),
77✔
1135
            RpcModeInfo::ServerStreaming => Some(crate::grpc::RpcMode::ServerStream),
×
1136
            RpcModeInfo::ClientStreaming => Some(crate::grpc::RpcMode::ClientStream),
×
1137
            RpcModeInfo::BidirectionalStreaming => Some(crate::grpc::RpcMode::Bidi),
×
1138
            RpcModeInfo::Unknown => None,
×
1139
        };
1140

1141
        // For HTTP bidi, the runner must send all requests before reading any responses.
1142
        let is_http_bidi = client_protocol != crate::grpc::WireProtocol::Grpc
77✔
1143
            && rpc_mode == Some(crate::grpc::RpcMode::Bidi);
×
1144
        let mut deferred_bidi_expectations: Vec<Value> = Vec::new();
77✔
1145

1146
        // Start the gRPC call in background so unary/server-streaming methods can wait
1147
        // for the first request message without deadlocking this task.
1148
        let full_service_clone = full_service.clone();
77✔
1149
        let method_clone = method.clone();
77✔
1150
        let mut client_for_call = client;
77✔
1151
        let mut call_handle = Some(tokio::spawn(async move {
77✔
1152
            client_for_call
77✔
1153
                .call_stream(&full_service_clone, &method_clone, request_stream, rpc_mode)
77✔
1154
                .await
77✔
1155
        }));
77✔
1156

1157
        let mut response_stream = None;
77✔
1158

1159
        // variables passed from caller (shared across chain)
1160
        let mut last_message: Option<Value> = None;
77✔
1161
        let mut last_error_message: Option<String> = None;
77✔
1162
        let mut last_error_json: Option<Value> = None;
77✔
1163
        let mut last_error_timing: Option<AssertionTiming> = None;
77✔
1164
        let mut captured_headers: HashMap<String, String> = HashMap::new();
77✔
1165
        let mut captured_trailers: HashMap<String, String> = HashMap::new();
77✔
1166
        let mut failure_reasons: Vec<String> = Vec::new();
77✔
1167
        let mut assertion_records: Vec<apif_state::AssertionRecord> = Vec::new();
77✔
1168
        let mut assertion_timing = AssertionScopeTimingState::default();
77✔
1169
        // Transport-level failures (connection refused, stream startup errors,
1170
        // stream read timeouts, ...) must never be masked in write mode and
1171
        // must never trigger a snapshot rewrite.
1172
        let mut transport_failure = false;
77✔
1173
        // Set when any REQUEST's retry loop needed more than one attempt to
1174
        // succeed — surfaced on the result so a test that only passed after a
1175
        // retry can be flagged flaky instead of looking like a clean pass.
1176
        let mut retry_occurred = false;
77✔
1177
        // Numeric gRPC status observed on this call (from a returned GrpcError),
1178
        // surfaced on the result so bench can bucket by real status code.
1179
        let mut grpc_status: Option<u32> = None;
77✔
1180

1181
        // We iterate by index to allow lookahead
1182
        let sections = &document.sections;
77✔
1183

1184
        // Pre-compute last request index to avoid O(n²) lookups in loop
1185
        let last_request_idx = sections
77✔
1186
            .iter()
77✔
1187
            .rposition(|s| s.section_type == SectionType::Request);
152✔
1188

1189
        let has_request_sections = sections
77✔
1190
            .iter()
77✔
1191
            .any(|s| s.section_type == SectionType::Request);
254✔
1192

1193
        // Legacy behavior: if no REQUEST section is provided, send an empty
1194
        // JSON object as a single request message for unary/server-stream calls.
1195
        if !has_request_sections && let Some(tx_ref) = tx.as_mut() {
77✔
1196
            if let Err(e) = tx_ref.send(Value::Object(serde_json::Map::new())).await {
×
1197
                failure_reasons.push(format!("Failed to send implicit empty request: {}", e));
×
1198
                transport_failure = true;
×
1199
            }
×
1200
            drop(tx.take());
×
1201
        }
77✔
1202

1203
        let mut skip_next_section = false;
77✔
1204

1205
        let mut captured_response = if effective_write_mode || self.capture_exchange {
77✔
1206
            Some(crate::grpc::GrpcResponse::new())
22✔
1207
        } else {
1208
            None
55✔
1209
        };
1210

1211
        /// Awaits the gRPC call handle and extracts the response stream.
1212
        /// Eliminates duplication across Response and Asserts section handlers.
1213
        macro_rules! ensure_stream_ready {
1214
            () => {
1215
                if response_stream.is_none()
1216
                    && let Some(handle) = call_handle.take()
1217
                {
1218
                    match handle.await {
1219
                        Ok(Ok((h, stream))) => {
1220
                            if let Some(resp) = &mut captured_response {
1221
                                captured_headers = h.clone();
1222
                                resp.headers = h;
1223
                            } else {
1224
                                captured_headers = h;
1225
                            }
1226
                            response_stream = Some(stream);
1227
                        }
1228
                        Ok(Err(e)) => {
1229
                            failure_reasons.push(format!("Failed to start gRPC stream: {}", e));
1230
                            transport_failure = true;
1231
                            break;
1232
                        }
1233
                        Err(e) => {
1234
                            failure_reasons
1235
                                .push(format!("Failed to join gRPC stream startup task: {}", e));
1236
                            transport_failure = true;
1237
                            break;
1238
                        }
1239
                    }
1240
                }
1241
            };
1242
        }
1243

1244
        let mut inherited_attrs: Vec<crate::parser::ast::GctfAttribute> = Vec::new();
77✔
1245

1246
        for (i, section) in sections.iter().enumerate() {
329✔
1247
            let resolved_attrs = crate::parser::content_parser::resolve_attributes(
329✔
1248
                &section.attributes,
329✔
1249
                &inherited_attrs,
329✔
1250
            );
1251
            inherited_attrs = resolved_attrs.clone();
329✔
1252

1253
            let get_attr = |name: &str| -> Option<&crate::parser::ast::GctfAttribute> {
886✔
1254
                resolved_attrs.iter().find(|a| a.name == name)
886✔
1255
            };
886✔
1256
            let get_timeout = || -> Option<u64> {
329✔
1257
                get_attr("timeout")
151✔
1258
                    .and_then(|a| a.parse_u64())
151✔
1259
                    .filter(|&v| v > 0)
151✔
1260
            };
151✔
1261
            let get_retry = || -> Option<u32> { get_attr("retry").and_then(|a| a.parse_u32()) };
329✔
1262
            let get_repeat = || -> Option<u32> {
329✔
1263
                get_attr("repeat")
329✔
1264
                    .and_then(|a| a.parse_u32())
329✔
1265
                    .filter(|&v| v >= 1)
329✔
1266
            };
329✔
1267
            let get_skip = || -> bool {
329✔
1268
                get_attr("skip")
329✔
1269
                    .and_then(|a| a.parse_bool())
329✔
1270
                    .unwrap_or(false)
329✔
1271
            };
329✔
1272

1273
            if skip_next_section {
329✔
1274
                skip_next_section = false;
×
1275
                continue;
×
1276
            }
329✔
1277

1278
            if get_skip() {
329✔
1279
                continue;
×
1280
            }
329✔
1281

1282
            let repeat_count = get_repeat().unwrap_or(1);
329✔
1283
            'repeat_iters: for repeat_iter in 0..repeat_count {
329✔
1284
                if repeat_count > 1 {
329✔
1285
                    eprintln!(
×
1286
                        "   [repeat] section at line {} — iteration {}/{}",
×
1287
                        section.start_line,
×
1288
                        repeat_iter + 1,
×
1289
                        repeat_count
×
1290
                    );
×
1291
                }
329✔
1292

1293
                match section.section_type {
329✔
1294
                    SectionType::Request => {
1295
                        // A JsonLines REQUEST sends one message per value, in
1296
                        // order, on the same stream — the client/bidi-streaming
1297
                        // symmetric counterpart to RESPONSE's JsonLines (one
1298
                        // expected value per streamed message received).
1299
                        let request_values: Vec<Value> = match &section.content {
77✔
1300
                            SectionContent::Json(req_json) => vec![req_json.clone()],
77✔
1301
                            SectionContent::JsonLines(values) => values.clone(),
×
1302
                            SectionContent::Empty => vec![Value::Object(serde_json::Map::new())],
×
1303
                            _ => continue,
×
1304
                        };
1305

1306
                        for mut request_value in request_values {
77✔
1307
                            if !matches!(section.content, SectionContent::Empty) {
77✔
1308
                                self.substitute_variables(&mut request_value, variables);
77✔
1309
                                let mut unresolved = Vec::new();
77✔
1310
                                runner_helpers::collect_unresolved_placeholders(
77✔
1311
                                    &request_value,
77✔
1312
                                    variables,
77✔
1313
                                    &mut unresolved,
77✔
1314
                                );
1315
                                if !unresolved.is_empty() {
77✔
1316
                                    // An undefined/typo'd variable must never be
1317
                                    // shipped to the wire as a literal `{{...}}`.
1318
                                    return Ok(TestExecutionResult::fail(
×
1319
                                        format!(
×
1320
                                            "Unresolved variable placeholder(s) in REQUEST at line {}: {}",
×
1321
                                            section.start_line,
×
1322
                                            runner_helpers::format_unresolved_placeholders(
×
1323
                                                &unresolved,
×
1324
                                            )
×
1325
                                        ),
×
1326
                                        Some(start_time.elapsed().as_millis() as u64),
×
1327
                                    )
×
1328
                                    .with_failure_kind(FailureKind::Assertion));
×
1329
                                }
77✔
1330
                            }
×
1331

1332
                            if let (Some(collector), Some(msg_type)) =
×
1333
                                (&self.coverage_collector, &input_message_type)
77✔
1334
                            {
×
1335
                                collector.record_fields_from_json(msg_type, &request_value);
×
1336
                            }
77✔
1337

1338
                            let Some(tx_ref) = tx.as_mut() else {
77✔
1339
                                failure_reasons.push(format!(
×
1340
                                    "Failed to send request at line {}: request stream already closed",
1341
                                    section.start_line
1342
                                ));
1343
                                break 'repeat_iters;
×
1344
                            };
1345

1346
                            let section_timeout = get_timeout();
77✔
1347
                            let effective_timeout =
77✔
1348
                                section_timeout.unwrap_or(effective_timeout_seconds);
77✔
1349
                            let max_retries = get_retry().unwrap_or(0);
77✔
1350
                            let mut attempt = 0;
77✔
1351

1352
                            let send_with_timeout = || async {
77✔
1353
                                if effective_timeout > 0 {
77✔
1354
                                    let send_fut = self.request_handler.send_request(
77✔
1355
                                        tx_ref,
77✔
1356
                                        request_value.clone(),
77✔
1357
                                        section.start_line,
77✔
1358
                                        None,
77✔
1359
                                    );
1360
                                    match tokio::time::timeout(
77✔
1361
                                        std::time::Duration::from_secs(effective_timeout),
77✔
1362
                                        send_fut,
77✔
1363
                                    )
1364
                                    .await
77✔
1365
                                    {
1366
                                        Ok(r) => r,
77✔
1367
                                        Err(_) => RequestSendResult {
×
1368
                                            success: false,
×
1369
                                            error_message: Some(format!(
×
1370
                                                "Request timed out after {}s (section timeout)",
×
1371
                                                effective_timeout
×
1372
                                            )),
×
1373
                                        },
×
1374
                                    }
1375
                                } else {
1376
                                    self.request_handler
×
1377
                                        .send_request(
×
1378
                                            tx_ref,
×
1379
                                            request_value.clone(),
×
1380
                                            section.start_line,
×
1381
                                            None,
×
1382
                                        )
×
1383
                                        .await
×
1384
                                }
1385
                            };
154✔
1386

1387
                            let result = loop {
77✔
1388
                                if attempt > 0 {
77✔
1389
                                    tokio::time::sleep(std::time::Duration::from_millis(
×
1390
                                        100 * attempt as u64,
×
1391
                                    ))
×
1392
                                    .await;
×
1393
                                }
77✔
1394
                                let r = send_with_timeout().await;
77✔
1395
                                if r.success || attempt >= max_retries {
77✔
1396
                                    if r.success && attempt > 0 {
77✔
1397
                                        retry_occurred = true;
×
1398
                                    }
77✔
1399
                                    break r;
77✔
1400
                                }
×
1401
                                attempt += 1;
×
1402
                            };
1403
                            if !result.success
77✔
1404
                                && let Some(error) = result.error_message
×
1405
                            {
×
1406
                                failure_reasons.push(error);
×
1407
                                transport_failure = true;
×
1408
                            }
77✔
1409
                        }
1410
                    }
1411
                    SectionType::Response => {
1412
                        let scope_start_ms = assertion_timing.last_message_elapsed_ms.unwrap_or(0);
25✔
1413
                        let mut scope_end_ms = scope_start_ms;
25✔
1414
                        let mut scope_message_count = 0usize;
25✔
1415

1416
                        if i >= last_request_idx.unwrap_or(usize::MAX) {
25✔
1417
                            drop(tx.take());
25✔
1418
                        }
25✔
1419

1420
                        // For HTTP bidi, buffer expected values until stream is ready.
1421
                        if is_http_bidi && i < last_request_idx.unwrap_or(usize::MAX) {
25✔
1422
                            let expected_values =
×
1423
                                Self::expected_values_for_response_section(section);
×
1424
                            for exp in expected_values {
×
1425
                                deferred_bidi_expectations.push(exp);
×
1426
                            }
×
1427
                            continue;
×
1428
                        }
25✔
1429

1430
                        ensure_stream_ready!();
25✔
1431

1432
                        let mut received_messages_for_section: Vec<Value> = Vec::new();
24✔
1433
                        let section_expected = Self::expected_values_for_response_section(section);
24✔
1434
                        // Merge deferred expectations (from earlier skipped response sections)
1435
                        // with the current section's expectations in order.
1436
                        let expected_values: Vec<Value> = deferred_bidi_expectations
24✔
1437
                            .drain(..)
24✔
1438
                            .chain(section_expected)
24✔
1439
                            .collect();
24✔
1440

1441
                        let Some(stream) = response_stream.as_mut() else {
24✔
1442
                            failure_reasons.push(format!(
×
1443
                                "No response stream available for RESPONSE section at line {}",
1444
                                section.start_line
1445
                            ));
1446
                            transport_failure = true;
×
1447
                            break;
×
1448
                        };
1449

1450
                        let read_timeout_secs = get_timeout().unwrap_or(effective_timeout_seconds);
24✔
1451
                        let mut stream_read_timed_out = false;
24✔
1452
                        for expected_template in expected_values {
24✔
1453
                            // Bound each stream read: tonic's Endpoint::timeout only
1454
                            // covers the request/headers phase, not stalled streams.
1455
                            let next_item = if read_timeout_secs > 0 {
24✔
1456
                                match tokio::time::timeout(
24✔
1457
                                    std::time::Duration::from_secs(read_timeout_secs),
24✔
1458
                                    stream.next(),
24✔
1459
                                )
1460
                                .await
24✔
1461
                                {
1462
                                    Ok(item) => item,
24✔
1463
                                    Err(_) => {
1464
                                        failure_reasons.push(format!(
×
1465
                                            "Timed out after {}s waiting for stream message for RESPONSE section at line {}",
1466
                                            read_timeout_secs, section.start_line
1467
                                        ));
1468
                                        transport_failure = true;
×
1469
                                        stream_read_timed_out = true;
×
1470
                                        break;
×
1471
                                    }
1472
                                }
1473
                            } else {
1474
                                stream.next().await
×
1475
                            };
1476
                            match next_item {
24✔
1477
                                Some(Ok(item)) => match item {
24✔
1478
                                    crate::grpc::client::StreamItem::Message(msg) => {
24✔
1479
                                        let now_elapsed_ms =
24✔
1480
                                            start_time.elapsed().as_millis() as u64;
24✔
1481

1482
                                        let msg_for_state = msg.clone();
24✔
1483
                                        last_message = Some(msg_for_state.clone());
24✔
1484
                                        if section.inline_options.with_asserts {
24✔
1485
                                            received_messages_for_section
×
1486
                                                .push(msg_for_state.clone());
×
1487
                                        }
24✔
1488
                                        scope_end_ms = now_elapsed_ms;
24✔
1489
                                        scope_message_count += 1;
24✔
1490
                                        assertion_timing.last_message_elapsed_ms =
24✔
1491
                                            Some(now_elapsed_ms);
24✔
1492
                                        if let Some(resp) = &mut captured_response {
24✔
1493
                                            resp.messages.push(msg_for_state);
×
1494
                                        }
24✔
1495

1496
                                        Self::log_response_message(
24✔
1497
                                            &msg,
24✔
1498
                                            effective_no_assert,
24✔
1499
                                            self.verbose,
24✔
1500
                                            protocol_display(client_protocol),
24✔
1501
                                            &client_address,
24✔
1502
                                        );
1503

1504
                                        if !effective_no_assert {
24✔
1505
                                            let mut expected = expected_template.clone();
24✔
1506
                                            self.substitute_variables(&mut expected, variables);
24✔
1507

1508
                                            if let (Some(collector), Some(msg_type)) =
×
1509
                                                (&self.coverage_collector, &output_message_type)
24✔
1510
                                            {
×
1511
                                                collector
×
1512
                                                    .record_fields_from_json(msg_type, &expected);
×
1513
                                            }
24✔
1514

1515
                                            let diffs = JsonComparator::compare(
24✔
1516
                                                &msg,
24✔
1517
                                                &expected,
24✔
1518
                                                &section.inline_options,
24✔
1519
                                            );
1520

1521
                                            if !diffs.is_empty() {
24✔
1522
                                                self.append_response_diffs(
×
1523
                                                    diffs,
×
1524
                                                    section.start_line,
×
1525
                                                    &expected,
×
1526
                                                    &msg,
×
1527
                                                    &mut failure_reasons,
×
1528
                                                );
×
1529
                                            }
24✔
1530
                                        }
×
1531
                                    }
1532
                                    crate::grpc::client::StreamItem::Trailers(t) => {
×
1533
                                        if let Some(resp) = &mut captured_response {
×
1534
                                            captured_trailers.extend(
×
1535
                                                t.iter().map(|(k, v)| (k.clone(), v.clone())),
×
1536
                                            );
1537
                                            resp.trailers.extend(t);
×
1538
                                        } else {
×
1539
                                            captured_trailers.extend(t);
×
1540
                                        }
×
1541
                                        if !effective_no_assert {
×
1542
                                            failure_reasons.push(format!(
×
1543
                                                "Expected message for RESPONSE section at line {}, but received Trailers (End of Stream)",
×
1544
                                                section.start_line
×
1545
                                            ));
×
1546
                                        }
×
1547
                                        break;
×
1548
                                    }
1549
                                },
1550
                                Some(Err(status)) => {
×
1551
                                    grpc_status = Some(status.code());
×
1552
                                    let scope_start_ms =
×
1553
                                        assertion_timing.last_message_elapsed_ms.unwrap_or(0);
×
1554
                                    let scope_end_ms = start_time.elapsed().as_millis() as u64;
×
1555
                                    assertion_timing.last_message_elapsed_ms = Some(scope_end_ms);
×
1556
                                    last_error_timing = assertion_timing.finish_scope(
×
1557
                                        scope_start_ms,
×
1558
                                        scope_end_ms,
×
1559
                                        1,
×
1560
                                    );
×
1561
                                    last_error_message = Some(status.message().to_string());
×
1562

1563
                                    if let Some(resp) = &mut captured_response {
×
1564
                                        resp.error = Some(status.message().to_string());
×
1565
                                    }
×
1566
                                    if !effective_no_assert {
×
1567
                                        failure_reasons.push(format!(
×
1568
                                        "Expected message for RESPONSE section at line {}, but received Error: {}",
×
1569
                                        section.start_line,
×
1570
                                        status.message()
×
1571
                                    ));
×
1572
                                    } else {
×
1573
                                        println!("--- RESPONSE (Error) ---");
×
1574
                                        println!("{}", status.message());
×
1575
                                    }
×
1576
                                    break;
×
1577
                                }
1578
                                None => {
1579
                                    if !effective_no_assert {
×
1580
                                        failure_reasons.push(format!(
×
1581
                                        "Expected message for RESPONSE section at line {}, but stream ended",
×
1582
                                        section.start_line
×
1583
                                    ));
×
1584
                                    }
×
1585
                                    break;
×
1586
                                }
1587
                            }
1588
                        }
1589

1590
                        if stream_read_timed_out {
24✔
1591
                            // The stream is stalled; drop it so later sections
×
1592
                            // fail fast instead of timing out one by one.
×
1593
                            response_stream = None;
×
1594
                        }
24✔
1595

1596
                        // If this RESPONSE is the last section that reads from the
1597
                        // stream, drain any trailing items so trailers are captured
1598
                        // before the attached assertions run. Otherwise `@trailer(...)`
1599
                        // on the final RESPONSE sees empty trailers, because trailers
1600
                        // arrive only after the last message. We do NOT drain when a
1601
                        // later section still needs the stream (avoids stealing its
1602
                        // messages / reordering multi-response streams).
1603
                        if !stream_read_timed_out && let Some(stream) = response_stream.as_mut() {
24✔
1604
                            let attaches_next = section.inline_options.with_asserts
24✔
1605
                                && matches!(
×
1606
                                    sections.get(i + 1).map(|s| s.section_type),
×
1607
                                    Some(SectionType::Asserts)
1608
                                );
1609
                            let further_start = if attaches_next { i + 2 } else { i + 1 };
24✔
1610
                            let is_last_reader = sections
24✔
1611
                                .get(further_start..)
24✔
1612
                                .map(|rest| {
24✔
1613
                                    !rest.iter().any(|s| {
24✔
1614
                                        matches!(
×
1615
                                            s.section_type,
×
1616
                                            SectionType::Response
1617
                                                | SectionType::Asserts
1618
                                                | SectionType::Error
1619
                                        )
1620
                                    })
×
1621
                                })
24✔
1622
                                .unwrap_or(true);
24✔
1623

1624
                            if is_last_reader {
24✔
1625
                                loop {
1626
                                    let item = if read_timeout_secs > 0 {
24✔
1627
                                        match tokio::time::timeout(
24✔
1628
                                            std::time::Duration::from_secs(read_timeout_secs),
24✔
1629
                                            stream.next(),
24✔
1630
                                        )
1631
                                        .await
24✔
1632
                                        {
1633
                                            Ok(it) => it,
24✔
1634
                                            // Expectations already satisfied; a stalled
1635
                                            // trailer read must not fail the test.
1636
                                            Err(_) => break,
×
1637
                                        }
1638
                                    } else {
1639
                                        stream.next().await
×
1640
                                    };
1641
                                    match item {
24✔
1642
                                        Some(Ok(crate::grpc::client::StreamItem::Trailers(t))) => {
24✔
1643
                                            if let Some(resp) = &mut captured_response {
24✔
1644
                                                resp.trailers.extend(
×
1645
                                                    t.iter().map(|(k, v)| (k.clone(), v.clone())),
×
1646
                                                );
1647
                                            }
24✔
1648
                                            captured_trailers.extend(t);
24✔
1649
                                            break;
24✔
1650
                                        }
1651
                                        Some(Ok(crate::grpc::client::StreamItem::Message(msg))) => {
×
1652
                                            // Over-delivery beyond expectations: keep for
1653
                                            // snapshot fidelity but do not assert on it.
1654
                                            if let Some(resp) = &mut captured_response {
×
1655
                                                resp.messages.push(msg);
×
1656
                                            }
×
1657
                                        }
1658
                                        // Error or end-of-stream: nothing more to capture.
1659
                                        _ => break,
×
1660
                                    }
1661
                                }
1662
                            }
×
1663
                        }
×
1664

1665
                        if section.inline_options.with_asserts
24✔
1666
                            && let Some(next_section) = sections.get(i + 1)
×
1667
                            && next_section.section_type == SectionType::Asserts
×
1668
                        {
1669
                            if !effective_no_assert
×
1670
                                && let SectionContent::Assertions(lines) = &next_section.content
×
1671
                            {
1672
                                let scope_timing = assertion_timing.finish_scope(
×
1673
                                    scope_start_ms,
×
1674
                                    scope_end_ms,
×
1675
                                    scope_message_count,
×
1676
                                );
1677

1678
                                for msg in &received_messages_for_section {
×
1679
                                    self.run_assertions(
×
1680
                                        lines,
×
1681
                                        msg,
×
1682
                                        &mut failure_reasons,
×
1683
                                        &mut assertion_records,
×
1684
                                        format!(
×
1685
                                            "(attached to RESPONSE at line {})",
×
1686
                                            section.start_line
×
1687
                                        ),
×
1688
                                        section.start_line,
×
1689
                                        AssertionContext {
×
1690
                                            headers: &captured_headers,
×
1691
                                            trailers: &captured_trailers,
×
1692
                                            timing: scope_timing.as_ref(),
×
1693
                                            variables: &*variables,
×
1694
                                            protocol: protocol_str(client_protocol),
×
1695
                                        },
×
1696
                                    );
×
1697
                                }
×
1698
                            }
×
1699
                            skip_next_section = true;
×
1700
                        } else if section.inline_options.with_asserts && !effective_no_assert {
24✔
1701
                            failure_reasons.push(format!(
×
1702
                            "RESPONSE at line {} has 'with_asserts' but is not followed by ASSERTS",
×
1703
                            section.start_line
×
1704
                        ));
×
1705
                        }
24✔
1706
                    }
1707
                    SectionType::Asserts => {
1708
                        if i >= last_request_idx.unwrap_or(usize::MAX) {
50✔
1709
                            drop(tx.take());
50✔
1710
                        }
50✔
1711

1712
                        ensure_stream_ready!();
50✔
1713

1714
                        // If we have a captured error context (from a preceding ERROR section),
1715
                        // use that instead of reading from the stream.
1716
                        if last_error_json.is_some() || last_error_message.is_some() {
50✔
1717
                            if !effective_no_assert
×
1718
                                && let SectionContent::Assertions(lines) = &section.content
×
1719
                            {
1720
                                if let Some(error_value) = &last_error_json {
×
1721
                                    self.run_assertions(
×
1722
                                        lines,
×
1723
                                        error_value,
×
1724
                                        &mut failure_reasons,
×
1725
                                        &mut assertion_records,
×
1726
                                        format!("after ERROR at line {}", section.start_line),
×
1727
                                        section.start_line,
×
1728
                                        AssertionContext {
×
1729
                                            headers: &captured_headers,
×
1730
                                            trailers: &captured_trailers,
×
1731
                                            timing: last_error_timing.as_ref(),
×
1732
                                            variables: &*variables,
×
1733
                                            protocol: protocol_str(client_protocol),
×
1734
                                        },
×
1735
                                    );
×
1736
                                } else if let Some(error_message) = &last_error_message {
×
1737
                                    let error_value = Value::String(error_message.clone());
×
1738
                                    self.run_assertions(
×
1739
                                        lines,
×
1740
                                        &error_value,
×
1741
                                        &mut failure_reasons,
×
1742
                                        &mut assertion_records,
×
1743
                                        format!("after ERROR at line {}", section.start_line),
×
1744
                                        section.start_line,
×
1745
                                        AssertionContext {
×
1746
                                            headers: &captured_headers,
×
1747
                                            trailers: &captured_trailers,
×
1748
                                            timing: last_error_timing.as_ref(),
×
1749
                                            variables: &*variables,
×
1750
                                            protocol: protocol_str(client_protocol),
×
1751
                                        },
×
1752
                                    );
×
1753
                                }
×
1754
                            }
×
1755
                            continue;
×
1756
                        }
50✔
1757

1758
                        let Some(stream) = response_stream.as_mut() else {
50✔
1759
                            if !effective_no_assert {
×
1760
                                failure_reasons.push(format!(
×
1761
                                    "ASSERTS section at line {} has no active response/error context",
×
1762
                                    section.start_line
×
1763
                                ));
×
1764
                            }
×
1765
                            continue;
×
1766
                        };
1767

1768
                        let read_timeout_secs = get_timeout().unwrap_or(effective_timeout_seconds);
50✔
1769
                        let next_item = if read_timeout_secs > 0 {
50✔
1770
                            match tokio::time::timeout(
50✔
1771
                                std::time::Duration::from_secs(read_timeout_secs),
50✔
1772
                                stream.next(),
50✔
1773
                            )
1774
                            .await
50✔
1775
                            {
1776
                                Ok(item) => item,
50✔
1777
                                Err(_) => {
1778
                                    failure_reasons.push(format!(
×
1779
                                        "Timed out after {}s waiting for stream message for ASSERTS section at line {}",
1780
                                        read_timeout_secs, section.start_line
1781
                                    ));
1782
                                    transport_failure = true;
×
1783
                                    response_stream = None;
×
1784
                                    continue;
×
1785
                                }
1786
                            }
1787
                        } else {
1788
                            stream.next().await
×
1789
                        };
1790

1791
                        match next_item {
50✔
1792
                            Some(Ok(crate::grpc::client::StreamItem::Message(msg))) => {
50✔
1793
                                let scope_start_ms =
50✔
1794
                                    assertion_timing.last_message_elapsed_ms.unwrap_or(0);
50✔
1795
                                let scope_end_ms = start_time.elapsed().as_millis() as u64;
50✔
1796
                                assertion_timing.last_message_elapsed_ms = Some(scope_end_ms);
50✔
1797
                                let scope_timing =
50✔
1798
                                    assertion_timing.finish_scope(scope_start_ms, scope_end_ms, 1);
50✔
1799

1800
                                last_message = Some(msg.clone());
50✔
1801

1802
                                let should_format_message =
50✔
1803
                                    tracing::enabled!(tracing::Level::DEBUG) || effective_no_assert;
50✔
1804
                                let msg_pretty = should_format_message
50✔
1805
                                    .then(|| runner_helpers::format_json_pretty(&msg));
50✔
1806

1807
                                if let Some(pretty) = msg_pretty.as_deref()
50✔
1808
                                    && tracing::enabled!(tracing::Level::DEBUG)
×
1809
                                {
1810
                                    tracing::debug!("Received Response (for Asserts):\n{}", pretty);
×
1811
                                }
50✔
1812

1813
                                if effective_no_assert {
50✔
1814
                                    println!("--- RESPONSE (Raw) ---");
×
1815
                                    if let Some(pretty) = msg_pretty.as_deref() {
×
1816
                                        println!("{}", pretty);
×
1817
                                    }
×
1818
                                }
50✔
1819

1820
                                if !effective_no_assert
50✔
1821
                                    && let SectionContent::Assertions(lines) = &section.content
50✔
1822
                                {
50✔
1823
                                    self.run_assertions(
50✔
1824
                                        lines,
50✔
1825
                                        &msg,
50✔
1826
                                        &mut failure_reasons,
50✔
1827
                                        &mut assertion_records,
50✔
1828
                                        format!("at line {}", section.start_line),
50✔
1829
                                        section.start_line,
50✔
1830
                                        AssertionContext {
50✔
1831
                                            headers: &captured_headers,
50✔
1832
                                            trailers: &captured_trailers,
50✔
1833
                                            timing: scope_timing.as_ref(),
50✔
1834
                                            variables: &*variables,
50✔
1835
                                            protocol: protocol_str(client_protocol),
50✔
1836
                                        },
50✔
1837
                                    );
50✔
1838
                                }
50✔
1839
                            }
1840
                            Some(Ok(crate::grpc::client::StreamItem::Trailers(t))) => {
×
1841
                                captured_trailers.extend(t);
×
1842
                                // Trailers arrive at end-of-stream. A standalone ASSERTS
1843
                                // positioned here is asserting on trailers/status rather
1844
                                // than a message body: evaluate it against the last
1845
                                // received message (or Null when none) with the captured
1846
                                // trailers in context, so `@trailer(...)`/`@has_trailer(...)`
1847
                                // work instead of the section unconditionally failing.
1848
                                if !effective_no_assert
×
1849
                                    && let SectionContent::Assertions(lines) = &section.content
×
1850
                                {
×
1851
                                    let target = last_message.clone().unwrap_or(Value::Null);
×
1852
                                    self.run_assertions(
×
1853
                                        lines,
×
1854
                                        &target,
×
1855
                                        &mut failure_reasons,
×
1856
                                        &mut assertion_records,
×
1857
                                        format!("(trailers) at line {}", section.start_line),
×
1858
                                        section.start_line,
×
1859
                                        AssertionContext {
×
1860
                                            headers: &captured_headers,
×
1861
                                            trailers: &captured_trailers,
×
1862
                                            timing: None,
×
1863
                                            variables: &*variables,
×
1864
                                            protocol: protocol_str(client_protocol),
×
1865
                                        },
×
1866
                                    );
×
1867
                                }
×
1868
                            }
1869
                            Some(Err(status)) => {
×
1870
                                grpc_status = Some(status.code());
×
1871
                                let scope_start_ms =
×
1872
                                    assertion_timing.last_message_elapsed_ms.unwrap_or(0);
×
1873
                                let scope_end_ms = start_time.elapsed().as_millis() as u64;
×
1874
                                assertion_timing.last_message_elapsed_ms = Some(scope_end_ms);
×
1875
                                last_error_timing =
×
1876
                                    assertion_timing.finish_scope(scope_start_ms, scope_end_ms, 1);
×
1877

1878
                                last_error_message = Some(status.message().to_string());
×
1879
                                let error_json =
×
1880
                                    super::error_handler::ErrorHandler::status_to_json(&status);
×
1881
                                last_error_json = Some(error_json.clone());
×
1882
                                captured_trailers.extend(status.metadata().clone());
×
1883
                                if !effective_no_assert
×
1884
                                    && let SectionContent::Assertions(lines) = &section.content
×
1885
                                {
×
1886
                                    self.run_assertions(
×
1887
                                        lines,
×
1888
                                        &error_json,
×
1889
                                        &mut failure_reasons,
×
1890
                                        &mut assertion_records,
×
1891
                                        format!("after ERROR at line {}", section.start_line),
×
1892
                                        section.start_line,
×
1893
                                        AssertionContext {
×
1894
                                            headers: &captured_headers,
×
1895
                                            trailers: &captured_trailers,
×
1896
                                            timing: last_error_timing.as_ref(),
×
1897
                                            variables: &*variables,
×
1898
                                            protocol: protocol_str(client_protocol),
×
1899
                                        },
×
1900
                                    );
×
1901
                                } else {
×
1902
                                    println!("--- RESPONSE (Error) ---");
×
1903
                                    println!("{}", status.message());
×
1904
                                }
×
1905
                            }
1906
                            None => {
1907
                                if !effective_no_assert {
×
1908
                                    failure_reasons.push(format!(
×
1909
                                    "Expected message for ASSERTS section at line {}, but stream ended",
×
1910
                                    section.start_line
×
1911
                                ));
×
1912
                                }
×
1913
                            }
1914
                        }
1915
                    }
1916

1917
                    SectionType::Extract => {
1918
                        if let Some(msg) = &last_message {
×
1919
                            if let SectionContent::Extract(extractions) = &section.content {
×
1920
                                for (key, query) in extractions {
×
1921
                                    match self.assertion_engine.query(query, msg) {
×
1922
                                        Ok(results) => {
×
1923
                                            if let Some(val) = results.first() {
×
1924
                                                variables.insert(key.clone(), val.clone());
×
1925
                                            } else {
×
1926
                                                failure_reasons.push(format!(
×
1927
                                                 "Extraction failed at line {}: Query '{}' returned no results",
×
1928
                                                 section.start_line, query
×
1929
                                             ));
×
1930
                                            }
×
1931
                                        }
1932
                                        Err(e) => {
×
1933
                                            failure_reasons.push(format!(
×
1934
                                                "Extraction error at line {}: {}",
×
1935
                                                section.start_line, e
×
1936
                                            ));
×
1937
                                        }
×
1938
                                    }
1939
                                }
1940
                            }
×
1941
                        } else {
×
1942
                            failure_reasons.push(format!(
×
1943
                                "EXTRACT at line {} requires a previous response message",
×
1944
                                section.start_line
×
1945
                            ));
×
1946
                        }
×
1947
                    }
1948
                    SectionType::Error => {
1949
                        if i >= last_request_idx.unwrap_or(usize::MAX) {
×
1950
                            drop(tx.take());
×
1951
                        }
×
1952

1953
                        if response_stream.is_none()
×
1954
                            && let Some(handle) = call_handle.take()
×
1955
                        {
1956
                            match handle.await {
×
1957
                                Ok(Ok((h, stream))) => {
×
1958
                                    if let Some(resp) = &mut captured_response {
×
1959
                                        captured_headers = h.clone();
×
1960
                                        resp.headers = h;
×
1961
                                    } else {
×
1962
                                        captured_headers = h;
×
1963
                                    }
×
1964
                                    response_stream = Some(stream);
×
1965
                                }
1966
                                Ok(Err(_e)) => {
×
1967
                                    let e = _e;
×
1968
                                    if let Some(status) =
×
1969
                                        e.downcast_ref::<apif_grpc_transport::GrpcError>()
×
1970
                                    {
×
1971
                                        grpc_status = Some(status.code());
×
1972
                                    }
×
1973
                                    if let Some(resp) = &mut captured_response {
×
1974
                                        match e.downcast_ref::<apif_grpc_transport::GrpcError>() {
×
1975
                                            // Code 14 = UNAVAILABLE: connection-level
1976
                                            // failure, not a genuine server response —
1977
                                            // never snapshot it.
1978
                                            Some(status) if status.code() != 14 => {
×
1979
                                                resp.error = Some(status.message().to_string());
×
1980
                                            }
×
1981
                                            _ => transport_failure = true,
×
1982
                                        }
1983
                                    }
×
1984
                                    let mut error_assert_target: Option<Value> = None;
×
1985
                                    if !effective_no_assert {
×
1986
                                        if let SectionContent::Json(expected_json) =
×
1987
                                            &section.content
×
1988
                                        {
1989
                                            let mut expected = expected_json.clone();
×
1990
                                            self.substitute_variables(&mut expected, variables);
×
1991

1992
                                            // Try to extract tonic Status from anyhow::Error
1993
                                            let (matches, got, mismatch_reason) = if let Some(
×
1994
                                                status,
×
1995
                                            ) =
1996
                                                e.downcast_ref::<apif_grpc_transport::GrpcError>()
×
1997
                                            {
1998
                                                last_error_message =
×
1999
                                                    Some(status.message().to_string());
×
2000
                                                let actual_error_json =
×
2001
                                                super::error_handler::ErrorHandler::status_to_json(
×
2002
                                                    status,
×
2003
                                                );
2004
                                                last_error_json = Some(actual_error_json.clone());
×
2005
                                                error_assert_target =
×
2006
                                                    Some(actual_error_json.clone());
×
2007
                                                captured_trailers.extend(status.metadata().clone());
×
2008
                                                let status_name =
×
2009
                                                    Self::grpc_code_name_from_numeric(
×
2010
                                                        status.code() as i64,
×
2011
                                                    )
2012
                                                    .unwrap_or("Unknown");
×
2013
                                                (
×
2014
                                                super::error_handler::ErrorHandler::status_matches_expected_with_options(
×
2015
                                                    status,
×
2016
                                                    &expected,
×
2017
                                                    section.inline_options.partial,
×
2018
                                                ),
×
2019
                                                format!(
×
2020
                                                    "status: {}, message: \"{}\"",
×
2021
                                                    status_name,
×
2022
                                                    status.message()
×
2023
                                                ),
×
2024
                                                super::error_handler::ErrorHandler::status_mismatch_reason_with_options(
×
2025
                                                    status,
×
2026
                                                    &expected,
×
2027
                                                    section.inline_options.partial,
×
2028
                                                ),
×
2029
                                            )
×
2030
                                            } else {
2031
                                                // Fallback to error string representation
2032
                                                let text = e.to_string();
×
2033
                                                error_assert_target =
×
2034
                                                    Some(Value::String(text.clone()));
×
2035
                                                (
×
2036
                                                    Self::error_matches_expected(&text, &expected),
×
2037
                                                    text,
×
2038
                                                    None,
×
2039
                                                )
×
2040
                                            };
2041

2042
                                            if self.verbose {
×
2043
                                                println!(
×
2044
                                                    "[{}@{}] 🔍 gRPC error received: '{}'",
2045
                                                    protocol_display(client_protocol),
×
2046
                                                    client_address,
2047
                                                    got
2048
                                                );
2049
                                                if let Some(status) = e
×
2050
                                                    .downcast_ref::<apif_grpc_transport::GrpcError>(
×
2051
                                                ) {
×
2052
                                                    let details_json = super::error_handler::ErrorHandler::status_details_json(status);
×
2053
                                                    if details_json != Value::Null
×
2054
                                                        && details_json
×
2055
                                                            .as_array()
×
2056
                                                            .is_some_and(|arr| !arr.is_empty())
×
2057
                                                    {
×
2058
                                                        println!(
×
2059
                                                            "🔍 gRPC error details: {}",
×
2060
                                                            details_json
×
2061
                                                        );
×
2062
                                                    }
×
2063
                                                }
×
2064
                                            }
×
2065

2066
                                            if !matches {
×
2067
                                                failure_reasons.push(format!(
×
2068
                                                    "Error mismatch at line {}:",
2069
                                                    section.start_line
2070
                                                ));
2071
                                                if let Some(reason) = mismatch_reason {
×
2072
                                                    failure_reasons.push(format!("  - {}", reason));
×
2073
                                                }
×
2074
                                                if let Some(status) = e
×
2075
                                                    .downcast_ref::<apif_grpc_transport::GrpcError>(
×
2076
                                                ) {
×
2077
                                                    let actual_json =
×
2078
                                                    super::error_handler::ErrorHandler::status_to_json(
×
2079
                                                        status,
×
2080
                                                    );
×
2081
                                                    failure_reasons.push(get_json_diff(
×
2082
                                                        &expected,
×
2083
                                                        &actual_json,
×
2084
                                                    ));
×
2085
                                                } else {
×
2086
                                                    failure_reasons.push(format!(
×
2087
                                                        "  - expected {}, got '{}'",
×
2088
                                                        expected, got
×
2089
                                                    ));
×
2090
                                                }
×
2091
                                            }
×
2092
                                        }
×
2093
                                    } else {
×
2094
                                        println!("--- RESPONSE (Error) ---");
×
2095
                                        println!("{}", e);
×
2096
                                    }
×
2097

2098
                                    if Self::has_required_followup_asserts(
×
2099
                                        section,
×
2100
                                        sections,
×
2101
                                        i,
×
2102
                                        effective_no_assert,
×
2103
                                        &mut failure_reasons,
×
2104
                                    ) && let Some(next_section) = sections.get(i + 1)
×
2105
                                        && next_section.section_type == SectionType::Asserts
×
2106
                                    {
2107
                                        if !effective_no_assert
×
2108
                                            && let SectionContent::Assertions(lines) =
×
2109
                                                &next_section.content
×
2110
                                        {
2111
                                            if error_assert_target.is_none()
×
2112
                                                && let Some(status) = e
×
2113
                                                    .downcast_ref::<apif_grpc_transport::GrpcError>(
×
2114
                                                )
×
2115
                                            {
×
2116
                                                let actual_error_json =
×
2117
                                                super::error_handler::ErrorHandler::status_to_json(
×
2118
                                                    status,
×
2119
                                                );
×
2120
                                                last_error_json = Some(actual_error_json.clone());
×
2121
                                                error_assert_target = Some(actual_error_json);
×
2122
                                            }
×
2123

2124
                                            if let Some(target) = error_assert_target.as_ref() {
×
2125
                                                self.run_assertions(
×
2126
                                                    lines,
×
2127
                                                    target,
×
2128
                                                    &mut failure_reasons,
×
2129
                                                    &mut assertion_records,
×
2130
                                                    format!(
×
2131
                                                        "(attached to ERROR at line {})",
×
2132
                                                        section.start_line
×
2133
                                                    ),
×
2134
                                                    section.start_line,
×
2135
                                                    AssertionContext {
×
2136
                                                        headers: &captured_headers,
×
2137
                                                        trailers: &captured_trailers,
×
2138
                                                        timing: last_error_timing.as_ref(),
×
2139
                                                        variables: &*variables,
×
2140
                                                        protocol: protocol_str(client_protocol),
×
2141
                                                    },
×
2142
                                                );
×
2143
                                            }
×
2144
                                        }
×
2145
                                        skip_next_section = true;
×
2146
                                    }
×
2147

2148
                                    // Error has been consumed at startup stage; continue with next sections.
2149
                                    continue;
×
2150
                                }
2151
                                Err(e) => {
×
2152
                                    failure_reasons.push(format!(
×
2153
                                        "Failed to join gRPC stream startup task: {}",
2154
                                        e
2155
                                    ));
2156
                                    transport_failure = true;
×
2157
                                    break;
×
2158
                                }
2159
                            }
2160
                        }
×
2161

2162
                        let Some(error_stream) = response_stream.as_mut() else {
×
2163
                            failure_reasons.push(format!(
×
2164
                                "No response stream available for ERROR section at line {}",
2165
                                section.start_line
2166
                            ));
2167
                            transport_failure = true;
×
2168
                            break;
×
2169
                        };
2170
                        let read_timeout_secs = get_timeout().unwrap_or(effective_timeout_seconds);
×
2171
                        let next_item = if read_timeout_secs > 0 {
×
2172
                            match tokio::time::timeout(
×
2173
                                std::time::Duration::from_secs(read_timeout_secs),
×
2174
                                error_stream.next(),
×
2175
                            )
2176
                            .await
×
2177
                            {
2178
                                Ok(item) => item,
×
2179
                                Err(_) => {
2180
                                    failure_reasons.push(format!(
×
2181
                                        "Timed out after {}s waiting for stream error for ERROR section at line {}",
2182
                                        read_timeout_secs, section.start_line
2183
                                    ));
2184
                                    transport_failure = true;
×
2185
                                    response_stream = None;
×
2186
                                    break;
×
2187
                                }
2188
                            }
2189
                        } else {
2190
                            error_stream.next().await
×
2191
                        };
2192
                        match next_item {
×
2193
                            Some(Err(status)) => {
×
2194
                                grpc_status = Some(status.code());
×
2195
                                let scope_start_ms =
×
2196
                                    assertion_timing.last_message_elapsed_ms.unwrap_or(0);
×
2197
                                let scope_end_ms = start_time.elapsed().as_millis() as u64;
×
2198
                                assertion_timing.last_message_elapsed_ms = Some(scope_end_ms);
×
2199
                                let error_scope_timing =
×
2200
                                    assertion_timing.finish_scope(scope_start_ms, scope_end_ms, 1);
×
2201

2202
                                let status_message = status.message();
×
2203
                                last_error_message = Some(status_message.to_string());
×
2204
                                if let Some(resp) = &mut captured_response {
×
2205
                                    // Genuine error response received from an
×
2206
                                    // established stream — capture for snapshots.
×
2207
                                    resp.error = Some(status_message.to_string());
×
2208
                                }
×
2209
                                let error_json =
×
2210
                                    super::error_handler::ErrorHandler::status_to_json(&status);
×
2211
                                last_error_json = Some(error_json.clone());
×
2212
                                last_error_timing = error_scope_timing;
×
2213
                                captured_trailers.extend(status.metadata().clone());
×
2214
                                let should_format_error = effective_no_assert || self.verbose;
×
2215
                                let got = should_format_error.then(|| {
×
2216
                                    let status_name =
×
2217
                                        Self::grpc_code_name_from_numeric(status.code() as i64)
×
2218
                                            .unwrap_or("Unknown");
×
2219
                                    format!(
×
2220
                                        "status: {}, message: \"{}\"",
2221
                                        status_name, status_message
2222
                                    )
2223
                                });
×
2224

2225
                                if effective_no_assert {
×
2226
                                    println!("--- RESPONSE (Error) ---");
×
2227
                                    if let Some(got) = got.as_deref() {
×
2228
                                        println!("{}", got);
×
2229
                                    }
×
2230
                                } else if self.verbose {
×
2231
                                    if let Some(got) = got.as_deref() {
×
2232
                                        println!(
×
2233
                                            "[{}@{}] 🔍 gRPC error received: '{}'",
×
2234
                                            protocol_display(client_protocol),
×
2235
                                            client_address,
×
2236
                                            got
×
2237
                                        );
×
2238
                                    }
×
2239
                                    let details_json =
×
2240
                                        super::error_handler::ErrorHandler::status_details_json(
×
2241
                                            &status,
×
2242
                                        );
2243
                                    if details_json != Value::Null
×
2244
                                        && details_json
×
2245
                                            .as_array()
×
2246
                                            .is_some_and(|arr| !arr.is_empty())
×
2247
                                    {
×
2248
                                        println!("🔍 gRPC error details: {}", details_json);
×
2249
                                    }
×
2250
                                }
×
2251

2252
                                if !effective_no_assert {
×
2253
                                    if let SectionContent::Json(expected_json) = &section.content {
×
2254
                                        let mut expected = expected_json.clone();
×
2255
                                        self.substitute_variables(&mut expected, variables);
×
2256

2257
                                        if !super::error_handler::ErrorHandler::status_matches_expected_with_options(
×
2258
                                        &status,
×
2259
                                        &expected,
×
2260
                                        section.inline_options.partial,
×
2261
                                    ) {
×
2262
                                        failure_reasons.push(format!(
×
2263
                                            "Error mismatch at line {}:",
2264
                                            section.start_line
2265
                                        ));
2266
                                        if let Some(reason) =
×
2267
                                            super::error_handler::ErrorHandler::status_mismatch_reason_with_options(
×
2268
                                                &status,
×
2269
                                                &expected,
×
2270
                                                section.inline_options.partial,
×
2271
                                            )
×
2272
                                        {
×
2273
                                            failure_reasons.push(format!("  - {}", reason));
×
2274
                                        }
×
2275
                                        let actual_json =
×
2276
                                            super::error_handler::ErrorHandler::status_to_json(
×
2277
                                                &status,
×
2278
                                            );
2279
                                        failure_reasons
×
2280
                                            .push(get_json_diff(&expected, &actual_json));
×
2281
                                    }
×
2282
                                    }
×
2283

2284
                                    if Self::has_required_followup_asserts(
×
2285
                                        section,
×
2286
                                        sections,
×
2287
                                        i,
×
2288
                                        effective_no_assert,
×
2289
                                        &mut failure_reasons,
×
2290
                                    ) && let Some(next_section) = sections.get(i + 1)
×
2291
                                        && next_section.section_type == SectionType::Asserts
×
2292
                                        && let SectionContent::Assertions(lines) =
×
2293
                                            &next_section.content
×
2294
                                    {
×
2295
                                        self.run_assertions(
×
2296
                                            lines,
×
2297
                                            &error_json,
×
2298
                                            &mut failure_reasons,
×
2299
                                            &mut assertion_records,
×
2300
                                            format!(
×
2301
                                                "(attached to ERROR at line {})",
×
2302
                                                section.start_line
×
2303
                                            ),
×
2304
                                            section.start_line,
×
2305
                                            AssertionContext {
×
2306
                                                headers: &captured_headers,
×
2307
                                                trailers: &captured_trailers,
×
2308
                                                timing: last_error_timing.as_ref(),
×
2309
                                                variables: &*variables,
×
2310
                                                protocol: protocol_str(client_protocol),
×
2311
                                            },
×
2312
                                        );
×
2313
                                        skip_next_section = true;
×
2314
                                    }
×
2315
                                } else {
2316
                                    // In no_assert mode, we still need to skip the attached ASSERTS section if present
2317
                                    if section.inline_options.with_asserts
×
2318
                                        && let Some(next_section) = sections.get(i + 1)
×
2319
                                        && next_section.section_type == SectionType::Asserts
×
2320
                                    {
×
2321
                                        skip_next_section = true;
×
2322
                                    }
×
2323
                                }
2324
                            }
2325
                            Some(Ok(msg_item)) => {
×
2326
                                if !effective_no_assert {
×
2327
                                    failure_reasons.push(format!(
×
2328
                                    "Expected ERROR at line {}, but received success message or trailers",
×
2329
                                    section.start_line
×
2330
                                ));
×
2331
                                } else {
×
2332
                                    // If we got a message instead of error in no_assert mode, print it
2333
                                    if let crate::grpc::client::StreamItem::Message(msg) = msg_item
×
2334
                                    {
×
2335
                                        println!("--- RESPONSE (Raw) ---");
×
2336
                                        println!("{}", runner_helpers::format_json_pretty(&msg));
×
2337
                                    }
×
2338
                                }
2339
                            }
2340
                            None => {
2341
                                if !effective_no_assert {
×
2342
                                    failure_reasons.push(format!(
×
2343
                                        "Expected ERROR at line {}, but stream ended successfully",
×
2344
                                        section.start_line
×
2345
                                    ));
×
2346
                                }
×
2347
                            }
2348
                        }
2349
                    }
2350
                    _ => {}
177✔
2351
                }
2352
            } // close repeat_iter
2353
        } // close for (i, section)
2354

2355
        drop(tx.take());
77✔
2356

2357
        if let Some(resp) = &mut captured_response {
77✔
2358
            if response_stream.is_none()
22✔
2359
                && let Some(handle) = call_handle.take()
1✔
2360
            {
2361
                match handle.await {
×
2362
                    Ok(Ok((h, stream))) => {
×
2363
                        resp.headers = h;
×
2364
                        response_stream = Some(stream);
×
2365
                    }
×
2366
                    Ok(Err(e)) => {
×
2367
                        failure_reasons.push(format!("Failed to start gRPC stream: {}", e));
×
2368
                        transport_failure = true;
×
2369
                        response_stream = None;
×
2370
                    }
×
2371
                    Err(e) => {
×
2372
                        failure_reasons
×
2373
                            .push(format!("Failed to join gRPC stream startup task: {}", e));
×
2374
                        transport_failure = true;
×
2375
                        response_stream = None;
×
2376
                    }
×
2377
                }
2378
            }
22✔
2379

2380
            loop {
2381
                let next_item = if let Some(stream) = response_stream.as_mut() {
43✔
2382
                    if effective_timeout_seconds > 0 {
42✔
2383
                        match tokio::time::timeout(
42✔
2384
                            std::time::Duration::from_secs(effective_timeout_seconds),
42✔
2385
                            stream.next(),
42✔
2386
                        )
2387
                        .await
42✔
2388
                        {
2389
                            Ok(item) => item,
42✔
2390
                            Err(_) => {
2391
                                failure_reasons.push(format!(
×
2392
                                    "Timed out after {}s draining response stream in write mode",
2393
                                    effective_timeout_seconds
2394
                                ));
2395
                                transport_failure = true;
×
2396
                                None
×
2397
                            }
2398
                        }
2399
                    } else {
2400
                        stream.next().await
×
2401
                    }
2402
                } else {
2403
                    None
1✔
2404
                };
2405

2406
                let Some(item_res) = next_item else {
43✔
2407
                    break;
22✔
2408
                };
2409

2410
                match item_res {
21✔
2411
                    Ok(crate::grpc::client::StreamItem::Message(msg)) => {
×
2412
                        resp.messages.push(msg);
×
2413
                    }
×
2414
                    Ok(crate::grpc::client::StreamItem::Trailers(t)) => {
21✔
2415
                        resp.trailers.extend(t);
21✔
2416
                    }
21✔
2417
                    Err(status) => {
×
2418
                        resp.error = Some(status.message().to_string());
×
2419
                    }
×
2420
                }
2421
            }
2422
        }
55✔
2423

2424
        // Tag this document's assertions with its endpoint so multi-document
2425
        // chains group per endpoint in verbose reports.
2426
        let endpoint_label = format!("{}/{}", full_service, method);
77✔
2427
        for rec in &mut assertion_records {
77✔
2428
            if rec.endpoint.is_none() {
62✔
2429
                rec.endpoint = Some(endpoint_label.clone());
62✔
2430
            }
62✔
2431
        }
2432

2433
        let grpc_duration = start_time.elapsed().as_millis() as u64;
77✔
2434

2435
        if !failure_reasons.is_empty() {
77✔
2436
            // In write mode, assertion mismatches are expected because we are
2437
            // updating snapshots — but transport failures (connection refused,
2438
            // stream startup errors, timeouts) must stay failures and must not
2439
            // rewrite the file with an empty/partial response.
2440
            if effective_write_mode
18✔
2441
                && !transport_failure
1✔
2442
                && let Some(resp) = captured_response
×
2443
            {
2444
                return Ok(TestExecutionResult::pass(Some(grpc_duration))
×
2445
                    .with_response(resp)
×
2446
                    .with_grpc_status(grpc_status.unwrap_or(0))
×
2447
                    .with_assertions(assertion_records)
×
2448
                    .with_retried(retry_occurred));
×
2449
            }
18✔
2450

2451
            let kind = if transport_failure {
18✔
2452
                FailureKind::Transport
1✔
2453
            } else {
2454
                FailureKind::Assertion
17✔
2455
            };
2456
            let mut result = TestExecutionResult::fail(
18✔
2457
                format!("Validation failed:\n  - {}", failure_reasons.join("\n  - ")),
18✔
2458
                Some(grpc_duration),
18✔
2459
            )
2460
            .with_failure_kind(kind)
18✔
2461
            .with_assertions(assertion_records)
18✔
2462
            .with_retried(retry_occurred);
18✔
2463
            // Only surface a gRPC status for transport failures (the real code
2464
            // the server/transport returned). A pure assertion failure reached
2465
            // no defined gRPC status, so it stays `None`.
2466
            if transport_failure && let Some(code) = grpc_status {
18✔
2467
                result = result.with_grpc_status(code);
×
2468
            }
18✔
2469
            if let Some(resp) = captured_response {
18✔
2470
                result = result.with_response(resp);
8✔
2471
            }
10✔
2472
            return Ok(result);
18✔
2473
        }
59✔
2474

2475
        let mut result = TestExecutionResult::pass(Some(grpc_duration))
59✔
2476
            .with_grpc_status(grpc_status.unwrap_or(0))
59✔
2477
            .with_assertions(assertion_records)
59✔
2478
            .with_retried(retry_occurred);
59✔
2479
        if !transport_failure && let Some(resp) = captured_response {
59✔
2480
            result = result.with_response(resp);
14✔
2481
        }
45✔
2482
        Ok(result)
59✔
2483
    }
92✔
2484

2485
    /// Validates a collected response against the document (for testing purposes)
2486
    pub fn validate_response(
12✔
2487
        &self,
12✔
2488
        document: &GctfDocument,
12✔
2489
        response: &crate::grpc::GrpcResponse,
12✔
2490
    ) -> TestExecutionResult {
12✔
2491
        self.response_handler.validate_document(document, response)
12✔
2492
    }
12✔
2493

2494
    fn substitute_variables(&self, value: &mut Value, variables: &HashMap<String, Value>) {
105✔
2495
        match value {
105✔
2496
            Value::String(s) => {
4✔
2497
                if !s.contains("{{") {
4✔
2498
                    return;
1✔
2499
                }
3✔
2500

2501
                // Check for exact match "{{ var }}" to preserve type
2502
                if s.starts_with("{{") && s.ends_with("}}") {
3✔
2503
                    let inner = s[2..s.len() - 2].trim();
1✔
2504
                    // check if inner has more {{ }} which implies complex string
2505
                    if !inner.contains("{{")
1✔
2506
                        && let Some(val) = variables.get(inner)
1✔
2507
                    {
2508
                        *value = val.clone();
1✔
2509
                        return;
1✔
2510
                    }
×
2511
                }
2✔
2512

2513
                // String interpolation "prefix {{ var }} suffix"
2514
                if let Some(result) = runner_helpers::interpolate_variables(s, variables) {
2✔
2515
                    *value = Value::String(result);
2✔
2516
                }
2✔
2517
            }
2518
            Value::Array(arr) => {
×
2519
                for v in arr {
×
2520
                    self.substitute_variables(v, variables);
×
2521
                }
×
2522
            }
2523
            Value::Object(obj) => {
101✔
2524
                for v in obj.values_mut() {
101✔
2525
                    self.substitute_variables(v, variables);
1✔
2526
                }
1✔
2527
            }
2528
            _ => {}
×
2529
        }
2530
    }
105✔
2531

2532
    #[expect(clippy::too_many_arguments)]
2533
    fn run_assertions(
51✔
2534
        &self,
51✔
2535
        lines: &[String],
51✔
2536
        target_value: &Value,
51✔
2537
        failure_reasons: &mut Vec<String>,
51✔
2538
        assertion_records: &mut Vec<apif_state::AssertionRecord>,
51✔
2539
        context: String,
51✔
2540
        start_line: usize,
51✔
2541
        assertion_context: AssertionContext<'_>,
51✔
2542
    ) {
51✔
2543
        let mut optimized_lines: Option<Vec<String>> = None;
51✔
2544

2545
        for (idx, line) in lines.iter().enumerate() {
65✔
2546
            if let Some(rewritten) =
×
2547
                optimizer::rewrite_assertion_expression_fixed_point_if_changed_with_level(
65✔
2548
                    line,
65✔
2549
                    optimizer::OptimizeLevel::Safe,
65✔
2550
                )
65✔
2551
            {
2552
                let vec = optimized_lines.get_or_insert_with(|| lines[..idx].to_vec());
×
2553
                vec.push(rewritten);
×
2554
            } else if let Some(vec) = optimized_lines.as_mut() {
65✔
2555
                vec.push(line.clone());
×
2556
            }
65✔
2557
        }
2558

2559
        let lines_to_evaluate: &[String] = optimized_lines.as_deref().unwrap_or(lines);
51✔
2560

2561
        let result = self.assertion_handler.evaluate_assertions_for_section(
51✔
2562
            lines_to_evaluate,
51✔
2563
            target_value,
51✔
2564
            assertion_context.headers,
51✔
2565
            assertion_context.trailers,
51✔
2566
            &context,
51✔
2567
            start_line,
51✔
2568
            assertion_context.timing,
51✔
2569
            assertion_context.variables,
51✔
2570
            assertion_context.protocol,
51✔
2571
        );
2572

2573
        if !result.passed {
51✔
2574
            failure_reasons.extend(result.failure_messages);
17✔
2575
        }
34✔
2576
        assertion_records.extend(result.records);
51✔
2577
    }
51✔
2578

2579
    /// Format JSON comparison diffs and append to failure_reasons.
2580
    fn append_response_diffs(
×
2581
        &self,
×
2582
        diffs: Vec<crate::assert::AssertionResult>,
×
2583
        section_line: usize,
×
2584
        expected: &Value,
×
2585
        actual: &Value,
×
2586
        failure_reasons: &mut Vec<String>,
×
2587
    ) {
×
2588
        failure_reasons.push(format!("Response mismatch at line {}:", section_line));
×
2589
        for diff in diffs {
×
2590
            match diff {
×
2591
                crate::assert::AssertionResult::Fail {
2592
                    message,
×
2593
                    expected: exp,
×
2594
                    actual: act,
×
2595
                } => {
2596
                    let mut msg = format!("  - {}", message);
×
2597
                    if let (Some(e), Some(a)) = (exp, act) {
×
2598
                        msg.push_str(&format!("\n      Expected: {}\n      Actual:   {}", e, a));
×
2599
                    }
×
2600
                    failure_reasons.push(msg);
×
2601
                }
2602
                crate::assert::AssertionResult::Error(m) => {
×
2603
                    failure_reasons.push(format!("  - Error: {}", m));
×
2604
                }
×
2605
                _ => {}
×
2606
            }
2607
        }
2608
        failure_reasons.push(get_json_diff(expected, actual));
×
2609
    }
×
2610

2611
    /// Log a response message for debug/verbose/raw modes.
2612
    fn log_response_message(
24✔
2613
        msg: &Value,
24✔
2614
        effective_no_assert: bool,
24✔
2615
        verbose: bool,
24✔
2616
        protocol: &str,
24✔
2617
        addr: &str,
24✔
2618
    ) {
24✔
2619
        let should_format =
24✔
2620
            tracing::enabled!(tracing::Level::DEBUG) || effective_no_assert || verbose;
24✔
2621
        if !should_format {
24✔
2622
            return;
24✔
2623
        }
×
2624
        let pretty = runner_helpers::format_json_pretty(msg);
×
2625
        if tracing::enabled!(tracing::Level::DEBUG) {
×
2626
            tracing::debug!("[{}@{}] Received Response:\n{}", protocol, addr, pretty);
×
2627
        }
×
2628
        if effective_no_assert {
×
2629
            println!("--- RESPONSE (Raw) ---\n{}", pretty);
×
2630
        }
×
2631
    }
24✔
2632

2633
    /// Print dry-run preview of test execution
2634
    fn print_dry_run_preview(
6✔
2635
        &self,
6✔
2636
        document: &GctfDocument,
6✔
2637
        address: &str,
6✔
2638
        package: &str,
6✔
2639
        service: &str,
6✔
2640
        method: &str,
6✔
2641
    ) {
6✔
2642
        println!();
6✔
2643
        println!("🔍 Dry-Run Preview: {}", document.file_path);
6✔
2644
        println!("═══════════════════════════════════════════════════════════════");
6✔
2645
        println!();
6✔
2646
        println!("📍 Target:");
6✔
2647
        println!("   Address: {}", address);
6✔
2648
        let full_service = runner_helpers::full_service_name(package, service);
6✔
2649
        println!("   Endpoint: {} / {}", full_service, method);
6✔
2650
        println!();
6✔
2651

2652
        let mut has_headers = false;
6✔
2653
        for section in &document.sections {
21✔
2654
            if section.section_type == SectionType::RequestHeaders {
21✔
2655
                if !has_headers {
×
2656
                    println!();
×
2657
                    println!("📋 Request Headers:");
×
2658
                    has_headers = true;
×
2659
                }
×
2660
                if let SectionContent::KeyValues(headers) = &section.content {
×
2661
                    for (key, value) in headers {
×
2662
                        println!("   {}: {}", key, value);
×
2663
                    }
×
2664
                }
×
2665
            }
21✔
2666
        }
2667

2668
        let mut has_request = false;
6✔
2669
        let mut has_asserts = false;
6✔
2670
        let mut has_error = false;
6✔
2671

2672
        for section in &document.sections {
21✔
2673
            match section.section_type {
21✔
2674
                SectionType::Address => {}
2✔
2675
                SectionType::Endpoint => {}
6✔
2676
                SectionType::RequestHeaders => {}
×
2677
                SectionType::Options => {}
×
2678
                SectionType::Tls => {}
×
2679
                SectionType::Proto => {}
×
2680
                SectionType::Request => {
2681
                    if !has_request {
6✔
2682
                        println!();
6✔
2683
                        println!("📤 Request/Response Flow:");
6✔
2684
                        has_request = true;
6✔
2685
                    }
6✔
2686
                    if let SectionContent::Json(json) = &section.content {
6✔
2687
                        let json_str = runner_helpers::format_json_pretty(json);
6✔
2688
                        println!("   ➤ REQUEST:");
6✔
2689
                        println!("     {}", json_str.replace('\n', "\n     "));
6✔
2690
                    }
6✔
2691
                }
2692
                SectionType::Response => {
2693
                    let with_asserts = if section.inline_options.with_asserts {
5✔
2694
                        " (with_asserts)"
×
2695
                    } else {
2696
                        ""
5✔
2697
                    };
2698
                    match &section.content {
5✔
2699
                        SectionContent::Json(json) => {
5✔
2700
                            let json_str = runner_helpers::format_json_pretty(json);
5✔
2701
                            println!(
5✔
2702
                                "   ↤ RESPONSE (Line {}):{}",
5✔
2703
                                section.start_line, with_asserts
5✔
2704
                            );
5✔
2705
                            println!("     {}", json_str.replace('\n', "\n     "));
5✔
2706
                        }
5✔
2707
                        SectionContent::JsonLines(values) => {
×
2708
                            println!(
×
2709
                                "   ↤ RESPONSE (Line {}, {} messages):{}",
2710
                                section.start_line,
2711
                                values.len(),
×
2712
                                with_asserts
2713
                            );
2714
                            for value in values {
×
2715
                                let json_str = runner_helpers::format_json_pretty(value);
×
2716
                                println!("     {}", json_str.replace('\n', "\n     "));
×
2717
                            }
×
2718
                        }
2719
                        _ => {}
×
2720
                    }
2721
                }
2722
                SectionType::Asserts => {
2723
                    if !has_asserts {
×
2724
                        println!();
×
2725
                        println!("✓ Assertions:");
×
2726
                        has_asserts = true;
×
2727
                    }
×
2728
                    if let SectionContent::Assertions(lines) = &section.content {
×
2729
                        for line in lines {
×
2730
                            println!("   . {}", line);
×
2731
                        }
×
2732
                    }
×
2733
                }
2734
                SectionType::Error => {
2735
                    if !has_error {
×
2736
                        println!();
×
2737
                        println!("❌ Expected Error:");
×
2738
                        has_error = true;
×
2739
                    }
×
2740
                    if let SectionContent::Json(json) = &section.content {
×
2741
                        let json_str = runner_helpers::format_json_pretty(json);
×
2742
                        println!("   {}", json_str);
×
2743
                    }
×
2744
                }
2745
                SectionType::Extract => {
2746
                    println!();
×
2747
                    println!("💾 Variables to Extract:");
×
2748
                    if let SectionContent::Extract(extractions) = &section.content {
×
2749
                        for (key, query) in extractions {
×
2750
                            println!("   {} -> {}", key, query);
×
2751
                        }
×
2752
                    }
×
2753
                }
2754
                SectionType::Bench | SectionType::Meta | SectionType::Dataset => {}
2✔
2755
            }
2756
        }
2757

2758
        let tls_defaults = runner_helpers::tls_env_defaults();
6✔
2759
        if let Some(tls_config) = document.get_tls_config_with_defaults(&tls_defaults) {
6✔
2760
            println!();
×
2761
            println!("🔒 TLS Configuration:");
×
2762
            if let Some(ca_cert) = tls_config
×
2763
                .get("ca_cert")
×
2764
                .or_else(|| tls_config.get("ca_file"))
×
2765
            {
×
2766
                println!("   CA Cert: {}", ca_cert);
×
2767
            }
×
2768
            if let Some(client_cert) = tls_config
×
2769
                .get("client_cert")
×
2770
                .or_else(|| tls_config.get("cert"))
×
2771
                .or_else(|| tls_config.get("cert_file"))
×
2772
            {
×
2773
                println!("   Client Cert: {}", client_cert);
×
2774
            }
×
2775
            if let Some(client_key) = tls_config
×
2776
                .get("client_key")
×
2777
                .or_else(|| tls_config.get("key"))
×
2778
                .or_else(|| tls_config.get("key_file"))
×
2779
            {
×
2780
                println!("   Client Key: {}", client_key);
×
2781
            }
×
2782
            if tls_config
×
2783
                .get("insecure")
×
2784
                .is_some_and(|s| runner_helpers::parse_bool_flag(s))
×
2785
            {
×
2786
                println!("   Insecure Skip Verify: true");
×
2787
            }
×
2788
        }
6✔
2789

2790
        if let Some(proto_config) = document.get_proto_config() {
6✔
2791
            println!();
×
2792
            println!("📄 Proto Configuration:");
×
2793
            if let Some(descriptor) = proto_config.get("descriptor") {
×
2794
                println!("   Descriptor: {}", descriptor);
×
2795
            }
×
2796
            if let Some(files) = proto_config.get("files") {
×
2797
                println!("   Proto Files: {}", files);
×
2798
            }
×
2799
        }
6✔
2800

2801
        println!();
6✔
2802
        println!("═══════════════════════════════════════════════════════════════");
6✔
2803
        println!();
6✔
2804
    }
6✔
2805
}
2806

2807
#[cfg(test)]
2808
mod tests {
2809
    use super::*;
2810
    use crate::polyfill::runtime;
2811
    use serde_json::json;
2812
    use std::sync::Mutex;
2813

2814
    static ENV_MUTEX: Mutex<()> = Mutex::new(());
2815

2816
    // Regression: `run_test_with_variables`/`run_test_capturing_vars` used to
2817
    // hardcode `captured_response: None` on the aggregate, discarding every
2818
    // per-document response — so `--write` snapshot mode never rewrote files
2819
    // with the real captured response for the happy path. `ChainAccumulator`
2820
    // carries the last document's response through.
2821
    // A single JsonLines REQUEST section sending 3 messages must infer the
2822
    // same ClientStreaming mode as 3 separate REQUEST sections would — the
2823
    // inference must count messages, not sections, or `rpc_mode` (which
2824
    // drives the actual wire streaming type for non-gRPC protocols) silently
2825
    // misclassifies a JsonLines request as unary.
2826
    #[test]
2827
    fn infer_rpc_mode_counts_jsonlines_request_messages_not_sections() {
1✔
2828
        let content = r#"--- ENDPOINT ---
1✔
2829
chat.ChatService/SendMessages
1✔
2830

1✔
2831
--- REQUEST ---
1✔
2832
{ "text": "one" }
1✔
2833
{ "text": "two" }
1✔
2834
{ "text": "three" }
1✔
2835

1✔
2836
--- RESPONSE ---
1✔
2837
{ "count": 3 }
1✔
2838
"#;
1✔
2839
        let doc = crate::parser::parse_gctf_from_str(content, "test.gctf").expect("valid document");
1✔
2840
        assert_eq!(
1✔
2841
            infer_rpc_mode_for_section_types(&doc),
1✔
2842
            RpcModeInfo::ClientStreaming
2843
        );
2844
    }
1✔
2845

2846
    #[test]
2847
    fn chain_accumulator_carries_last_captured_response_through() {
1✔
2848
        let mut acc = ChainAccumulator::default();
1✔
2849

2850
        let first =
1✔
2851
            TestExecutionResult::pass(Some(10)).with_response(crate::grpc::GrpcResponse::new());
1✔
2852
        assert!(!acc.absorb(first));
1✔
2853

2854
        let mut second_response = crate::grpc::GrpcResponse::new();
1✔
2855
        second_response.messages.push(json!({"ok": true}));
1✔
2856
        let second = TestExecutionResult::pass(Some(20)).with_response(second_response);
1✔
2857
        assert!(!acc.absorb(second));
1✔
2858

2859
        let result = acc.into_result();
1✔
2860
        assert_eq!(result.call_duration_ms, Some(30));
1✔
2861
        let resp = result
1✔
2862
            .captured_response
1✔
2863
            .expect("last document's response must survive chain aggregation");
1✔
2864
        assert_eq!(resp.messages, vec![json!({"ok": true})]);
1✔
2865
    }
1✔
2866

2867
    #[test]
2868
    fn chain_accumulator_stops_on_first_failure_and_keeps_its_response() {
1✔
2869
        let mut acc = ChainAccumulator::default();
1✔
2870

2871
        let mut failing_response = crate::grpc::GrpcResponse::new();
1✔
2872
        failing_response.error = Some("boom".to_string());
1✔
2873
        let failing = TestExecutionResult::fail("assertion mismatch".to_string(), Some(5))
1✔
2874
            .with_response(failing_response);
1✔
2875
        assert!(acc.absorb(failing), "a Fail result must stop the chain");
1✔
2876

2877
        let result = acc.into_result();
1✔
2878
        assert!(
1✔
2879
            matches!(result.status, TestExecutionStatus::Fail(ref m) if m == "assertion mismatch")
1✔
2880
        );
2881
        assert_eq!(
1✔
2882
            result.captured_response.and_then(|r| r.error),
1✔
2883
            Some("boom".to_string())
1✔
2884
        );
2885
    }
1✔
2886

2887
    #[test]
2888
    fn chain_accumulator_aggregates_assertions_across_documents() {
1✔
2889
        let mut acc = ChainAccumulator::default();
1✔
2890

2891
        let record = apif_state::AssertionRecord {
1✔
2892
            line: 1,
1✔
2893
            expression: ".id == 1".to_string(),
1✔
2894
            passed: true,
1✔
2895
            elapsed_ms: 0,
1✔
2896
            message: None,
1✔
2897
            endpoint: None,
1✔
2898
            expected: None,
1✔
2899
            actual: None,
1✔
2900
        };
1✔
2901
        acc.absorb(TestExecutionResult::pass(None).with_assertions(vec![record.clone()]));
1✔
2902
        acc.absorb(TestExecutionResult::pass(None).with_assertions(vec![record.clone()]));
1✔
2903

2904
        assert_eq!(acc.into_result().assertions.len(), 2);
1✔
2905
    }
1✔
2906

2907
    // Regression: `retried` must be sticky across a chain — one flaky document
2908
    // makes the whole chain flaky, even if later documents didn't need a retry.
2909
    #[test]
2910
    fn chain_accumulator_retried_is_sticky_across_documents() {
1✔
2911
        let mut acc = ChainAccumulator::default();
1✔
2912
        acc.absorb(TestExecutionResult::pass(None).with_retried(true));
1✔
2913
        acc.absorb(TestExecutionResult::pass(None).with_retried(false));
1✔
2914
        assert!(acc.into_result().retried, "retried must stay true");
1✔
2915
    }
1✔
2916

2917
    #[test]
2918
    fn chain_accumulator_not_retried_when_no_document_retried() {
1✔
2919
        let mut acc = ChainAccumulator::default();
1✔
2920
        acc.absorb(TestExecutionResult::pass(None).with_retried(false));
1✔
2921
        assert!(!acc.into_result().retried);
1✔
2922
    }
1✔
2923

2924
    // Regression: each document's own duration must be collected in order, not
2925
    // just summed — reporters need the individual values for real step timing.
2926
    #[test]
2927
    fn chain_accumulator_collects_per_document_durations_in_order() {
1✔
2928
        let mut acc = ChainAccumulator::default();
1✔
2929
        acc.absorb(TestExecutionResult::pass(Some(50)));
1✔
2930
        acc.absorb(TestExecutionResult::pass(Some(30)));
1✔
2931
        let result = acc.into_result();
1✔
2932
        assert_eq!(result.document_durations_ms, vec![50, 30]);
1✔
2933
        // total_duration_ms (call_duration_ms) still sums, unaffected.
2934
        assert_eq!(result.call_duration_ms, Some(80));
1✔
2935
    }
1✔
2936

2937
    #[test]
2938
    fn chain_accumulator_missing_duration_recorded_as_zero() {
1✔
2939
        let mut acc = ChainAccumulator::default();
1✔
2940
        acc.absorb(TestExecutionResult::pass(None));
1✔
2941
        assert_eq!(acc.into_result().document_durations_ms, vec![0]);
1✔
2942
    }
1✔
2943

2944
    #[test]
2945
    fn test_test_runner_new() {
1✔
2946
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
2947
        assert!(!runner.dry_run);
1✔
2948
        assert_eq!(runner.timeout_seconds, 30);
1✔
2949
        assert!(!runner.no_assert);
1✔
2950
        assert!(!runner.write_mode);
1✔
2951
        assert!(!runner.verbose);
1✔
2952
    }
1✔
2953

2954
    #[test]
2955
    fn test_test_runner_with_dry_run() {
1✔
2956
        let runner = TestRunner::new(true, 30, false, false, false, None);
1✔
2957
        assert!(runner.dry_run);
1✔
2958
    }
1✔
2959

2960
    #[test]
2961
    fn test_test_runner_with_timeout() {
1✔
2962
        let runner = TestRunner::new(false, 60, false, false, false, None);
1✔
2963
        assert_eq!(runner.timeout_seconds, 60);
1✔
2964
    }
1✔
2965

2966
    #[test]
2967
    fn test_test_runner_with_no_assert() {
1✔
2968
        let runner = TestRunner::new(false, 30, true, false, false, None);
1✔
2969
        assert!(runner.no_assert);
1✔
2970
    }
1✔
2971

2972
    #[test]
2973
    fn test_test_runner_with_write_mode() {
1✔
2974
        let runner = TestRunner::new(false, 30, false, true, false, None);
1✔
2975
        assert!(runner.write_mode);
1✔
2976
    }
1✔
2977

2978
    #[test]
2979
    fn test_parse_bool_flag_truthy_values() {
1✔
2980
        assert!(runner_helpers::parse_bool_flag("true"));
1✔
2981
        assert!(runner_helpers::parse_bool_flag("1"));
1✔
2982
        assert!(runner_helpers::parse_bool_flag("YES"));
1✔
2983
        assert!(runner_helpers::parse_bool_flag("on"));
1✔
2984
    }
1✔
2985

2986
    #[test]
2987
    fn test_parse_bool_flag_falsy_values() {
1✔
2988
        assert!(!runner_helpers::parse_bool_flag("false"));
1✔
2989
        assert!(!runner_helpers::parse_bool_flag("0"));
1✔
2990
        assert!(!runner_helpers::parse_bool_flag("off"));
1✔
2991
        assert!(!runner_helpers::parse_bool_flag(""));
1✔
2992
    }
1✔
2993

2994
    #[test]
2995
    fn test_parse_compression_option_from_options() {
1✔
2996
        let mut options = crate::parser::OrderedStringMap::new();
1✔
2997
        options.insert("compression".to_string(), "gzip".to_string());
1✔
2998

2999
        assert_eq!(
1✔
3000
            runner_helpers::parse_compression_option(&options),
1✔
3001
            Some(crate::grpc::CompressionMode::Gzip)
3002
        );
3003
    }
1✔
3004

3005
    #[test]
3006
    fn test_parse_compression_option_none_from_options() {
1✔
3007
        let mut options = crate::parser::OrderedStringMap::new();
1✔
3008
        options.insert("compression".to_string(), "none".to_string());
1✔
3009

3010
        assert_eq!(
1✔
3011
            runner_helpers::parse_compression_option(&options),
1✔
3012
            Some(crate::grpc::CompressionMode::None)
3013
        );
3014
    }
1✔
3015

3016
    #[test]
3017
    fn test_parse_compression_option_fallback_to_env() {
1✔
3018
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
1✔
3019
        unsafe {
1✔
3020
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_COMPRESSION, "gzip");
1✔
3021
        }
1✔
3022

3023
        let mut options = crate::parser::OrderedStringMap::new();
1✔
3024
        options.insert("compression".to_string(), "invalid".to_string());
1✔
3025
        assert_eq!(runner_helpers::parse_compression_option(&options), None);
1✔
3026

3027
        unsafe {
1✔
3028
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_COMPRESSION);
1✔
3029
        }
1✔
3030
    }
1✔
3031

3032
    #[test]
3033
    fn test_resolve_tls_path_from_env_uses_cwd() {
1✔
3034
        if !runtime::supports(runtime::Capability::IsolatedFsIo) {
1✔
3035
            return;
×
3036
        }
1✔
3037

3038
        let cwd = std::env::current_dir().unwrap();
1✔
3039
        let document_path = Path::new("tests/fixtures/sample.gctf");
1✔
3040
        let resolved = runner_helpers::resolve_tls_path("certs/ca.crt", true, document_path);
1✔
3041
        assert_eq!(Path::new(&resolved), cwd.join("certs/ca.crt"));
1✔
3042
    }
1✔
3043

3044
    #[test]
3045
    fn test_resolve_tls_path_from_env_without_fs_capability_returns_relative() {
1✔
3046
        if runtime::supports(runtime::Capability::IsolatedFsIo) {
1✔
3047
            return;
1✔
3048
        }
×
3049

3050
        let document_path = Path::new("tests/fixtures/sample.gctf");
×
3051
        let resolved = runner_helpers::resolve_tls_path("certs/ca.crt", true, document_path);
×
3052
        assert_eq!(resolved, "certs/ca.crt");
×
3053
    }
1✔
3054

3055
    #[test]
3056
    fn test_resolve_tls_path_from_document_uses_document_dir() {
1✔
3057
        let document_path = Path::new("tests/fixtures/sample.gctf");
1✔
3058
        let resolved = runner_helpers::resolve_tls_path("certs/ca.crt", false, document_path);
1✔
3059
        assert_eq!(
1✔
3060
            Path::new(&resolved),
1✔
3061
            Path::new("tests/fixtures").join("certs").join("ca.crt")
1✔
3062
        );
3063
    }
1✔
3064

3065
    #[test]
3066
    fn test_tls_env_defaults_uses_grpctestify_prefix() {
1✔
3067
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
1✔
3068

3069
        unsafe {
1✔
3070
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE, "/tmp/ca.pem");
1✔
3071
            std::env::set_var(
1✔
3072
                crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE,
1✔
3073
                "/tmp/cert.pem",
1✔
3074
            );
1✔
3075
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE, "/tmp/key.pem");
1✔
3076
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME, "localhost");
1✔
3077
        }
1✔
3078

3079
        let defaults = runner_helpers::tls_env_defaults();
1✔
3080
        assert_eq!(defaults.get("ca_cert"), Some(&"/tmp/ca.pem".to_string()));
1✔
3081
        assert_eq!(
1✔
3082
            defaults.get("client_cert"),
1✔
3083
            Some(&"/tmp/cert.pem".to_string())
1✔
3084
        );
3085
        assert_eq!(
1✔
3086
            defaults.get("client_key"),
1✔
3087
            Some(&"/tmp/key.pem".to_string())
1✔
3088
        );
3089
        assert_eq!(defaults.get("server_name"), Some(&"localhost".to_string()));
1✔
3090

3091
        unsafe {
1✔
3092
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE);
1✔
3093
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE);
1✔
3094
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE);
1✔
3095
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME);
1✔
3096
        }
1✔
3097
    }
1✔
3098

3099
    #[test]
3100
    fn test_tls_env_defaults_ignores_empty_values() {
1✔
3101
        let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
1✔
3102

3103
        unsafe {
1✔
3104
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE, "");
1✔
3105
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE, "   ");
1✔
3106
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE, "");
1✔
3107
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME, " ");
1✔
3108
        }
1✔
3109

3110
        let defaults = runner_helpers::tls_env_defaults();
1✔
3111
        assert!(defaults.is_empty());
1✔
3112

3113
        unsafe {
1✔
3114
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE);
1✔
3115
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE);
1✔
3116
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE);
1✔
3117
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME);
1✔
3118
        }
1✔
3119
    }
1✔
3120

3121
    #[test]
3122
    fn test_test_runner_with_verbose() {
1✔
3123
        let runner = TestRunner::new(false, 30, false, false, true, None);
1✔
3124
        assert!(runner.verbose);
1✔
3125
    }
1✔
3126

3127
    #[test]
3128
    fn test_grpc_code_name_from_numeric() {
1✔
3129
        assert_eq!(TestRunner::grpc_code_name_from_numeric(0), Some("OK"));
1✔
3130
        assert_eq!(TestRunner::grpc_code_name_from_numeric(5), Some("NotFound"));
1✔
3131
        assert_eq!(
1✔
3132
            TestRunner::grpc_code_name_from_numeric(13),
1✔
3133
            Some("Internal")
3134
        );
3135
        assert_eq!(TestRunner::grpc_code_name_from_numeric(99), None);
1✔
3136
    }
1✔
3137

3138
    #[test]
3139
    fn test_error_matches_expected_message() {
1✔
3140
        let expected = json!({
1✔
3141
            "message": "Can't find stub",
1✔
3142
            "code": 5
1✔
3143
        });
3144
        let error_text = "status: NotFound, message: \"Can't find stub\"";
1✔
3145
        assert!(TestRunner::error_matches_expected(error_text, &expected));
1✔
3146
    }
1✔
3147

3148
    #[test]
3149
    fn test_error_matches_expected_code() {
1✔
3150
        let expected = json!({
1✔
3151
            "code": 5
1✔
3152
        });
3153
        let error_text = "status: NotFound, message: \"error\"";
1✔
3154
        assert!(TestRunner::error_matches_expected(error_text, &expected));
1✔
3155
    }
1✔
3156

3157
    #[test]
3158
    fn test_error_matches_expected_wrong_code() {
1✔
3159
        let expected = json!({
1✔
3160
            "code": 3
1✔
3161
        });
3162
        let error_text = "status: NotFound, message: \"error\"";
1✔
3163
        assert!(!TestRunner::error_matches_expected(error_text, &expected));
1✔
3164
    }
1✔
3165

3166
    #[test]
3167
    fn test_error_matches_expected_wrong_message() {
1✔
3168
        let expected = json!({
1✔
3169
            "message": "Different error"
1✔
3170
        });
3171
        let error_text = "status: NotFound, message: \"Can't find stub\"";
1✔
3172
        assert!(!TestRunner::error_matches_expected(error_text, &expected));
1✔
3173
    }
1✔
3174

3175
    #[test]
3176
    fn test_error_matches_expected_string() {
1✔
3177
        let expected = json!("Can't find stub");
1✔
3178
        let error_text = "status: NotFound, message: \"Can't find stub\"";
1✔
3179
        assert!(TestRunner::error_matches_expected(error_text, &expected));
1✔
3180
    }
1✔
3181

3182
    #[test]
3183
    fn test_full_service_name() {
1✔
3184
        assert_eq!(
1✔
3185
            runner_helpers::full_service_name("package", "Service"),
1✔
3186
            "package.Service"
3187
        );
3188
        assert_eq!(runner_helpers::full_service_name("", "Service"), "Service");
1✔
3189
    }
1✔
3190

3191
    #[test]
3192
    fn test_substitute_variables_exact_match_preserves_type() {
1✔
3193
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
3194
        let mut value = json!("{{ count }}");
1✔
3195
        let mut vars = HashMap::new();
1✔
3196
        vars.insert("count".to_string(), json!(42));
1✔
3197

3198
        runner.substitute_variables(&mut value, &vars);
1✔
3199
        assert_eq!(value, json!(42));
1✔
3200
    }
1✔
3201

3202
    #[test]
3203
    fn test_substitute_variables_interpolation_single_pass() {
1✔
3204
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
3205
        let mut value = json!("id={{id}}, user={{ user }}, ok={{ok}}");
1✔
3206
        let mut vars = HashMap::new();
1✔
3207
        vars.insert("id".to_string(), json!(7));
1✔
3208
        vars.insert("user".to_string(), json!("alice"));
1✔
3209
        vars.insert("ok".to_string(), json!(true));
1✔
3210

3211
        runner.substitute_variables(&mut value, &vars);
1✔
3212
        assert_eq!(value, json!("id=7, user=alice, ok=true"));
1✔
3213
    }
1✔
3214

3215
    #[test]
3216
    fn test_substitute_variables_keeps_unknown_placeholder() {
1✔
3217
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
3218
        let mut value = json!("hello {{known}} and {{unknown}}");
1✔
3219
        let mut vars = HashMap::new();
1✔
3220
        vars.insert("known".to_string(), json!("world"));
1✔
3221

3222
        runner.substitute_variables(&mut value, &vars);
1✔
3223
        assert_eq!(value, json!("hello world and {{unknown}}"));
1✔
3224
    }
1✔
3225

3226
    #[test]
3227
    fn test_expected_values_for_response_section() {
1✔
3228
        use crate::parser::ast::{InlineOptions, Section, SectionContent, SectionSpan};
3229

3230
        let section = Section {
1✔
3231
            section_type: crate::parser::ast::SectionType::Response,
1✔
3232
            content: SectionContent::Json(json!({"key": "value"})),
1✔
3233
            inline_options: InlineOptions::default(),
1✔
3234
            raw_content: "".to_string(),
1✔
3235
            start_line: 1,
1✔
3236
            end_line: 2,
1✔
3237
            attributes: Vec::new(),
1✔
3238
            span: SectionSpan::default(),
1✔
3239
        };
1✔
3240

3241
        let values = TestRunner::expected_values_for_response_section(&section);
1✔
3242
        assert_eq!(values.len(), 1);
1✔
3243
        assert_eq!(values[0], json!({"key": "value"}));
1✔
3244
    }
1✔
3245

3246
    #[test]
3247
    fn test_expected_values_for_json_lines() {
1✔
3248
        use crate::parser::ast::{InlineOptions, Section, SectionContent, SectionSpan};
3249

3250
        let section = Section {
1✔
3251
            section_type: crate::parser::ast::SectionType::Response,
1✔
3252
            content: SectionContent::JsonLines(vec![
1✔
3253
                json!({"key1": "value1"}),
1✔
3254
                json!({"key2": "value2"}),
1✔
3255
            ]),
1✔
3256
            inline_options: InlineOptions::default(),
1✔
3257
            raw_content: "".to_string(),
1✔
3258
            start_line: 1,
1✔
3259
            end_line: 3,
1✔
3260
            attributes: Vec::new(),
1✔
3261
            span: SectionSpan::default(),
1✔
3262
        };
1✔
3263

3264
        let values = TestRunner::expected_values_for_response_section(&section);
1✔
3265
        assert_eq!(values.len(), 2);
1✔
3266
    }
1✔
3267

3268
    #[test]
3269
    fn test_expected_values_for_other_section() {
1✔
3270
        use crate::parser::ast::{
3271
            InlineOptions, Section, SectionContent, SectionSpan, SectionType,
3272
        };
3273

3274
        // The function returns values for any Json content, not just Response sections
3275
        // This is expected behavior - it extracts Json values regardless of section type
3276
        let section = Section {
1✔
3277
            section_type: SectionType::Request,
1✔
3278
            content: SectionContent::Json(json!({"key": "value"})),
1✔
3279
            inline_options: InlineOptions::default(),
1✔
3280
            raw_content: "".to_string(),
1✔
3281
            start_line: 1,
1✔
3282
            end_line: 2,
1✔
3283
            attributes: Vec::new(),
1✔
3284
            span: SectionSpan::default(),
1✔
3285
        };
1✔
3286

3287
        let values = TestRunner::expected_values_for_response_section(&section);
1✔
3288
        // Returns 1 because the content is Json, even though it's a Request section
3289
        assert_eq!(values.len(), 1);
1✔
3290
        assert_eq!(values[0], json!({"key": "value"}));
1✔
3291
    }
1✔
3292

3293
    #[test]
3294
    fn test_metadata_map_to_hashmap_extracts_ascii_values() {
1✔
3295
        let mut metadata = std::collections::HashMap::new();
1✔
3296
        metadata.insert(
1✔
3297
            "code".to_string(),
1✔
3298
            "EXTERNAL_SERVICE_ERROR_CODE".to_string(),
1✔
3299
        );
3300
        metadata.insert(
1✔
3301
            "message".to_string(),
1✔
3302
            "External service error message".to_string(),
1✔
3303
        );
3304

3305
        let trailers = metadata;
1✔
3306
        assert_eq!(
1✔
3307
            trailers.get("code"),
1✔
3308
            Some(&"EXTERNAL_SERVICE_ERROR_CODE".to_string())
1✔
3309
        );
3310
        assert_eq!(
1✔
3311
            trailers.get("message"),
1✔
3312
            Some(&"External service error message".to_string())
1✔
3313
        );
3314
    }
1✔
3315

3316
    #[test]
3317
    fn test_assertion_scope_timing_single_message_scope() {
1✔
3318
        let mut timing = AssertionScopeTimingState::default();
1✔
3319

3320
        let first = timing.finish_scope(0, 12, 1).unwrap();
1✔
3321

3322
        assert_eq!(first.elapsed_ms, 12);
1✔
3323
        assert_eq!(first.total_elapsed_ms, 12);
1✔
3324
        assert_eq!(first.scope_message_count, 1);
1✔
3325
        assert_eq!(first.scope_index, 1);
1✔
3326
    }
1✔
3327

3328
    #[test]
3329
    fn test_assertion_scope_timing_batch_scope_uses_full_section_window() {
1✔
3330
        let mut timing = AssertionScopeTimingState::default();
1✔
3331

3332
        let batch = timing.finish_scope(0, 27, 2).unwrap();
1✔
3333

3334
        assert_eq!(batch.elapsed_ms, 27);
1✔
3335
        assert_eq!(batch.total_elapsed_ms, 27);
1✔
3336
        assert_eq!(batch.scope_message_count, 2);
1✔
3337
        assert_eq!(batch.scope_index, 1);
1✔
3338
    }
1✔
3339

3340
    #[test]
3341
    fn test_assertion_scope_timing_accumulates_total_duration() {
1✔
3342
        let mut timing = AssertionScopeTimingState::default();
1✔
3343

3344
        let first = timing.finish_scope(0, 10, 1).unwrap();
1✔
3345
        let second = timing.finish_scope(10, 35, 3).unwrap();
1✔
3346

3347
        assert_eq!(first.elapsed_ms, 10);
1✔
3348
        assert_eq!(first.total_elapsed_ms, 10);
1✔
3349
        assert_eq!(second.elapsed_ms, 25);
1✔
3350
        assert_eq!(second.total_elapsed_ms, 35);
1✔
3351
        assert_eq!(second.scope_message_count, 3);
1✔
3352
        assert_eq!(second.scope_index, 2);
1✔
3353
    }
1✔
3354

3355
    #[test]
3356
    fn test_has_required_followup_asserts_for_error_requires_adjacent_asserts() {
1✔
3357
        use crate::parser::ast::{
3358
            InlineOptions, Section, SectionContent, SectionSpan, SectionType,
3359
        };
3360

3361
        let error = Section {
1✔
3362
            section_type: SectionType::Error,
1✔
3363
            content: SectionContent::Empty,
1✔
3364
            inline_options: InlineOptions {
1✔
3365
                with_asserts: true,
1✔
3366
                ..InlineOptions::default()
1✔
3367
            },
1✔
3368
            raw_content: "".to_string(),
1✔
3369
            start_line: 12,
1✔
3370
            end_line: 12,
1✔
3371
            attributes: Vec::new(),
1✔
3372
            span: SectionSpan::default(),
1✔
3373
        };
1✔
3374
        let sections = vec![error.clone()];
1✔
3375
        let mut failures = Vec::new();
1✔
3376

3377
        let has_followup =
1✔
3378
            TestRunner::has_required_followup_asserts(&error, &sections, 0, false, &mut failures);
1✔
3379

3380
        assert!(!has_followup);
1✔
3381
        assert_eq!(failures.len(), 1);
1✔
3382
        assert!(failures[0].contains("ERROR at line 12 has 'with_asserts'"));
1✔
3383
    }
1✔
3384

3385
    #[test]
3386
    fn test_has_required_followup_asserts_for_error_accepts_adjacent_asserts() {
1✔
3387
        use crate::parser::ast::{
3388
            InlineOptions, Section, SectionContent, SectionSpan, SectionType,
3389
        };
3390

3391
        let error = Section {
1✔
3392
            section_type: SectionType::Error,
1✔
3393
            content: SectionContent::Empty,
1✔
3394
            inline_options: InlineOptions {
1✔
3395
                with_asserts: true,
1✔
3396
                ..InlineOptions::default()
1✔
3397
            },
1✔
3398
            raw_content: "".to_string(),
1✔
3399
            start_line: 20,
1✔
3400
            end_line: 20,
1✔
3401
            attributes: Vec::new(),
1✔
3402
            span: SectionSpan::default(),
1✔
3403
        };
1✔
3404
        let asserts = Section {
1✔
3405
            section_type: SectionType::Asserts,
1✔
3406
            content: SectionContent::Assertions(vec![".code == 5".to_string()]),
1✔
3407
            inline_options: InlineOptions::default(),
1✔
3408
            raw_content: ".code == 5".to_string(),
1✔
3409
            start_line: 21,
1✔
3410
            end_line: 21,
1✔
3411
            attributes: Vec::new(),
1✔
3412
            span: SectionSpan::default(),
1✔
3413
        };
1✔
3414
        let sections = vec![error.clone(), asserts];
1✔
3415
        let mut failures = Vec::new();
1✔
3416

3417
        let has_followup =
1✔
3418
            TestRunner::has_required_followup_asserts(&error, &sections, 0, false, &mut failures);
1✔
3419

3420
        assert!(has_followup);
1✔
3421
        assert!(failures.is_empty());
1✔
3422
    }
1✔
3423

3424
    #[test]
3425
    fn test_error_assertions_evaluate_against_error_json_object() {
1✔
3426
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
3427
        let target = json!({
1✔
3428
            "code": 5,
1✔
3429
            "message": "resource not found in backend",
1✔
3430
            "details": [
1✔
3431
                {
3432
                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1✔
3433
                    "reason": "NOT_FOUND"
1✔
3434
                }
3435
            ]
3436
        });
3437
        let lines = vec![
1✔
3438
            ".code == 5".to_string(),
1✔
3439
            ".message contains \"not found\"".to_string(),
1✔
3440
            ".details[0][\"@type\"] == \"type.googleapis.com/google.rpc.ErrorInfo\"".to_string(),
1✔
3441
        ];
3442
        let mut failures = Vec::new();
1✔
3443
        let mut assertion_records = Vec::new();
1✔
3444
        let headers: HashMap<String, String> = HashMap::new();
1✔
3445
        let trailers: HashMap<String, String> = HashMap::new();
1✔
3446

3447
        runner.run_assertions(
1✔
3448
            &lines,
1✔
3449
            &target,
1✔
3450
            &mut failures,
1✔
3451
            &mut assertion_records,
1✔
3452
            "(attached to ERROR at line 1)".to_string(),
1✔
3453
            1,
3454
            AssertionContext {
1✔
3455
                headers: &headers,
1✔
3456
                trailers: &trailers,
1✔
3457
                timing: None,
1✔
3458
                variables: &HashMap::new(),
1✔
3459
                protocol: "grpc",
1✔
3460
            },
1✔
3461
        );
3462

3463
        assert!(failures.is_empty(), "unexpected failures: {failures:?}");
1✔
3464
        assert_eq!(assertion_records.len(), 3);
1✔
3465
        assert!(assertion_records.iter().all(|r| r.passed));
1✔
3466
    }
1✔
3467

3468
    #[tokio::test]
3469
    async fn run_test_capturing_vars_returns_result_and_map() {
1✔
3470
        // Dry-run short-circuits before any network call, so this exercises the
3471
        // additive method's plumbing (result + variable map) without a server.
3472
        let content = "--- ENDPOINT ---\nsvc.Svc/Call\n\n--- REQUEST ---\n{}\n";
1✔
3473
        let doc = crate::parser::parse_gctf_from_str(content, "capture.gctf").unwrap();
1✔
3474
        let runner = TestRunner::new(true, 30, false, false, false, None);
1✔
3475

3476
        let (result, vars) = runner.run_test_capturing_vars(&doc).await.unwrap();
1✔
3477
        assert!(matches!(result.status, TestExecutionStatus::Pass));
1✔
3478
        // No EXTRACT was executed (dry-run), so the captured map is empty, but a
3479
        // map is returned alongside the result as the fixture seeding relies on.
3480
        assert!(vars.is_empty());
1✔
3481
    }
1✔
3482
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc