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

gripmock / grpctestify-rust / 24368153097

13 Apr 2026 09:36PM UTC coverage: 75.096% (-0.3%) from 75.445%
24368153097

Pull #35

github

web-flow
Merge 97a02fd78 into 4ba0f08f1
Pull Request #35: feat: meta section & refactoring

2518 of 3592 new or added lines in 47 files covered. (70.1%)

155 existing lines in 9 files now uncovered.

16781 of 22346 relevant lines covered (75.1%)

2495.37 hits per line

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

42.28
/src/execution/runner.rs
1
// Test runner
2
// Executes tests defined in GctfDocument
3
// Refactored to use RequestHandler, ResponseHandler, AssertionHandler
4

5
use super::super::parser::GctfDocument;
6
use super::runner_helpers;
7
use super::{AssertionHandler, RequestHandler, ResponseHandler};
8
use crate::assert::{AssertionEngine, JsonComparator, get_json_diff};
9
use crate::grpc::{CompressionMode, GrpcClient, GrpcClientConfig, ProtoConfig, TlsConfig};
10
use crate::optimizer;
11
use crate::parser::ast::{SectionContent, SectionType};
12
use crate::plugins::AssertionTiming;
13
use crate::polyfill::runtime;
14
use crate::report::CoverageCollector;
15
use crate::utils::file::FileUtils;
16
use anyhow::Result;
17
use serde::{Deserialize, Serialize};
18
use serde_json::Value;
19
use std::collections::HashMap;
20
use std::path::Path;
21
use std::sync::Arc;
22
use tokio::sync::mpsc;
23
use tokio_stream::StreamExt;
24
use tokio_stream::wrappers::ReceiverStream;
25

26
/// Buffer size for the request message channel.
27
/// Controls back-pressure for client streaming: larger values allow more
28
/// buffered requests but consume more memory.
29
const REQUEST_CHANNEL_BUFFER: usize = 100;
30

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

46
#[derive(Debug, Clone, Serialize, Deserialize)]
47
pub struct ConnectionInfo {
48
    pub address: String,
49
    pub source: String,
50
}
51

52
#[derive(Debug, Clone, Serialize, Deserialize)]
53
pub struct TargetInfo {
54
    pub endpoint: String,
55
    pub package: Option<String>,
56
    pub service: Option<String>,
57
    pub method: Option<String>,
58
}
59

60
#[derive(Debug, Clone, Serialize, Deserialize)]
61
pub struct HeadersInfo {
62
    pub count: usize,
63
    pub headers: HashMap<String, String>,
64
}
65

66
#[derive(Debug, Clone, Serialize, Deserialize)]
67
pub struct RequestInfo {
68
    pub index: usize,
69
    pub content: Value,
70
    pub content_type: String,
71
    pub line_start: usize,
72
    pub line_end: usize,
73
}
74

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

86
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
87
pub struct ComparisonOptions {
88
    pub partial: bool,
89
    pub redact: Vec<String>,
90
    pub tolerance: Option<f64>,
91
    pub unordered_arrays: bool,
92
    pub with_asserts: bool,
93
}
94

95
#[derive(Debug, Clone, Serialize, Deserialize)]
96
pub struct AssertionInfo {
97
    pub index: usize,
98
    pub assertions: Vec<String>,
99
    pub line_start: usize,
100
    pub line_end: usize,
101
}
102

103
#[derive(Debug, Clone, Serialize, Deserialize)]
104
pub struct ExtractionInfo {
105
    pub index: usize,
106
    pub variables: HashMap<String, String>,
107
    pub line_start: usize,
108
    pub line_end: usize,
109
}
110

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

129
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130
pub struct ExecutionSummary {
131
    pub total_requests: usize,
132
    pub total_responses: usize,
133
    pub total_errors: usize,
134
    pub error_expected: bool,
135
    pub assertion_blocks: usize,
136
    pub variable_extractions: usize,
137
    pub rpc_mode_name: String,
138
}
139

140
struct AssertionContext<'a> {
141
    headers: &'a HashMap<String, String>,
142
    trailers: &'a HashMap<String, String>,
143
    timing: Option<&'a AssertionTiming>,
144
}
145

146
impl ExecutionPlan {
147
    /// Build execution plan from a GctfDocument
148
    pub fn from_document(doc: &GctfDocument) -> Self {
72✔
149
        let file_path = doc.file_path.clone();
72✔
150

151
        // Connection info
152
        let connection = if let Some(section) = doc.first_section(SectionType::Address) {
72✔
153
            if let SectionContent::Single(addr) = &section.content {
4✔
154
                ConnectionInfo {
4✔
155
                    address: addr.clone(),
4✔
156
                    source: format!(
4✔
157
                        "ADDRESS section [Line {}-{}]",
4✔
158
                        section.start_line, section.end_line
4✔
159
                    ),
4✔
160
                }
4✔
161
            } else {
162
                ConnectionInfo {
×
163
                    address: "<env:GRPCTESTIFY_ADDRESS>".to_string(),
×
164
                    source: "Environment variable (implicit)".to_string(),
×
165
                }
×
166
            }
167
        } else {
168
            ConnectionInfo {
68✔
169
                address: "<env:GRPCTESTIFY_ADDRESS>".to_string(),
68✔
170
                source: "Environment variable (implicit)".to_string(),
68✔
171
            }
68✔
172
        };
173

174
        // Target info
175
        let target = if let Some(section) = doc.first_section(SectionType::Endpoint) {
72✔
176
            if let SectionContent::Single(endpoint) = &section.content {
72✔
177
                let (package, service, method) = doc
72✔
178
                    .parse_endpoint()
72✔
179
                    .map(|(p, s, m)| (Some(p), Some(s), Some(m)))
72✔
180
                    .unwrap_or((None, None, None));
72✔
181
                TargetInfo {
72✔
182
                    endpoint: endpoint.clone(),
72✔
183
                    package,
72✔
184
                    service,
72✔
185
                    method,
72✔
186
                }
72✔
187
            } else {
188
                TargetInfo {
×
189
                    endpoint: "<missing>".to_string(),
×
190
                    package: None,
×
191
                    service: None,
×
192
                    method: None,
×
193
                }
×
194
            }
195
        } else {
196
            TargetInfo {
×
197
                endpoint: "<missing>".to_string(),
×
198
                package: None,
×
199
                service: None,
×
200
                method: None,
×
201
            }
×
202
        };
203

204
        // Headers info
205
        let headers = doc
72✔
206
            .first_section(SectionType::RequestHeaders)
72✔
207
            .and_then(|section| {
72✔
208
                if let SectionContent::KeyValues(headers) = &section.content {
4✔
209
                    Some(HeadersInfo {
4✔
210
                        count: headers.len(),
4✔
211
                        headers: headers.clone(),
4✔
212
                    })
4✔
213
                } else {
214
                    None
×
215
                }
216
            });
4✔
217

218
        // Requests
219
        let request_sections = doc.sections_by_type(SectionType::Request);
72✔
220
        let requests: Vec<RequestInfo> = request_sections
72✔
221
            .iter()
72✔
222
            .enumerate()
72✔
223
            .map(|(i, section)| {
81✔
224
                let (content, content_type) = match &section.content {
81✔
225
                    SectionContent::Json(j) => (j.clone(), "json"),
81✔
226
                    SectionContent::JsonLines(_) => (Value::Array(vec![]), "json-lines"),
×
227
                    SectionContent::Empty => (Value::Object(serde_json::Map::new()), "empty"),
×
228
                    _ => (Value::Null, "unknown"),
×
229
                };
230
                RequestInfo {
81✔
231
                    index: i + 1,
81✔
232
                    content,
81✔
233
                    content_type: content_type.to_string(),
81✔
234
                    line_start: section.start_line,
81✔
235
                    line_end: section.end_line,
81✔
236
                }
81✔
237
            })
81✔
238
            .collect();
72✔
239

240
        // Expectations (responses or error)
241
        let response_sections = doc.sections_by_type(SectionType::Response);
72✔
242
        let error_section = doc.first_section(SectionType::Error);
72✔
243

244
        let expectations: Vec<ExpectationInfo> = if !response_sections.is_empty() {
72✔
245
            response_sections
67✔
246
                .iter()
67✔
247
                .enumerate()
67✔
248
                .map(|(i, section)| {
77✔
249
                    let (content, message_count) = match &section.content {
77✔
250
                        SectionContent::Json(j) => (Some(j.clone()), None),
77✔
251
                        SectionContent::JsonLines(vals) => (None, Some(vals.len())),
×
252
                        _ => (None, None),
×
253
                    };
254
                    ExpectationInfo {
77✔
255
                        index: i + 1,
77✔
256
                        expectation_type: "response".to_string(),
77✔
257
                        content,
77✔
258
                        message_count,
77✔
259
                        comparison_options: ComparisonOptions {
77✔
260
                            partial: section.inline_options.partial,
77✔
261
                            redact: section.inline_options.redact.clone(),
77✔
262
                            tolerance: section.inline_options.tolerance,
77✔
263
                            unordered_arrays: section.inline_options.unordered_arrays,
77✔
264
                            with_asserts: section.inline_options.with_asserts,
77✔
265
                        },
77✔
266
                        line_start: section.start_line,
77✔
267
                        line_end: section.end_line,
77✔
268
                    }
77✔
269
                })
77✔
270
                .collect()
67✔
271
        } else if let Some(section) = error_section {
5✔
272
            let content = match &section.content {
4✔
273
                SectionContent::Json(j) => Some(j.clone()),
4✔
274
                _ => None,
×
275
            };
276
            vec![ExpectationInfo {
4✔
277
                index: 1,
4✔
278
                expectation_type: "error".to_string(),
4✔
279
                content,
4✔
280
                message_count: None,
4✔
281
                comparison_options: ComparisonOptions::default(),
4✔
282
                line_start: section.start_line,
4✔
283
                line_end: section.end_line,
4✔
284
            }]
4✔
285
        } else {
286
            vec![]
1✔
287
        };
288

289
        // Assertions
290
        let assert_sections = doc.sections_by_type(SectionType::Asserts);
72✔
291
        let assertions: Vec<AssertionInfo> = assert_sections
72✔
292
            .iter()
72✔
293
            .enumerate()
72✔
294
            .map(|(i, section)| {
72✔
295
                let assertions = if let SectionContent::Assertions(lines) = &section.content {
29✔
296
                    lines
29✔
297
                        .iter()
29✔
298
                        .map(|line| {
50✔
299
                            optimizer::rewrite_assertion_expression_fixed_point_if_changed(line)
50✔
300
                                .unwrap_or_else(|| line.clone())
50✔
301
                        })
50✔
302
                        .collect()
29✔
303
                } else {
304
                    vec![]
×
305
                };
306
                AssertionInfo {
29✔
307
                    index: i + 1,
29✔
308
                    assertions,
29✔
309
                    line_start: section.start_line,
29✔
310
                    line_end: section.end_line,
29✔
311
                }
29✔
312
            })
29✔
313
            .collect();
72✔
314

315
        // Extractions
316
        let extract_sections = doc.sections_by_type(SectionType::Extract);
72✔
317
        let extractions: Vec<ExtractionInfo> = extract_sections
72✔
318
            .iter()
72✔
319
            .enumerate()
72✔
320
            .map(|(i, section)| {
72✔
321
                let variables = if let SectionContent::Extract(vars) = &section.content {
21✔
322
                    vars.clone()
21✔
323
                } else {
324
                    HashMap::new()
×
325
                };
326
                ExtractionInfo {
21✔
327
                    index: i + 1,
21✔
328
                    variables,
21✔
329
                    line_start: section.start_line,
21✔
330
                    line_end: section.end_line,
21✔
331
                }
21✔
332
            })
21✔
333
            .collect();
72✔
334

335
        // Infer RPC mode
336
        let has_json_lines = response_sections
72✔
337
            .iter()
72✔
338
            .any(|s| matches!(&s.content, SectionContent::JsonLines(vals) if vals.len() > 1));
72✔
339
        let rpc_mode = infer_rpc_mode(
72✔
340
            &requests,
72✔
341
            &expectations,
72✔
342
            error_section.is_some(),
72✔
343
            has_json_lines,
72✔
344
        );
345

346
        // Summary
347
        let rpc_mode_name = match &rpc_mode {
72✔
348
            RpcMode::Unary => "Unary",
57✔
349
            RpcMode::UnaryError => "Unary Error",
4✔
350
            RpcMode::ServerStreaming { .. } => "Server Streaming",
5✔
351
            RpcMode::ClientStreaming { .. } => "Client Streaming",
5✔
352
            RpcMode::BidirectionalStreaming { .. } => "Bidirectional Streaming",
×
353
            RpcMode::Unknown => "Unknown",
1✔
354
        };
355

356
        let summary = ExecutionSummary {
72✔
357
            total_requests: requests.len(),
72✔
358
            total_responses: expectations
72✔
359
                .iter()
72✔
360
                .filter(|e| e.expectation_type == "response")
81✔
361
                .count(),
72✔
362
            total_errors: expectations
72✔
363
                .iter()
72✔
364
                .filter(|e| e.expectation_type == "error")
81✔
365
                .count(),
72✔
366
            error_expected: expectations.iter().any(|e| e.expectation_type == "error"),
81✔
367
            assertion_blocks: assertions.len(),
72✔
368
            variable_extractions: extractions.len(),
72✔
369
            rpc_mode_name: rpc_mode_name.to_string(),
72✔
370
        };
371

372
        ExecutionPlan {
72✔
373
            file_path,
72✔
374
            connection,
72✔
375
            target,
72✔
376
            headers,
72✔
377
            requests,
72✔
378
            expectations,
72✔
379
            assertions,
72✔
380
            extractions,
72✔
381
            rpc_mode,
72✔
382
            summary,
72✔
383
        }
72✔
384
    }
72✔
385
}
386

387
fn infer_rpc_mode(
72✔
388
    requests: &[RequestInfo],
72✔
389
    expectations: &[ExpectationInfo],
72✔
390
    has_error: bool,
72✔
391
    has_json_lines: bool,
72✔
392
) -> RpcMode {
72✔
393
    let req_count = requests.len();
72✔
394
    let resp_count = expectations
72✔
395
        .iter()
72✔
396
        .filter(|e| e.expectation_type == "response")
81✔
397
        .count();
72✔
398

399
    if has_error {
72✔
400
        RpcMode::UnaryError
4✔
401
    } else if has_json_lines || resp_count > 1 {
68✔
402
        if req_count > 1 {
5✔
403
            RpcMode::BidirectionalStreaming {
×
404
                request_count: req_count,
×
405
                response_count: resp_count,
×
406
            }
×
407
        } else {
408
            RpcMode::ServerStreaming {
5✔
409
                response_count: resp_count,
5✔
410
            }
5✔
411
        }
412
    } else if req_count > 1 {
63✔
413
        RpcMode::ClientStreaming {
5✔
414
            request_count: req_count,
5✔
415
        }
5✔
416
    } else if req_count == 1 && resp_count == 1 {
58✔
417
        RpcMode::Unary
57✔
418
    } else if req_count == 0 && resp_count > 0 {
1✔
419
        RpcMode::ServerStreaming {
×
420
            response_count: resp_count,
×
421
        }
×
422
    } else {
423
        RpcMode::Unknown
1✔
424
    }
425
}
72✔
426

427
/// Test execution status
428
#[derive(Debug, Clone, PartialEq, Eq)]
429
pub enum TestExecutionStatus {
430
    Pass,
431
    Fail(String),
432
}
433

434
/// Test execution result
435
#[derive(Debug, Clone, PartialEq, Eq)]
436
pub struct TestExecutionResult {
437
    pub status: TestExecutionStatus,
438
    pub grpc_duration_ms: Option<u64>,
439
    // Optional: captured response for updating the test file
440
    pub captured_response: Option<crate::grpc::GrpcResponse>,
441
}
442

443
#[derive(Debug, Default, Clone)]
444
struct AssertionScopeTimingState {
445
    last_message_elapsed_ms: Option<u64>,
446
    total_scope_elapsed_ms: u64,
447
    scope_index: usize,
448
}
449

450
impl AssertionScopeTimingState {
451
    fn finish_scope(
4✔
452
        &mut self,
4✔
453
        scope_start_ms: u64,
4✔
454
        scope_end_ms: u64,
4✔
455
        scope_message_count: usize,
4✔
456
    ) -> Option<AssertionTiming> {
4✔
457
        if scope_message_count == 0 {
4✔
458
            return None;
×
459
        }
4✔
460

461
        let elapsed_ms = scope_end_ms.saturating_sub(scope_start_ms);
4✔
462
        self.scope_index += 1;
4✔
463
        self.total_scope_elapsed_ms = self.total_scope_elapsed_ms.saturating_add(elapsed_ms);
4✔
464

465
        let timing = AssertionTiming {
4✔
466
            elapsed_ms,
4✔
467
            total_elapsed_ms: self.total_scope_elapsed_ms,
4✔
468
            scope_message_count,
4✔
469
            scope_index: self.scope_index,
4✔
470
        };
4✔
471

472
        Some(timing)
4✔
473
    }
4✔
474
}
475

476
impl TestExecutionResult {
477
    pub fn pass(grpc_duration_ms: Option<u64>) -> Self {
10✔
478
        Self {
10✔
479
            status: TestExecutionStatus::Pass,
10✔
480
            grpc_duration_ms,
10✔
481
            captured_response: None,
10✔
482
        }
10✔
483
    }
10✔
484

485
    pub fn fail(message: String, grpc_duration_ms: Option<u64>) -> Self {
7✔
486
        Self {
7✔
487
            status: TestExecutionStatus::Fail(message),
7✔
488
            grpc_duration_ms,
7✔
489
            captured_response: None,
7✔
490
        }
7✔
491
    }
7✔
492

493
    pub fn with_response(mut self, response: crate::grpc::GrpcResponse) -> Self {
×
494
        self.captured_response = Some(response);
×
495
        self
×
496
    }
×
497
}
498

499
/// Test runner
500
pub struct TestRunner {
501
    dry_run: bool,
502
    timeout_seconds: u64,
503
    no_assert: bool,
504
    write_mode: bool,
505
    verbose: bool,
506
    assertion_engine: AssertionEngine,
507
    coverage_collector: Option<Arc<CoverageCollector>>,
508
    // Handler modules for delegated functionality
509
    request_handler: RequestHandler,
510
    response_handler: ResponseHandler,
511
    assertion_handler: AssertionHandler,
512
}
513

514
fn parse_bool_flag(value: &str) -> bool {
8✔
515
    matches!(
4✔
516
        value.trim().to_ascii_lowercase().as_str(),
8✔
517
        "true" | "1" | "yes" | "on"
8✔
518
    )
519
}
8✔
520

521
fn tls_env_defaults() -> HashMap<String, String> {
3✔
522
    let mut defaults = HashMap::new();
3✔
523

524
    if let Ok(value) = std::env::var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE)
3✔
525
        && !value.trim().is_empty()
2✔
526
    {
1✔
527
        defaults.insert("ca_cert".to_string(), value);
1✔
528
    }
2✔
529
    if let Ok(value) = std::env::var(crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE)
3✔
530
        && !value.trim().is_empty()
2✔
531
    {
1✔
532
        defaults.insert("client_cert".to_string(), value);
1✔
533
    }
2✔
534
    if let Ok(value) = std::env::var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE)
3✔
535
        && !value.trim().is_empty()
2✔
536
    {
1✔
537
        defaults.insert("client_key".to_string(), value);
1✔
538
    }
2✔
539
    if let Ok(value) = std::env::var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME)
3✔
540
        && !value.trim().is_empty()
2✔
541
    {
1✔
542
        defaults.insert("server_name".to_string(), value);
1✔
543
    }
2✔
544

545
    defaults
3✔
546
}
3✔
547

548
fn resolve_tls_path(value: &str, from_env: bool, document_path: &Path) -> String {
2✔
549
    let path = Path::new(value);
2✔
550
    if path.is_absolute() {
2✔
551
        return path.to_string_lossy().to_string();
×
552
    }
2✔
553

554
    if from_env {
2✔
555
        if runtime::supports(runtime::Capability::IsolatedFsIo)
1✔
556
            && let Ok(cwd) = std::env::current_dir()
1✔
557
        {
558
            return cwd.join(path).to_string_lossy().to_string();
1✔
559
        }
×
560
        return path.to_string_lossy().to_string();
×
561
    }
1✔
562

563
    FileUtils::resolve_relative_path(document_path, value)
1✔
564
        .to_string_lossy()
1✔
565
        .to_string()
1✔
566
}
2✔
567

568
impl TestRunner {
569
    /// Create expected values from a response section.
570
    pub fn expected_values_for_response_section(
3✔
571
        section: &crate::parser::ast::Section,
3✔
572
    ) -> Vec<Value> {
3✔
573
        match &section.content {
3✔
574
            SectionContent::Json(v) => vec![v.clone()],
2✔
575
            SectionContent::JsonLines(values) => values.clone(),
1✔
576
            _ => Vec::new(),
×
577
        }
578
    }
3✔
579

580
    pub fn grpc_code_name_from_numeric(code: i64) -> Option<&'static str> {
4✔
581
        super::error_handler::ErrorHandler::grpc_code_name_from_numeric(code)
4✔
582
    }
4✔
583

584
    pub fn error_matches_expected(error_text: &str, expected: &Value) -> bool {
5✔
585
        super::error_handler::ErrorHandler::error_matches_expected(error_text, expected)
5✔
586
    }
5✔
587

588
    /// Create a new test runner
589
    pub fn new(
22✔
590
        dry_run: bool,
22✔
591
        timeout_seconds: u64,
22✔
592
        no_assert: bool,
22✔
593
        write_mode: bool,
22✔
594
        verbose: bool,
22✔
595
        coverage_collector: Option<Arc<CoverageCollector>>,
22✔
596
    ) -> Self {
22✔
597
        Self {
22✔
598
            dry_run,
22✔
599
            timeout_seconds,
22✔
600
            no_assert,
22✔
601
            write_mode,
22✔
602
            verbose,
22✔
603
            assertion_engine: AssertionEngine::new(),
22✔
604
            coverage_collector: coverage_collector.clone(),
22✔
605
            // Initialize handler modules
22✔
606
            request_handler: RequestHandler::new(no_assert, verbose, coverage_collector.clone()),
22✔
607
            response_handler: ResponseHandler::new(no_assert),
22✔
608
            assertion_handler: AssertionHandler::new(verbose),
22✔
609
        }
22✔
610
    }
22✔
611

612
    /// Run a test document chain.
613
    /// Walks the `next_document` linked list, accumulating EXTRACT variables
614
    /// between documents. Fail-fast: stops on first failure.
615
    pub async fn run_test(&self, document: &GctfDocument) -> Result<TestExecutionResult> {
1✔
616
        let mut variables: HashMap<String, Value> = HashMap::new();
1✔
617
        let mut overall_status = TestExecutionStatus::Pass;
1✔
618
        let mut total_duration_ms = 0.0;
1✔
619

620
        for doc in document.iter_chain() {
1✔
621
            let result = self.run_one(doc, &mut variables).await?;
1✔
622
            if let Some(dur) = result.grpc_duration_ms {
1✔
623
                total_duration_ms += dur as f64;
×
624
            }
1✔
625

626
            if let TestExecutionStatus::Fail(msg) = &result.status {
1✔
627
                overall_status = TestExecutionStatus::Fail(msg.clone());
×
628
                break;
×
629
            }
1✔
630
        }
631

632
        // Build summary result
633
        Ok(TestExecutionResult {
1✔
634
            status: overall_status,
1✔
635
            grpc_duration_ms: Some(total_duration_ms as u64),
1✔
636
            captured_response: None,
1✔
637
        })
1✔
638
    }
1✔
639

640
    /// Run a single document, sharing variables with the chain.
641
    async fn run_one(
1✔
642
        &self,
1✔
643
        document: &GctfDocument,
1✔
644
        variables: &mut HashMap<String, Value>,
1✔
645
    ) -> Result<TestExecutionResult> {
1✔
646
        let effective_dry_run = self.dry_run;
1✔
647
        let effective_no_assert = self.no_assert;
1✔
648
        let effective_write_mode = self.write_mode;
1✔
649

650
        let options = document.get_options().unwrap_or_default();
1✔
651
        let effective_timeout_seconds = match options.get("timeout") {
1✔
652
            Some(value) => match value.trim().parse::<u64>() {
×
653
                Ok(v) if v > 0 => v,
×
654
                _ => {
655
                    return Ok(TestExecutionResult::fail(
×
656
                        format!(
×
657
                            "OPTIONS.timeout must be a positive integer, got '{}'",
×
658
                            value
×
659
                        ),
×
660
                        None,
×
661
                    ));
×
662
                }
663
            },
664
            None => self.timeout_seconds,
1✔
665
        };
666

667
        // Validate file path in update mode
668
        if effective_write_mode {
1✔
669
            let file_path = Path::new(&document.file_path);
×
670
            if !file_path.exists() {
×
671
                return Ok(TestExecutionResult::fail(
×
672
                    format!("Update mode: file '{}' does not exist", document.file_path),
×
673
                    None,
×
674
                ));
×
675
            }
×
676

677
            // Check if file is writable
678
            use std::fs::OpenOptions;
679
            if OpenOptions::new().write(true).open(file_path).is_err() {
×
680
                return Ok(TestExecutionResult::fail(
×
681
                    format!("Update mode: file '{}' is not writable", document.file_path),
×
682
                    None,
×
683
                ));
×
684
            }
×
685
        }
1✔
686

687
        // Extract address
688
        let address = match document.get_address(
1✔
689
            std::env::var(crate::config::ENV_GRPCTESTIFY_ADDRESS)
1✔
690
                .ok()
1✔
691
                .as_deref(),
1✔
692
        ) {
1✔
693
            Some(a) => a,
×
694
            None => {
695
                // Default to localhost:4770 if no address is specified anywhere
696
                crate::config::default_address()
1✔
697
            }
698
        };
699

700
        // Extract endpoint
701
        let (package, service, method) = match document.parse_endpoint() {
1✔
702
            Some(e) => e,
1✔
703
            None => {
704
                return Ok(TestExecutionResult::fail(
×
705
                    "Invalid or missing endpoint".to_string(),
×
706
                    None,
×
707
                ));
×
708
            }
709
        };
710

711
        if document.sections.is_empty() {
1✔
712
            return Ok(TestExecutionResult::fail(
×
713
                "No sections found".to_string(),
×
714
                None,
×
715
            ));
×
716
        }
1✔
717

718
        if effective_dry_run {
1✔
719
            // In dry-run, show detailed preview of what will be executed
720
            self.print_dry_run_preview(document, &address, &package, &service, &method);
1✔
721
            return Ok(TestExecutionResult::pass(None));
1✔
722
        }
×
723

724
        // Configure Client
725
        let document_path = Path::new(&document.file_path);
×
726

727
        let tls_defaults = tls_env_defaults();
×
728
        let tls_section = document.get_tls_config();
×
729

730
        let pick_tls_value = |keys: &[&str]| -> Option<(String, bool)> {
×
731
            if let Some(section_map) = tls_section.as_ref() {
×
732
                for key in keys {
×
733
                    if let Some(value) = section_map.get(*key) {
×
734
                        return Some((value.clone(), false));
×
735
                    }
×
736
                }
737
            }
×
738

739
            for key in keys {
×
740
                if let Some(value) = tls_defaults.get(*key) {
×
741
                    return Some((value.clone(), true));
×
742
                }
×
743
            }
744

745
            None
×
746
        };
×
747

748
        let ca_cert_path = pick_tls_value(&["ca_cert", "ca_file"])
×
749
            .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
×
750
        let client_cert_path = pick_tls_value(&["client_cert", "cert", "cert_file"])
×
751
            .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
×
752
        let client_key_path = pick_tls_value(&["client_key", "key", "key_file"])
×
753
            .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
×
754
        let server_name = pick_tls_value(&["server_name"]).map(|(v, _)| v);
×
755
        let insecure_skip_verify = tls_section
×
756
            .as_ref()
×
757
            .and_then(|m| m.get("insecure"))
×
758
            .map(|s| parse_bool_flag(s))
×
759
            .unwrap_or(false);
×
760

761
        let tls_config = if ca_cert_path.is_some()
×
762
            || client_cert_path.is_some()
×
763
            || client_key_path.is_some()
×
764
            || server_name.is_some()
×
765
            || insecure_skip_verify
×
766
        {
767
            Some(TlsConfig {
×
768
                ca_cert_path,
×
769
                client_cert_path,
×
770
                client_key_path,
×
771
                server_name,
×
772
                insecure_skip_verify,
×
773
            })
×
774
        } else {
775
            None
×
776
        };
777

778
        // Check for Proto config in document
779
        let proto_config = if let Some(proto_map) = document.get_proto_config() {
×
780
            let files = proto_map
×
781
                .get("files")
×
782
                .map(|s| {
×
783
                    s.split(',')
×
784
                        .map(|p| {
×
785
                            FileUtils::resolve_relative_path(document_path, p.trim())
×
786
                                .to_string_lossy()
×
787
                                .to_string()
×
788
                        })
×
789
                        .collect()
×
790
                })
×
791
                .unwrap_or_default();
×
792

793
            let import_paths = proto_map
×
794
                .get("import_paths")
×
795
                .map(|s| {
×
796
                    s.split(',')
×
797
                        .map(|p| {
×
798
                            FileUtils::resolve_relative_path(document_path, p.trim())
×
799
                                .to_string_lossy()
×
800
                                .to_string()
×
801
                        })
×
802
                        .collect()
×
803
                })
×
804
                .unwrap_or_default();
×
805

806
            let descriptor = proto_map.get("descriptor").map(|p| {
×
807
                FileUtils::resolve_relative_path(document_path, p)
×
808
                    .to_string_lossy()
×
809
                    .to_string()
×
810
            });
×
811

812
            Some(ProtoConfig {
×
813
                files,
×
814
                import_paths,
×
815
                descriptor,
×
816
            })
×
817
        } else {
818
            None
×
819
        };
820

NEW
821
        let full_service = runner_helpers::full_service_name(&package, &service);
×
822

823
        let client_config = GrpcClientConfig {
×
824
            address,
×
825
            timeout_seconds: effective_timeout_seconds,
×
826
            tls_config,
×
827
            proto_config,
×
828
            metadata: document.get_request_headers(),
×
829
            target_service: Some(full_service.clone()),
×
830
            compression: CompressionMode::from_env(),
×
831
        };
×
832

833
        let client = GrpcClient::new(client_config).await?;
×
834

835
        // Get input/output message types for field coverage tracking
836
        let input_message_type = client
×
837
            .descriptor_pool()
×
838
            .get_service_by_name(&full_service)
×
839
            .and_then(|s| s.methods().find(|m| m.name() == method))
×
840
            .map(|m| m.input().full_name().to_string());
×
841
        let output_message_type = client
×
842
            .descriptor_pool()
×
843
            .get_service_by_name(&full_service)
×
844
            .and_then(|s| s.methods().find(|m| m.name() == method))
×
845
            .map(|m| m.output().full_name().to_string());
×
846

847
        // Setup Streaming
NEW
848
        let (tx, rx) = mpsc::channel::<Value>(REQUEST_CHANNEL_BUFFER);
×
849
        let request_stream = ReceiverStream::new(rx);
×
850
        let mut tx = Some(tx);
×
851

852
        // Coverage: Register pool and record call
853
        if let Some(collector) = &self.coverage_collector {
×
854
            collector.register_pool(client.descriptor_pool());
×
855
            collector.record_call(&full_service, &method);
×
856
        }
×
857

858
        let start_time = std::time::Instant::now();
×
859

860
        // Start the gRPC call in background so unary/server-streaming methods can wait
861
        // for the first request message without deadlocking this task.
862
        let full_service_clone = full_service.clone();
×
863
        let method_clone = method.clone();
×
864
        let mut client_for_call = client;
×
865
        let mut call_handle = Some(tokio::spawn(async move {
×
866
            client_for_call
×
867
                .call_stream(&full_service_clone, &method_clone, request_stream)
×
868
                .await
×
869
        }));
×
870

871
        let mut response_stream = None;
×
872

873
        // variables passed from caller (shared across chain)
874
        let mut last_message: Option<Value> = None;
×
875
        let mut last_error_message: Option<String> = None;
×
876
        let mut last_error_timing: Option<AssertionTiming> = None;
×
877
        let mut captured_headers: HashMap<String, String> = HashMap::new();
×
878
        let mut captured_trailers: HashMap<String, String> = HashMap::new();
×
879
        let mut failure_reasons: Vec<String> = Vec::new();
×
880
        let mut assertion_timing = AssertionScopeTimingState::default();
×
881

882
        // Iterator for sections
883
        // We iterate by index to allow lookahead
884
        let sections = &document.sections;
×
885

886
        // Pre-compute last request index to avoid O(n²) lookups in loop
887
        let last_request_idx = sections
×
888
            .iter()
×
889
            .rposition(|s| s.section_type == SectionType::Request);
×
890

891
        let has_request_sections = sections
×
892
            .iter()
×
893
            .any(|s| s.section_type == SectionType::Request);
×
894

895
        // Legacy behavior: if no REQUEST section is provided, send an empty
896
        // JSON object as a single request message for unary/server-stream calls.
897
        if !has_request_sections && let Some(tx_ref) = tx.as_mut() {
×
898
            if let Err(e) = tx_ref.send(Value::Object(serde_json::Map::new())).await {
×
899
                failure_reasons.push(format!("Failed to send implicit empty request: {}", e));
×
900
            }
×
901
            drop(tx.take());
×
902
        }
×
903

904
        let mut skip_next_section = false;
×
905

906
        // Capture full response for write mode
907
        let mut captured_response = if effective_write_mode {
×
908
            Some(crate::grpc::GrpcResponse::new())
×
909
        } else {
910
            None
×
911
        };
912

913
        /// Awaits the gRPC call handle and extracts the response stream.
914
        /// Eliminates duplication across Response and Asserts section handlers.
915
        macro_rules! ensure_stream_ready {
916
            () => {
917
                if response_stream.is_none()
918
                    && let Some(handle) = call_handle.take()
919
                {
920
                    match handle.await {
921
                        Ok(Ok((h, stream))) => {
922
                            if let Some(resp) = &mut captured_response {
923
                                captured_headers = h.clone();
924
                                resp.headers = h;
925
                            } else {
926
                                captured_headers = h;
927
                            }
928
                            response_stream = Some(stream);
929
                        }
930
                        Ok(Err(e)) => {
931
                            failure_reasons.push(format!("Failed to start gRPC stream: {}", e));
932
                            break;
933
                        }
934
                        Err(e) => {
935
                            failure_reasons
936
                                .push(format!("Failed to join gRPC stream startup task: {}", e));
937
                            break;
938
                        }
939
                    }
940
                }
941
            };
942
        }
943

944
        for (i, section) in sections.iter().enumerate() {
×
945
            if skip_next_section {
×
946
                skip_next_section = false;
×
947
                continue;
×
948
            }
×
949

950
            match section.section_type {
×
951
                SectionType::Request => {
952
                    // Build request using RequestHandler
953
                    let request_value = match &section.content {
×
954
                        SectionContent::Json(req_json) => {
×
955
                            let mut req = req_json.clone();
×
956
                            self.substitute_variables(&mut req, variables);
×
957
                            req
×
958
                        }
959
                        SectionContent::Empty => Value::Object(serde_json::Map::new()),
×
960
                        _ => continue,
×
961
                    };
962

963
                    // Coverage: record request fields
964
                    if let (Some(collector), Some(msg_type)) =
×
965
                        (&self.coverage_collector, &input_message_type)
×
966
                    {
×
967
                        collector.record_fields_from_json(msg_type, &request_value);
×
968
                    }
×
969

970
                    // Send request using RequestHandler
971
                    let Some(tx_ref) = tx.as_mut() else {
×
972
                        failure_reasons.push(format!(
×
973
                            "Failed to send request at line {}: request stream already closed",
974
                            section.start_line
975
                        ));
976
                        break;
×
977
                    };
978

979
                    let result = self
×
980
                        .request_handler
×
981
                        .send_request(tx_ref, request_value, section.start_line, None)
×
982
                        .await;
×
983
                    if !result.success
×
984
                        && let Some(error) = result.error_message
×
985
                    {
×
986
                        failure_reasons.push(error);
×
987
                    }
×
988
                }
989
                SectionType::Response => {
990
                    let scope_start_ms = assertion_timing.last_message_elapsed_ms.unwrap_or(0);
×
991
                    let mut scope_end_ms = scope_start_ms;
×
992
                    let mut scope_message_count = 0usize;
×
993

994
                    if i >= last_request_idx.unwrap_or(usize::MAX) {
×
995
                        drop(tx.take());
×
996
                    }
×
997

NEW
998
                    ensure_stream_ready!();
×
999

1000
                    let mut received_messages_for_section: Vec<Value> = Vec::new();
×
1001
                    let expected_values = Self::expected_values_for_response_section(section);
×
1002

1003
                    for expected_template in expected_values {
×
1004
                        match response_stream.as_mut().unwrap().next().await {
×
1005
                            Some(Ok(item)) => {
×
1006
                                match item {
×
1007
                                    crate::grpc::client::StreamItem::Message(msg) => {
×
1008
                                        let now_elapsed_ms =
×
1009
                                            start_time.elapsed().as_millis() as u64;
×
1010

1011
                                        let msg_for_state = msg.clone();
×
1012
                                        last_message = Some(msg_for_state.clone());
×
1013
                                        if section.inline_options.with_asserts {
×
1014
                                            received_messages_for_section
×
1015
                                                .push(msg_for_state.clone());
×
1016
                                        }
×
1017
                                        scope_end_ms = now_elapsed_ms;
×
1018
                                        scope_message_count += 1;
×
1019
                                        assertion_timing.last_message_elapsed_ms =
×
1020
                                            Some(now_elapsed_ms);
×
1021
                                        if let Some(resp) = &mut captured_response {
×
1022
                                            resp.messages.push(msg_for_state);
×
1023
                                        }
×
1024

NEW
1025
                                        Self::log_response_message(
×
NEW
1026
                                            &msg,
×
NEW
1027
                                            effective_no_assert,
×
NEW
1028
                                            self.verbose,
×
1029
                                        );
1030

1031
                                        if !effective_no_assert {
×
1032
                                            let mut expected = expected_template.clone();
×
1033
                                            self.substitute_variables(&mut expected, variables);
×
1034

1035
                                            // Coverage: record expected response fields
1036
                                            if let (Some(collector), Some(msg_type)) =
×
1037
                                                (&self.coverage_collector, &output_message_type)
×
1038
                                            {
×
1039
                                                collector
×
1040
                                                    .record_fields_from_json(msg_type, &expected);
×
1041
                                            }
×
1042

1043
                                            let diffs = JsonComparator::compare(
×
1044
                                                &msg,
×
1045
                                                &expected,
×
1046
                                                &section.inline_options,
×
1047
                                            );
1048

1049
                                            if !diffs.is_empty() {
×
NEW
1050
                                                self.append_response_diffs(
×
NEW
1051
                                                    diffs,
×
NEW
1052
                                                    section.start_line,
×
NEW
1053
                                                    &expected,
×
NEW
1054
                                                    &msg,
×
NEW
1055
                                                    &mut failure_reasons,
×
NEW
1056
                                                );
×
1057
                                            }
×
1058
                                        }
×
1059
                                    }
1060
                                    crate::grpc::client::StreamItem::Trailers(t) => {
×
1061
                                        if let Some(resp) = &mut captured_response {
×
1062
                                            captured_trailers.extend(
×
1063
                                                t.iter().map(|(k, v)| (k.clone(), v.clone())),
×
1064
                                            );
1065
                                            resp.trailers.extend(t);
×
1066
                                        } else {
×
1067
                                            captured_trailers.extend(t);
×
1068
                                        }
×
1069
                                        if !effective_no_assert {
×
1070
                                            failure_reasons.push(format!(
×
1071
                                                "Expected message for RESPONSE section at line {}, but received Trailers (End of Stream)",
×
1072
                                                section.start_line
×
1073
                                            ));
×
1074
                                        }
×
1075
                                        break;
×
1076
                                    }
1077
                                }
1078
                            }
1079
                            Some(Err(status)) => {
×
1080
                                let scope_start_ms =
×
1081
                                    assertion_timing.last_message_elapsed_ms.unwrap_or(0);
×
1082
                                let scope_end_ms = start_time.elapsed().as_millis() as u64;
×
1083
                                assertion_timing.last_message_elapsed_ms = Some(scope_end_ms);
×
1084
                                last_error_timing =
×
1085
                                    assertion_timing.finish_scope(scope_start_ms, scope_end_ms, 1);
×
1086
                                last_error_message = Some(status.message().to_string());
×
1087

1088
                                if let Some(resp) = &mut captured_response {
×
1089
                                    resp.error = Some(status.message().to_string());
×
1090
                                }
×
1091
                                if !effective_no_assert {
×
1092
                                    failure_reasons.push(format!(
×
1093
                                        "Expected message for RESPONSE section at line {}, but received Error: {}",
×
1094
                                        section.start_line,
×
1095
                                        status.message()
×
1096
                                    ));
×
1097
                                } else {
×
1098
                                    println!("--- RESPONSE (Error) ---");
×
1099
                                    println!("{}", status.message());
×
1100
                                }
×
1101
                                break;
×
1102
                            }
1103
                            None => {
1104
                                if !effective_no_assert {
×
1105
                                    failure_reasons.push(format!(
×
1106
                                        "Expected message for RESPONSE section at line {}, but stream ended",
×
1107
                                        section.start_line
×
1108
                                    ));
×
1109
                                }
×
1110
                                break;
×
1111
                            }
1112
                        }
1113
                    }
1114

1115
                    if section.inline_options.with_asserts
×
1116
                        && let Some(next_section) = sections.get(i + 1)
×
1117
                        && next_section.section_type == SectionType::Asserts
×
1118
                    {
1119
                        if !effective_no_assert
×
1120
                            && let SectionContent::Assertions(lines) = &next_section.content
×
1121
                        {
1122
                            let scope_timing = assertion_timing.finish_scope(
×
1123
                                scope_start_ms,
×
1124
                                scope_end_ms,
×
1125
                                scope_message_count,
×
1126
                            );
1127

1128
                            for msg in &received_messages_for_section {
×
1129
                                self.run_assertions(
×
1130
                                    lines,
×
1131
                                    msg,
×
1132
                                    &mut failure_reasons,
×
1133
                                    format!(
×
1134
                                        "(attached to RESPONSE at line {})",
×
1135
                                        section.start_line
×
1136
                                    ),
×
1137
                                    AssertionContext {
×
1138
                                        headers: &captured_headers,
×
1139
                                        trailers: &captured_trailers,
×
1140
                                        timing: scope_timing.as_ref(),
×
1141
                                    },
×
1142
                                );
×
1143
                            }
×
1144
                        }
×
1145
                        skip_next_section = true;
×
1146
                    } else if section.inline_options.with_asserts && !effective_no_assert {
×
1147
                        failure_reasons.push(format!(
×
1148
                            "RESPONSE at line {} has 'with_asserts' but is not followed by ASSERTS",
×
1149
                            section.start_line
×
1150
                        ));
×
1151
                    }
×
1152
                }
1153
                SectionType::Asserts => {
1154
                    if i >= last_request_idx.unwrap_or(usize::MAX) {
×
1155
                        drop(tx.take());
×
1156
                    }
×
1157

NEW
1158
                    ensure_stream_ready!();
×
1159

1160
                    // Standalone ASSERTS usually consumes a new message.
1161
                    // If stream is unavailable but we already captured an ERROR,
1162
                    // evaluate assertions against that error context.
1163
                    let Some(stream) = response_stream.as_mut() else {
×
1164
                        if !effective_no_assert
×
1165
                            && let SectionContent::Assertions(lines) = &section.content
×
1166
                        {
1167
                            if let Some(error_message) = &last_error_message {
×
1168
                                let error_value = Value::String(error_message.clone());
×
1169
                                self.run_assertions(
×
1170
                                    lines,
×
1171
                                    &error_value,
×
1172
                                    &mut failure_reasons,
×
1173
                                    format!("after ERROR at line {}", section.start_line),
×
1174
                                    AssertionContext {
×
1175
                                        headers: &captured_headers,
×
1176
                                        trailers: &captured_trailers,
×
1177
                                        timing: last_error_timing.as_ref(),
×
1178
                                    },
×
1179
                                );
×
1180
                            } else {
×
1181
                                failure_reasons.push(format!(
×
1182
                                    "ASSERTS section at line {} has no active response/error context",
×
1183
                                    section.start_line
×
1184
                                ));
×
1185
                            }
×
1186
                        }
×
1187
                        continue;
×
1188
                    };
1189

1190
                    match stream.next().await {
×
1191
                        Some(Ok(crate::grpc::client::StreamItem::Message(msg))) => {
×
1192
                            let scope_start_ms =
×
1193
                                assertion_timing.last_message_elapsed_ms.unwrap_or(0);
×
1194
                            let scope_end_ms = start_time.elapsed().as_millis() as u64;
×
1195
                            assertion_timing.last_message_elapsed_ms = Some(scope_end_ms);
×
1196
                            let scope_timing =
×
1197
                                assertion_timing.finish_scope(scope_start_ms, scope_end_ms, 1);
×
1198

1199
                            last_message = Some(msg.clone());
×
1200

1201
                            let should_format_message =
×
1202
                                tracing::enabled!(tracing::Level::DEBUG) || effective_no_assert;
×
NEW
1203
                            let msg_pretty = should_format_message
×
NEW
1204
                                .then(|| runner_helpers::format_json_pretty(&msg));
×
1205

1206
                            if let Some(pretty) = msg_pretty.as_deref()
×
1207
                                && tracing::enabled!(tracing::Level::DEBUG)
×
1208
                            {
1209
                                tracing::debug!("Received Response (for Asserts):\n{}", pretty);
×
1210
                            }
×
1211

1212
                            if effective_no_assert {
×
1213
                                println!("--- RESPONSE (Raw) ---");
×
1214
                                if let Some(pretty) = msg_pretty.as_deref() {
×
1215
                                    println!("{}", pretty);
×
1216
                                }
×
1217
                            }
×
1218

1219
                            if !effective_no_assert
×
1220
                                && let SectionContent::Assertions(lines) = &section.content
×
1221
                            {
×
1222
                                self.run_assertions(
×
1223
                                    lines,
×
1224
                                    &msg,
×
1225
                                    &mut failure_reasons,
×
1226
                                    format!("at line {}", section.start_line),
×
1227
                                    AssertionContext {
×
1228
                                        headers: &captured_headers,
×
1229
                                        trailers: &captured_trailers,
×
1230
                                        timing: scope_timing.as_ref(),
×
1231
                                    },
×
1232
                                );
×
1233
                            }
×
1234
                        }
1235
                        Some(Ok(crate::grpc::client::StreamItem::Trailers(t))) => {
×
1236
                            captured_trailers.extend(t);
×
1237
                            if !effective_no_assert {
×
1238
                                failure_reasons.push(format!(
×
1239
                                    "Expected message for ASSERTS section at line {}, but received Trailers",
×
1240
                                    section.start_line
×
1241
                                ));
×
1242
                            }
×
1243
                        }
1244
                        Some(Err(status)) => {
×
1245
                            let scope_start_ms =
×
1246
                                assertion_timing.last_message_elapsed_ms.unwrap_or(0);
×
1247
                            let scope_end_ms = start_time.elapsed().as_millis() as u64;
×
1248
                            assertion_timing.last_message_elapsed_ms = Some(scope_end_ms);
×
1249
                            last_error_timing =
×
1250
                                assertion_timing.finish_scope(scope_start_ms, scope_end_ms, 1);
×
1251

1252
                            last_error_message = Some(status.message().to_string());
×
1253
                            captured_trailers
×
NEW
1254
                                .extend(runner_helpers::metadata_map_to_hashmap(status.metadata()));
×
1255
                            if !effective_no_assert {
×
1256
                                failure_reasons.push(format!(
×
1257
                                     "Expected message for ASSERTS section at line {}, but received Error: {}",
×
1258
                                     section.start_line, status.message()
×
1259
                                 ));
×
1260
                            } else {
×
1261
                                println!("--- RESPONSE (Error) ---");
×
1262
                                println!("{}", status.message());
×
1263
                            }
×
1264
                        }
1265
                        None => {
1266
                            if !effective_no_assert {
×
1267
                                failure_reasons.push(format!(
×
1268
                                    "Expected message for ASSERTS section at line {}, but stream ended",
×
1269
                                    section.start_line
×
1270
                                ));
×
1271
                            }
×
1272
                        }
1273
                    }
1274
                }
1275

1276
                SectionType::Extract => {
1277
                    if let Some(msg) = &last_message {
×
1278
                        if let SectionContent::Extract(extractions) = &section.content {
×
1279
                            for (key, query) in extractions {
×
1280
                                match self.assertion_engine.query(query, msg) {
×
1281
                                    Ok(results) => {
×
1282
                                        if let Some(val) = results.first() {
×
1283
                                            variables.insert(key.clone(), val.clone());
×
1284
                                        } else {
×
1285
                                            failure_reasons.push(format!(
×
1286
                                                 "Extraction failed at line {}: Query '{}' returned no results",
×
1287
                                                 section.start_line, query
×
1288
                                             ));
×
1289
                                        }
×
1290
                                    }
1291
                                    Err(e) => {
×
1292
                                        failure_reasons.push(format!(
×
1293
                                            "Extraction error at line {}: {}",
×
1294
                                            section.start_line, e
×
1295
                                        ));
×
1296
                                    }
×
1297
                                }
1298
                            }
1299
                        }
×
1300
                    } else {
×
1301
                        failure_reasons.push(format!(
×
1302
                            "EXTRACT at line {} requires a previous response message",
×
1303
                            section.start_line
×
1304
                        ));
×
1305
                    }
×
1306
                }
1307
                SectionType::Error => {
1308
                    if i >= last_request_idx.unwrap_or(usize::MAX) {
×
1309
                        drop(tx.take());
×
1310
                    }
×
1311

1312
                    if response_stream.is_none()
×
1313
                        && let Some(handle) = call_handle.take()
×
1314
                    {
1315
                        match handle.await {
×
1316
                            Ok(Ok((h, stream))) => {
×
1317
                                if let Some(resp) = &mut captured_response {
×
1318
                                    captured_headers = h.clone();
×
1319
                                    resp.headers = h;
×
1320
                                } else {
×
1321
                                    captured_headers = h;
×
1322
                                }
×
1323
                                response_stream = Some(stream);
×
1324
                            }
NEW
1325
                            Ok(Err(_e)) => {
×
NEW
1326
                                let e = _e;
×
1327
                                if !effective_no_assert {
×
1328
                                    if let SectionContent::Json(expected_json) = &section.content {
×
1329
                                        let mut expected = expected_json.clone();
×
1330
                                        self.substitute_variables(&mut expected, variables);
×
1331

1332
                                        // Try to extract tonic Status from anyhow::Error
1333
                                        let (matches, got, mismatch_reason) = if let Some(status) =
×
1334
                                            e.downcast_ref::<tonic::Status>()
×
1335
                                        {
1336
                                            last_error_message = Some(status.message().to_string());
×
1337
                                            captured_trailers.extend(
×
NEW
1338
                                                runner_helpers::metadata_map_to_hashmap(
×
NEW
1339
                                                    status.metadata(),
×
1340
                                                ),
1341
                                            );
1342
                                            let status_name = Self::grpc_code_name_from_numeric(
×
1343
                                                status.code() as i64,
×
1344
                                            )
1345
                                            .unwrap_or("Unknown");
×
1346
                                            (
×
1347
                                                super::error_handler::ErrorHandler::status_matches_expected(
×
1348
                                                    status,
×
1349
                                                    &expected,
×
1350
                                                ),
×
1351
                                                format!(
×
1352
                                                    "status: {}, message: \"{}\"",
×
1353
                                                    status_name,
×
1354
                                                    status.message()
×
1355
                                                ),
×
1356
                                                super::error_handler::ErrorHandler::status_mismatch_reason(
×
1357
                                                    status,
×
1358
                                                    &expected,
×
1359
                                                ),
×
1360
                                            )
×
1361
                                        } else {
1362
                                            // Fallback to error string representation
1363
                                            let text = e.to_string();
×
1364
                                            (
×
1365
                                                Self::error_matches_expected(&text, &expected),
×
1366
                                                text,
×
1367
                                                None,
×
1368
                                            )
×
1369
                                        };
1370

1371
                                        if self.verbose {
×
1372
                                            println!("🔍 gRPC error received: '{}'", got);
×
1373
                                            if let Some(status) = e.downcast_ref::<tonic::Status>()
×
1374
                                            {
1375
                                                let details_json = super::error_handler::ErrorHandler::status_details_json(status);
×
1376
                                                if details_json != Value::Null
×
1377
                                                    && details_json
×
1378
                                                        .as_array()
×
1379
                                                        .is_some_and(|arr| !arr.is_empty())
×
1380
                                                {
×
1381
                                                    println!(
×
1382
                                                        "🔍 gRPC error details: {}",
×
1383
                                                        details_json
×
1384
                                                    );
×
1385
                                                }
×
1386
                                            }
×
1387
                                        }
×
1388

1389
                                        if !matches {
×
1390
                                            failure_reasons.push(format!(
×
1391
                                                "Error mismatch at line {}:",
1392
                                                section.start_line
1393
                                            ));
1394
                                            if let Some(reason) = mismatch_reason {
×
1395
                                                failure_reasons.push(format!("  - {}", reason));
×
1396
                                            }
×
1397
                                            if let Some(status) = e.downcast_ref::<tonic::Status>()
×
1398
                                            {
×
1399
                                                let actual_json =
×
1400
                                                    super::error_handler::ErrorHandler::status_to_json(
×
1401
                                                        status,
×
1402
                                                    );
×
1403
                                                failure_reasons
×
1404
                                                    .push(get_json_diff(&expected, &actual_json));
×
1405
                                            } else {
×
1406
                                                failure_reasons.push(format!(
×
1407
                                                    "  - expected {}, got '{}'",
×
1408
                                                    expected, got
×
1409
                                                ));
×
1410
                                            }
×
1411
                                        }
×
1412
                                    }
×
1413
                                } else {
×
1414
                                    println!("--- RESPONSE (Error) ---");
×
1415
                                    println!("{}", e);
×
1416
                                }
×
1417
                                // Error has been consumed at startup stage; continue with next sections.
1418
                                continue;
×
1419
                            }
1420
                            Err(e) => {
×
1421
                                failure_reasons.push(format!(
×
1422
                                    "Failed to join gRPC stream startup task: {}",
1423
                                    e
1424
                                ));
1425
                                break;
×
1426
                            }
1427
                        }
1428
                    }
×
1429

1430
                    // Expect an error from the stream
1431
                    match response_stream.as_mut().unwrap().next().await {
×
1432
                        Some(Err(status)) => {
×
1433
                            let scope_start_ms =
×
1434
                                assertion_timing.last_message_elapsed_ms.unwrap_or(0);
×
1435
                            let scope_end_ms = start_time.elapsed().as_millis() as u64;
×
1436
                            assertion_timing.last_message_elapsed_ms = Some(scope_end_ms);
×
1437
                            let error_scope_timing =
×
1438
                                assertion_timing.finish_scope(scope_start_ms, scope_end_ms, 1);
×
1439

1440
                            let status_message = status.message();
×
1441
                            last_error_message = Some(status_message.to_string());
×
1442
                            last_error_timing = error_scope_timing;
×
1443
                            captured_trailers
×
NEW
1444
                                .extend(runner_helpers::metadata_map_to_hashmap(status.metadata()));
×
1445
                            let should_format_error = effective_no_assert || self.verbose;
×
1446
                            let got = should_format_error.then(|| {
×
1447
                                let status_name =
×
1448
                                    Self::grpc_code_name_from_numeric(status.code() as i64)
×
1449
                                        .unwrap_or("Unknown");
×
1450
                                format!("status: {}, message: \"{}\"", status_name, status_message)
×
1451
                            });
×
1452

1453
                            if effective_no_assert {
×
1454
                                println!("--- RESPONSE (Error) ---");
×
1455
                                if let Some(got) = got.as_deref() {
×
1456
                                    println!("{}", got);
×
1457
                                }
×
1458
                            } else if self.verbose {
×
1459
                                if let Some(got) = got.as_deref() {
×
1460
                                    println!("🔍 gRPC error received: '{}'", got);
×
1461
                                }
×
1462
                                let details_json =
×
1463
                                    super::error_handler::ErrorHandler::status_details_json(
×
1464
                                        &status,
×
1465
                                    );
1466
                                if details_json != Value::Null
×
1467
                                    && details_json.as_array().is_some_and(|arr| !arr.is_empty())
×
1468
                                {
×
1469
                                    println!("🔍 gRPC error details: {}", details_json);
×
1470
                                }
×
1471
                            }
×
1472

1473
                            if !effective_no_assert {
×
1474
                                if let SectionContent::Json(expected_json) = &section.content {
×
1475
                                    let mut expected = expected_json.clone();
×
1476
                                    self.substitute_variables(&mut expected, variables);
×
1477

1478
                                    if !super::error_handler::ErrorHandler::status_matches_expected(
×
1479
                                        &status, &expected,
×
1480
                                    ) {
×
1481
                                        failure_reasons.push(format!(
×
1482
                                            "Error mismatch at line {}:",
1483
                                            section.start_line
1484
                                        ));
1485
                                        if let Some(reason) =
×
1486
                                            super::error_handler::ErrorHandler::status_mismatch_reason(
×
1487
                                                &status,
×
1488
                                                &expected,
×
1489
                                            )
×
1490
                                        {
×
1491
                                            failure_reasons.push(format!("  - {}", reason));
×
1492
                                        }
×
1493
                                        let actual_json =
×
1494
                                            super::error_handler::ErrorHandler::status_to_json(
×
1495
                                                &status,
×
1496
                                            );
1497
                                        failure_reasons
×
1498
                                            .push(get_json_diff(&expected, &actual_json));
×
1499
                                    }
×
1500
                                }
×
1501

1502
                                // Handle with_asserts for Error
1503
                                if section.inline_options.with_asserts
×
1504
                                    && let Some(next_section) = sections.get(i + 1)
×
1505
                                    && next_section.section_type == SectionType::Asserts
×
1506
                                    && let SectionContent::Assertions(lines) = &next_section.content
×
1507
                                {
×
1508
                                    let error_value = Value::String(status.message().to_string());
×
1509
                                    self.run_assertions(
×
1510
                                        lines,
×
1511
                                        &error_value,
×
1512
                                        &mut failure_reasons,
×
1513
                                        format!(
×
1514
                                            "(attached to ERROR at line {})",
×
1515
                                            section.start_line
×
1516
                                        ),
×
1517
                                        AssertionContext {
×
1518
                                            headers: &captured_headers,
×
1519
                                            trailers: &captured_trailers,
×
1520
                                            timing: last_error_timing.as_ref(),
×
1521
                                        },
×
1522
                                    );
×
1523
                                    skip_next_section = true;
×
1524
                                }
×
1525
                            } else {
1526
                                // In no_assert mode, we still need to skip the attached ASSERTS section if present
1527
                                if section.inline_options.with_asserts
×
1528
                                    && let Some(next_section) = sections.get(i + 1)
×
1529
                                    && next_section.section_type == SectionType::Asserts
×
1530
                                {
×
1531
                                    skip_next_section = true;
×
1532
                                }
×
1533
                            }
1534
                        }
1535
                        Some(Ok(msg_item)) => {
×
1536
                            if !effective_no_assert {
×
1537
                                failure_reasons.push(format!(
×
1538
                                    "Expected ERROR at line {}, but received success message or trailers",
×
1539
                                    section.start_line
×
1540
                                ));
×
1541
                            } else {
×
1542
                                // If we got a message instead of error in no_assert mode, print it
1543
                                if let crate::grpc::client::StreamItem::Message(msg) = msg_item {
×
1544
                                    println!("--- RESPONSE (Raw) ---");
×
NEW
1545
                                    println!("{}", runner_helpers::format_json_pretty(&msg));
×
1546
                                }
×
1547
                            }
1548
                        }
1549
                        None => {
1550
                            if !effective_no_assert {
×
1551
                                failure_reasons.push(format!(
×
1552
                                    "Expected ERROR at line {}, but stream ended successfully",
×
1553
                                    section.start_line
×
1554
                                ));
×
1555
                            }
×
1556
                        }
1557
                    }
1558
                }
1559
                _ => {}
×
1560
            }
1561
        }
1562

1563
        // Ensure we close the request stream
1564
        drop(tx.take());
×
1565

1566
        // If in update mode, capture any remaining responses
1567
        if let Some(resp) = &mut captured_response {
×
1568
            if response_stream.is_none()
×
1569
                && let Some(handle) = call_handle.take()
×
1570
            {
1571
                match handle.await {
×
1572
                    Ok(Ok((h, stream))) => {
×
1573
                        resp.headers = h;
×
1574
                        response_stream = Some(stream);
×
1575
                    }
×
1576
                    Ok(Err(_)) | Err(_) => {
×
1577
                        response_stream = None;
×
1578
                    }
×
1579
                }
1580
            }
×
1581

1582
            loop {
1583
                let next_item = if let Some(stream) = response_stream.as_mut() {
×
1584
                    stream.next().await
×
1585
                } else {
1586
                    None
×
1587
                };
1588

1589
                let Some(item_res) = next_item else {
×
1590
                    break;
×
1591
                };
1592

1593
                match item_res {
×
1594
                    Ok(crate::grpc::client::StreamItem::Message(msg)) => {
×
1595
                        resp.messages.push(msg);
×
1596
                    }
×
1597
                    Ok(crate::grpc::client::StreamItem::Trailers(t)) => {
×
1598
                        resp.trailers.extend(t);
×
1599
                    }
×
1600
                    Err(status) => {
×
1601
                        resp.error = Some(status.message().to_string());
×
1602
                    }
×
1603
                }
1604
            }
1605
        }
×
1606

1607
        let grpc_duration = start_time.elapsed().as_millis() as u64;
×
1608

1609
        if !failure_reasons.is_empty() {
×
1610
            // Even if failed, we might want to return captured response?
1611
            // Usually snapshot update only happens if user asks for it.
1612
            // If write_mode is true, we should probably ignore failures?
1613
            if effective_write_mode {
×
1614
                // In write mode, failures (mismatches) are expected because we are updating!
1615
                // But validation errors (like invalid JSON) might still be relevant.
1616
                // Let's assume update mode implies "I want to overwrite whatever happens".
1617
                if let Some(resp) = captured_response {
×
1618
                    return Ok(TestExecutionResult::pass(Some(grpc_duration)).with_response(resp));
×
1619
                }
×
1620
            }
×
1621

1622
            return Ok(TestExecutionResult::fail(
×
1623
                format!("Validation failed:\n  - {}", failure_reasons.join("\n  - ")),
×
1624
                Some(grpc_duration),
×
1625
            ));
×
1626
        }
×
1627

1628
        let mut result = TestExecutionResult::pass(Some(grpc_duration));
×
1629
        if let Some(resp) = captured_response {
×
1630
            result = result.with_response(resp);
×
1631
        }
×
1632
        Ok(result)
×
1633
    }
1✔
1634

1635
    /// Validates a collected response against the document (for testing purposes)
1636
    pub fn validate_response(
12✔
1637
        &self,
12✔
1638
        document: &GctfDocument,
12✔
1639
        response: &crate::grpc::GrpcResponse,
12✔
1640
    ) -> TestExecutionResult {
12✔
1641
        self.response_handler.validate_document(document, response)
12✔
1642
    }
12✔
1643

1644
    fn substitute_variables(&self, value: &mut Value, variables: &HashMap<String, Value>) {
3✔
1645
        match value {
3✔
1646
            Value::String(s) => {
3✔
1647
                if !s.contains("{{") {
3✔
1648
                    return;
×
1649
                }
3✔
1650

1651
                // Check for exact match "{{ var }}" to preserve type
1652
                if s.starts_with("{{") && s.ends_with("}}") {
3✔
1653
                    let inner = s[2..s.len() - 2].trim();
1✔
1654
                    // check if inner has more {{ }} which implies complex string
1655
                    if !inner.contains("{{")
1✔
1656
                        && let Some(val) = variables.get(inner)
1✔
1657
                    {
1658
                        *value = val.clone();
1✔
1659
                        return;
1✔
1660
                    }
×
1661
                }
2✔
1662

1663
                // String interpolation "prefix {{ var }} suffix"
1664
                if let Some(result) = runner_helpers::interpolate_variables(s, variables) {
2✔
1665
                    *value = Value::String(result);
2✔
1666
                }
2✔
1667
            }
1668
            Value::Array(arr) => {
×
1669
                for v in arr {
×
1670
                    self.substitute_variables(v, variables);
×
1671
                }
×
1672
            }
1673
            Value::Object(obj) => {
×
1674
                for v in obj.values_mut() {
×
1675
                    self.substitute_variables(v, variables);
×
1676
                }
×
1677
            }
1678
            _ => {}
×
1679
        }
1680
    }
3✔
1681

1682
    fn run_assertions(
×
1683
        &self,
×
1684
        lines: &[String],
×
1685
        target_value: &Value,
×
1686
        failure_reasons: &mut Vec<String>,
×
1687
        context: String,
×
1688
        assertion_context: AssertionContext<'_>,
×
1689
    ) {
×
1690
        let mut optimized_lines: Option<Vec<String>> = None;
×
1691

1692
        for (idx, line) in lines.iter().enumerate() {
×
1693
            if let Some(rewritten) =
×
1694
                optimizer::rewrite_assertion_expression_fixed_point_if_changed(line)
×
1695
            {
1696
                let vec = optimized_lines.get_or_insert_with(|| lines[..idx].to_vec());
×
1697
                vec.push(rewritten);
×
1698
            } else if let Some(vec) = optimized_lines.as_mut() {
×
1699
                vec.push(line.clone());
×
1700
            }
×
1701
        }
1702

1703
        let lines_to_evaluate: &[String] = optimized_lines.as_deref().unwrap_or(lines);
×
1704

1705
        // Use AssertionHandler for assertion evaluation
1706
        let result = self.assertion_handler.evaluate_assertions_for_section(
×
1707
            lines_to_evaluate,
×
1708
            target_value,
×
1709
            assertion_context.headers,
×
1710
            assertion_context.trailers,
×
1711
            &context,
×
1712
            assertion_context.timing,
×
1713
        );
1714

1715
        if !result.passed {
×
1716
            failure_reasons.extend(result.failure_messages);
×
1717
        }
×
1718
    }
×
1719

1720
    /// Format JSON comparison diffs and append to failure_reasons.
NEW
1721
    fn append_response_diffs(
×
NEW
1722
        &self,
×
NEW
1723
        diffs: Vec<crate::assert::AssertionResult>,
×
NEW
1724
        section_line: usize,
×
NEW
1725
        expected: &Value,
×
NEW
1726
        actual: &Value,
×
NEW
1727
        failure_reasons: &mut Vec<String>,
×
NEW
1728
    ) {
×
NEW
1729
        failure_reasons.push(format!("Response mismatch at line {}:", section_line));
×
NEW
1730
        for diff in diffs {
×
NEW
1731
            match diff {
×
1732
                crate::assert::AssertionResult::Fail {
NEW
1733
                    message,
×
NEW
1734
                    expected: exp,
×
NEW
1735
                    actual: act,
×
1736
                } => {
NEW
1737
                    let mut msg = format!("  - {}", message);
×
NEW
1738
                    if let (Some(e), Some(a)) = (exp, act) {
×
NEW
1739
                        msg.push_str(&format!("\n      Expected: {}\n      Actual:   {}", e, a));
×
NEW
1740
                    }
×
NEW
1741
                    failure_reasons.push(msg);
×
1742
                }
NEW
1743
                crate::assert::AssertionResult::Error(m) => {
×
NEW
1744
                    failure_reasons.push(format!("  - Error: {}", m));
×
NEW
1745
                }
×
NEW
1746
                _ => {}
×
1747
            }
1748
        }
NEW
1749
        failure_reasons.push(get_json_diff(expected, actual));
×
NEW
1750
    }
×
1751

1752
    /// Log a response message for debug/verbose/raw modes.
NEW
1753
    fn log_response_message(msg: &Value, effective_no_assert: bool, verbose: bool) {
×
NEW
1754
        let should_format =
×
NEW
1755
            tracing::enabled!(tracing::Level::DEBUG) || effective_no_assert || verbose;
×
NEW
1756
        if !should_format {
×
NEW
1757
            return;
×
NEW
1758
        }
×
NEW
1759
        let pretty = runner_helpers::format_json_pretty(msg);
×
NEW
1760
        if tracing::enabled!(tracing::Level::DEBUG) {
×
NEW
1761
            tracing::debug!("Received Response:\n{}", pretty);
×
NEW
1762
        }
×
NEW
1763
        if effective_no_assert {
×
NEW
1764
            println!("--- RESPONSE (Raw) ---\n{}", pretty);
×
NEW
1765
        } else if verbose {
×
NEW
1766
            println!("🔍 gRPC response received: '{}'", pretty);
×
NEW
1767
        }
×
NEW
1768
    }
×
1769

1770
    /// Print dry-run preview of test execution
1771
    fn print_dry_run_preview(
1✔
1772
        &self,
1✔
1773
        document: &GctfDocument,
1✔
1774
        address: &str,
1✔
1775
        package: &str,
1✔
1776
        service: &str,
1✔
1777
        method: &str,
1✔
1778
    ) {
1✔
1779
        println!();
1✔
1780
        println!("🔍 Dry-Run Preview: {}", document.file_path);
1✔
1781
        println!("═══════════════════════════════════════════════════════════════");
1✔
1782
        println!();
1✔
1783
        println!("📍 Target:");
1✔
1784
        println!("   Address: {}", address);
1✔
1785
        let full_service = runner_helpers::full_service_name(package, service);
1✔
1786
        println!("   Endpoint: {} / {}", full_service, method);
1✔
1787
        println!();
1✔
1788

1789
        // Display headers first
1790
        let mut has_headers = false;
1✔
1791
        for section in &document.sections {
3✔
1792
            if section.section_type == SectionType::RequestHeaders {
3✔
1793
                if !has_headers {
×
1794
                    println!();
×
1795
                    println!("📋 Request Headers:");
×
1796
                    has_headers = true;
×
1797
                }
×
1798
                if let SectionContent::KeyValues(headers) = &section.content {
×
1799
                    for (key, value) in headers {
×
1800
                        println!("   {}: {}", key, value);
×
1801
                    }
×
1802
                }
×
1803
            }
3✔
1804
        }
1805

1806
        // Group requests and responses to show flow
1807
        let mut has_request = false;
1✔
1808
        let mut has_asserts = false;
1✔
1809
        let mut has_error = false;
1✔
1810

1811
        for section in &document.sections {
3✔
1812
            match section.section_type {
3✔
1813
                SectionType::Address => {}
×
1814
                SectionType::Endpoint => {}
1✔
1815
                SectionType::RequestHeaders => {}
×
1816
                SectionType::Options => {}
×
1817
                SectionType::Tls => {}
×
1818
                SectionType::Proto => {}
×
1819
                SectionType::Request => {
1820
                    if !has_request {
1✔
1821
                        println!();
1✔
1822
                        println!("📤 Request/Response Flow:");
1✔
1823
                        has_request = true;
1✔
1824
                    }
1✔
1825
                    if let SectionContent::Json(json) = &section.content {
1✔
1826
                        let json_str = runner_helpers::format_json_pretty(json);
1✔
1827
                        println!("   ➤ REQUEST:");
1✔
1828
                        println!("     {}", json_str.replace('\n', "\n     "));
1✔
1829
                    }
1✔
1830
                }
1831
                SectionType::Response => {
1832
                    let with_asserts = if section.inline_options.with_asserts {
1✔
1833
                        " (with_asserts)"
×
1834
                    } else {
1835
                        ""
1✔
1836
                    };
1837
                    match &section.content {
1✔
1838
                        SectionContent::Json(json) => {
1✔
1839
                            let json_str = runner_helpers::format_json_pretty(json);
1✔
1840
                            println!(
1✔
1841
                                "   ↤ RESPONSE (Line {}):{}",
1✔
1842
                                section.start_line, with_asserts
1✔
1843
                            );
1✔
1844
                            println!("     {}", json_str.replace('\n', "\n     "));
1✔
1845
                        }
1✔
1846
                        SectionContent::JsonLines(values) => {
×
1847
                            println!(
×
1848
                                "   ↤ RESPONSE (Line {}, {} messages):{}",
1849
                                section.start_line,
1850
                                values.len(),
×
1851
                                with_asserts
1852
                            );
1853
                            for value in values {
×
NEW
1854
                                let json_str = runner_helpers::format_json_pretty(value);
×
1855
                                println!("     {}", json_str.replace('\n', "\n     "));
×
1856
                            }
×
1857
                        }
1858
                        _ => {}
×
1859
                    }
1860
                }
1861
                SectionType::Asserts => {
1862
                    if !has_asserts {
×
1863
                        println!();
×
1864
                        println!("✓ Assertions:");
×
1865
                        has_asserts = true;
×
1866
                    }
×
1867
                    if let SectionContent::Assertions(lines) = &section.content {
×
1868
                        for line in lines {
×
1869
                            println!("   . {}", line);
×
1870
                        }
×
1871
                    }
×
1872
                }
1873
                SectionType::Error => {
1874
                    if !has_error {
×
1875
                        println!();
×
1876
                        println!("❌ Expected Error:");
×
1877
                        has_error = true;
×
1878
                    }
×
1879
                    if let SectionContent::Json(json) = &section.content {
×
NEW
1880
                        let json_str = runner_helpers::format_json_pretty(json);
×
1881
                        println!("   {}", json_str);
×
1882
                    }
×
1883
                }
1884
                SectionType::Extract => {
1885
                    println!();
×
1886
                    println!("💾 Variables to Extract:");
×
1887
                    if let SectionContent::Extract(extractions) = &section.content {
×
1888
                        for (key, query) in extractions {
×
1889
                            println!("   {} -> {}", key, query);
×
1890
                        }
×
1891
                    }
×
1892
                }
NEW
1893
                SectionType::Meta => {}
×
1894
            }
1895
        }
1896

1897
        // Show TLS config if present (including environment defaults)
1898
        let tls_defaults = tls_env_defaults();
1✔
1899
        if let Some(tls_config) = document.get_tls_config_with_defaults(&tls_defaults) {
1✔
1900
            println!();
×
1901
            println!("🔒 TLS Configuration:");
×
1902
            if let Some(ca_cert) = tls_config
×
1903
                .get("ca_cert")
×
1904
                .or_else(|| tls_config.get("ca_file"))
×
1905
            {
×
1906
                println!("   CA Cert: {}", ca_cert);
×
1907
            }
×
1908
            if let Some(client_cert) = tls_config
×
1909
                .get("client_cert")
×
1910
                .or_else(|| tls_config.get("cert"))
×
1911
                .or_else(|| tls_config.get("cert_file"))
×
1912
            {
×
1913
                println!("   Client Cert: {}", client_cert);
×
1914
            }
×
1915
            if let Some(client_key) = tls_config
×
1916
                .get("client_key")
×
1917
                .or_else(|| tls_config.get("key"))
×
1918
                .or_else(|| tls_config.get("key_file"))
×
1919
            {
×
1920
                println!("   Client Key: {}", client_key);
×
1921
            }
×
1922
            if tls_config
×
1923
                .get("insecure")
×
1924
                .map(|s| parse_bool_flag(s))
×
1925
                .unwrap_or(false)
×
1926
            {
×
1927
                println!("   Insecure Skip Verify: true");
×
1928
            }
×
1929
        }
1✔
1930

1931
        // Show PROTO config if present
1932
        if let Some(proto_config) = document.get_proto_config() {
1✔
1933
            println!();
×
1934
            println!("📄 Proto Configuration:");
×
1935
            if proto_config.contains_key("descriptor") {
×
1936
                println!("   Descriptor: {}", proto_config.get("descriptor").unwrap());
×
1937
            }
×
1938
            if proto_config.contains_key("files") {
×
1939
                println!("   Proto Files: {}", proto_config.get("files").unwrap());
×
1940
            }
×
1941
        }
1✔
1942

1943
        println!();
1✔
1944
        println!("═══════════════════════════════════════════════════════════════");
1✔
1945
        println!();
1✔
1946
    }
1✔
1947
}
1948

1949
#[cfg(test)]
1950
mod tests {
1951
    use super::*;
1952
    use serde_json::json;
1953
    use std::sync::Mutex;
1954

1955
    static ENV_MUTEX: Mutex<()> = Mutex::new(());
1956

1957
    #[test]
1958
    fn test_test_runner_new() {
1✔
1959
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
1960
        assert!(!runner.dry_run);
1✔
1961
        assert_eq!(runner.timeout_seconds, 30);
1✔
1962
        assert!(!runner.no_assert);
1✔
1963
        assert!(!runner.write_mode);
1✔
1964
        assert!(!runner.verbose);
1✔
1965
    }
1✔
1966

1967
    #[test]
1968
    fn test_test_runner_with_dry_run() {
1✔
1969
        let runner = TestRunner::new(true, 30, false, false, false, None);
1✔
1970
        assert!(runner.dry_run);
1✔
1971
    }
1✔
1972

1973
    #[test]
1974
    fn test_test_runner_with_timeout() {
1✔
1975
        let runner = TestRunner::new(false, 60, false, false, false, None);
1✔
1976
        assert_eq!(runner.timeout_seconds, 60);
1✔
1977
    }
1✔
1978

1979
    #[test]
1980
    fn test_test_runner_with_no_assert() {
1✔
1981
        let runner = TestRunner::new(false, 30, true, false, false, None);
1✔
1982
        assert!(runner.no_assert);
1✔
1983
    }
1✔
1984

1985
    #[test]
1986
    fn test_test_runner_with_write_mode() {
1✔
1987
        let runner = TestRunner::new(false, 30, false, true, false, None);
1✔
1988
        assert!(runner.write_mode);
1✔
1989
    }
1✔
1990

1991
    #[test]
1992
    fn test_parse_bool_flag_truthy_values() {
1✔
1993
        assert!(parse_bool_flag("true"));
1✔
1994
        assert!(parse_bool_flag("1"));
1✔
1995
        assert!(parse_bool_flag("YES"));
1✔
1996
        assert!(parse_bool_flag("on"));
1✔
1997
    }
1✔
1998

1999
    #[test]
2000
    fn test_parse_bool_flag_falsy_values() {
1✔
2001
        assert!(!parse_bool_flag("false"));
1✔
2002
        assert!(!parse_bool_flag("0"));
1✔
2003
        assert!(!parse_bool_flag("off"));
1✔
2004
        assert!(!parse_bool_flag(""));
1✔
2005
    }
1✔
2006

2007
    #[test]
2008
    fn test_resolve_tls_path_from_env_uses_cwd() {
1✔
2009
        if !runtime::supports(runtime::Capability::IsolatedFsIo) {
1✔
2010
            return;
×
2011
        }
1✔
2012

2013
        let cwd = std::env::current_dir().unwrap();
1✔
2014
        let document_path = Path::new("tests/fixtures/sample.gctf");
1✔
2015
        let resolved = resolve_tls_path("certs/ca.crt", true, document_path);
1✔
2016
        assert_eq!(Path::new(&resolved), cwd.join("certs/ca.crt"));
1✔
2017
    }
1✔
2018

2019
    #[test]
2020
    fn test_resolve_tls_path_from_env_without_fs_capability_returns_relative() {
1✔
2021
        if runtime::supports(runtime::Capability::IsolatedFsIo) {
1✔
2022
            return;
1✔
2023
        }
×
2024

2025
        let document_path = Path::new("tests/fixtures/sample.gctf");
×
2026
        let resolved = resolve_tls_path("certs/ca.crt", true, document_path);
×
2027
        assert_eq!(resolved, "certs/ca.crt");
×
2028
    }
1✔
2029

2030
    #[test]
2031
    fn test_resolve_tls_path_from_document_uses_document_dir() {
1✔
2032
        let document_path = Path::new("tests/fixtures/sample.gctf");
1✔
2033
        let resolved = resolve_tls_path("certs/ca.crt", false, document_path);
1✔
2034
        assert_eq!(
1✔
2035
            Path::new(&resolved),
1✔
2036
            Path::new("tests/fixtures").join("certs").join("ca.crt")
1✔
2037
        );
2038
    }
1✔
2039

2040
    #[test]
2041
    fn test_tls_env_defaults_uses_grpctestify_prefix() {
1✔
2042
        let _guard = ENV_MUTEX.lock().unwrap();
1✔
2043

2044
        unsafe {
1✔
2045
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE, "/tmp/ca.pem");
1✔
2046
            std::env::set_var(
1✔
2047
                crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE,
1✔
2048
                "/tmp/cert.pem",
1✔
2049
            );
1✔
2050
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE, "/tmp/key.pem");
1✔
2051
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME, "localhost");
1✔
2052
        }
1✔
2053

2054
        let defaults = tls_env_defaults();
1✔
2055
        assert_eq!(defaults.get("ca_cert"), Some(&"/tmp/ca.pem".to_string()));
1✔
2056
        assert_eq!(
1✔
2057
            defaults.get("client_cert"),
1✔
2058
            Some(&"/tmp/cert.pem".to_string())
1✔
2059
        );
2060
        assert_eq!(
1✔
2061
            defaults.get("client_key"),
1✔
2062
            Some(&"/tmp/key.pem".to_string())
1✔
2063
        );
2064
        assert_eq!(defaults.get("server_name"), Some(&"localhost".to_string()));
1✔
2065

2066
        unsafe {
1✔
2067
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE);
1✔
2068
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE);
1✔
2069
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE);
1✔
2070
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME);
1✔
2071
        }
1✔
2072
    }
1✔
2073

2074
    #[test]
2075
    fn test_tls_env_defaults_ignores_empty_values() {
1✔
2076
        let _guard = ENV_MUTEX.lock().unwrap();
1✔
2077

2078
        unsafe {
1✔
2079
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE, "");
1✔
2080
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE, "   ");
1✔
2081
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE, "");
1✔
2082
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME, " ");
1✔
2083
        }
1✔
2084

2085
        let defaults = tls_env_defaults();
1✔
2086
        assert!(defaults.is_empty());
1✔
2087

2088
        unsafe {
1✔
2089
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_CA_FILE);
1✔
2090
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_CERT_FILE);
1✔
2091
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_KEY_FILE);
1✔
2092
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_TLS_SERVER_NAME);
1✔
2093
        }
1✔
2094
    }
1✔
2095

2096
    #[test]
2097
    fn test_test_runner_with_verbose() {
1✔
2098
        let runner = TestRunner::new(false, 30, false, false, true, None);
1✔
2099
        assert!(runner.verbose);
1✔
2100
    }
1✔
2101

2102
    #[test]
2103
    fn test_grpc_code_name_from_numeric() {
1✔
2104
        assert_eq!(TestRunner::grpc_code_name_from_numeric(0), Some("OK"));
1✔
2105
        assert_eq!(TestRunner::grpc_code_name_from_numeric(5), Some("NotFound"));
1✔
2106
        assert_eq!(
1✔
2107
            TestRunner::grpc_code_name_from_numeric(13),
1✔
2108
            Some("Internal")
2109
        );
2110
        assert_eq!(TestRunner::grpc_code_name_from_numeric(99), None);
1✔
2111
    }
1✔
2112

2113
    #[test]
2114
    fn test_error_matches_expected_message() {
1✔
2115
        let expected = json!({
1✔
2116
            "message": "Can't find stub",
1✔
2117
            "code": 5
1✔
2118
        });
2119
        let error_text = "status: NotFound, message: \"Can't find stub\"";
1✔
2120
        assert!(TestRunner::error_matches_expected(error_text, &expected));
1✔
2121
    }
1✔
2122

2123
    #[test]
2124
    fn test_error_matches_expected_code() {
1✔
2125
        let expected = json!({
1✔
2126
            "code": 5
1✔
2127
        });
2128
        let error_text = "status: NotFound, message: \"error\"";
1✔
2129
        assert!(TestRunner::error_matches_expected(error_text, &expected));
1✔
2130
    }
1✔
2131

2132
    #[test]
2133
    fn test_error_matches_expected_wrong_code() {
1✔
2134
        let expected = json!({
1✔
2135
            "code": 3
1✔
2136
        });
2137
        let error_text = "status: NotFound, message: \"error\"";
1✔
2138
        assert!(!TestRunner::error_matches_expected(error_text, &expected));
1✔
2139
    }
1✔
2140

2141
    #[test]
2142
    fn test_error_matches_expected_wrong_message() {
1✔
2143
        let expected = json!({
1✔
2144
            "message": "Different error"
1✔
2145
        });
2146
        let error_text = "status: NotFound, message: \"Can't find stub\"";
1✔
2147
        assert!(!TestRunner::error_matches_expected(error_text, &expected));
1✔
2148
    }
1✔
2149

2150
    #[test]
2151
    fn test_error_matches_expected_string() {
1✔
2152
        let expected = json!("Can't find stub");
1✔
2153
        let error_text = "status: NotFound, message: \"Can't find stub\"";
1✔
2154
        assert!(TestRunner::error_matches_expected(error_text, &expected));
1✔
2155
    }
1✔
2156

2157
    #[test]
2158
    fn test_full_service_name() {
1✔
2159
        assert_eq!(
1✔
2160
            runner_helpers::full_service_name("package", "Service"),
1✔
2161
            "package.Service"
2162
        );
2163
        assert_eq!(runner_helpers::full_service_name("", "Service"), "Service");
1✔
2164
    }
1✔
2165

2166
    #[test]
2167
    fn test_substitute_variables_exact_match_preserves_type() {
1✔
2168
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
2169
        let mut value = json!("{{ count }}");
1✔
2170
        let mut vars = HashMap::new();
1✔
2171
        vars.insert("count".to_string(), json!(42));
1✔
2172

2173
        runner.substitute_variables(&mut value, &vars);
1✔
2174
        assert_eq!(value, json!(42));
1✔
2175
    }
1✔
2176

2177
    #[test]
2178
    fn test_substitute_variables_interpolation_single_pass() {
1✔
2179
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
2180
        let mut value = json!("id={{id}}, user={{ user }}, ok={{ok}}");
1✔
2181
        let mut vars = HashMap::new();
1✔
2182
        vars.insert("id".to_string(), json!(7));
1✔
2183
        vars.insert("user".to_string(), json!("alice"));
1✔
2184
        vars.insert("ok".to_string(), json!(true));
1✔
2185

2186
        runner.substitute_variables(&mut value, &vars);
1✔
2187
        assert_eq!(value, json!("id=7, user=alice, ok=true"));
1✔
2188
    }
1✔
2189

2190
    #[test]
2191
    fn test_substitute_variables_keeps_unknown_placeholder() {
1✔
2192
        let runner = TestRunner::new(false, 30, false, false, false, None);
1✔
2193
        let mut value = json!("hello {{known}} and {{unknown}}");
1✔
2194
        let mut vars = HashMap::new();
1✔
2195
        vars.insert("known".to_string(), json!("world"));
1✔
2196

2197
        runner.substitute_variables(&mut value, &vars);
1✔
2198
        assert_eq!(value, json!("hello world and {{unknown}}"));
1✔
2199
    }
1✔
2200

2201
    #[test]
2202
    fn test_expected_values_for_response_section() {
1✔
2203
        use crate::parser::ast::{InlineOptions, Section, SectionContent};
2204

2205
        let section = Section {
1✔
2206
            section_type: crate::parser::ast::SectionType::Response,
1✔
2207
            content: SectionContent::Json(json!({"key": "value"})),
1✔
2208
            inline_options: InlineOptions::default(),
1✔
2209
            raw_content: "".to_string(),
1✔
2210
            start_line: 1,
1✔
2211
            end_line: 2,
1✔
2212
        };
1✔
2213

2214
        let values = TestRunner::expected_values_for_response_section(&section);
1✔
2215
        assert_eq!(values.len(), 1);
1✔
2216
        assert_eq!(values[0], json!({"key": "value"}));
1✔
2217
    }
1✔
2218

2219
    #[test]
2220
    fn test_expected_values_for_json_lines() {
1✔
2221
        use crate::parser::ast::{InlineOptions, Section, SectionContent};
2222

2223
        let section = Section {
1✔
2224
            section_type: crate::parser::ast::SectionType::Response,
1✔
2225
            content: SectionContent::JsonLines(vec![
1✔
2226
                json!({"key1": "value1"}),
1✔
2227
                json!({"key2": "value2"}),
1✔
2228
            ]),
1✔
2229
            inline_options: InlineOptions::default(),
1✔
2230
            raw_content: "".to_string(),
1✔
2231
            start_line: 1,
1✔
2232
            end_line: 3,
1✔
2233
        };
1✔
2234

2235
        let values = TestRunner::expected_values_for_response_section(&section);
1✔
2236
        assert_eq!(values.len(), 2);
1✔
2237
    }
1✔
2238

2239
    #[test]
2240
    fn test_expected_values_for_other_section() {
1✔
2241
        use crate::parser::ast::{InlineOptions, Section, SectionContent, SectionType};
2242

2243
        // The function returns values for any Json content, not just Response sections
2244
        // This is expected behavior - it extracts Json values regardless of section type
2245
        let section = Section {
1✔
2246
            section_type: SectionType::Request,
1✔
2247
            content: SectionContent::Json(json!({"key": "value"})),
1✔
2248
            inline_options: InlineOptions::default(),
1✔
2249
            raw_content: "".to_string(),
1✔
2250
            start_line: 1,
1✔
2251
            end_line: 2,
1✔
2252
        };
1✔
2253

2254
        let values = TestRunner::expected_values_for_response_section(&section);
1✔
2255
        // Returns 1 because the content is Json, even though it's a Request section
2256
        assert_eq!(values.len(), 1);
1✔
2257
        assert_eq!(values[0], json!({"key": "value"}));
1✔
2258
    }
1✔
2259

2260
    #[test]
2261
    fn test_metadata_map_to_hashmap_extracts_ascii_values() {
1✔
2262
        let mut metadata = tonic::metadata::MetadataMap::new();
1✔
2263
        metadata.insert("code", "EXTERNAL_SERVICE_ERROR_CODE".parse().unwrap());
1✔
2264
        metadata.insert("message", "External service error message".parse().unwrap());
1✔
2265

2266
        let trailers = runner_helpers::metadata_map_to_hashmap(&metadata);
1✔
2267
        assert_eq!(
1✔
2268
            trailers.get("code"),
1✔
2269
            Some(&"EXTERNAL_SERVICE_ERROR_CODE".to_string())
1✔
2270
        );
2271
        assert_eq!(
1✔
2272
            trailers.get("message"),
1✔
2273
            Some(&"External service error message".to_string())
1✔
2274
        );
2275
    }
1✔
2276

2277
    #[test]
2278
    fn test_assertion_scope_timing_single_message_scope() {
1✔
2279
        let mut timing = AssertionScopeTimingState::default();
1✔
2280

2281
        let first = timing.finish_scope(0, 12, 1).unwrap();
1✔
2282

2283
        assert_eq!(first.elapsed_ms, 12);
1✔
2284
        assert_eq!(first.total_elapsed_ms, 12);
1✔
2285
        assert_eq!(first.scope_message_count, 1);
1✔
2286
        assert_eq!(first.scope_index, 1);
1✔
2287
    }
1✔
2288

2289
    #[test]
2290
    fn test_assertion_scope_timing_batch_scope_uses_full_section_window() {
1✔
2291
        let mut timing = AssertionScopeTimingState::default();
1✔
2292

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

2295
        assert_eq!(batch.elapsed_ms, 27);
1✔
2296
        assert_eq!(batch.total_elapsed_ms, 27);
1✔
2297
        assert_eq!(batch.scope_message_count, 2);
1✔
2298
        assert_eq!(batch.scope_index, 1);
1✔
2299
    }
1✔
2300

2301
    #[test]
2302
    fn test_assertion_scope_timing_accumulates_total_duration() {
1✔
2303
        let mut timing = AssertionScopeTimingState::default();
1✔
2304

2305
        let first = timing.finish_scope(0, 10, 1).unwrap();
1✔
2306
        let second = timing.finish_scope(10, 35, 3).unwrap();
1✔
2307

2308
        assert_eq!(first.elapsed_ms, 10);
1✔
2309
        assert_eq!(first.total_elapsed_ms, 10);
1✔
2310
        assert_eq!(second.elapsed_ms, 25);
1✔
2311
        assert_eq!(second.total_elapsed_ms, 35);
1✔
2312
        assert_eq!(second.scope_message_count, 3);
1✔
2313
        assert_eq!(second.scope_index, 2);
1✔
2314
    }
1✔
2315
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc