Skip to main content

victauri_test/
coverage.rs

1//! IPC coverage tracking — measures which Tauri commands are exercised by tests.
2//!
3//! Compares the set of registered commands (from the registry) against the set
4//! of IPC calls observed during a test session. Reports tested vs. untested
5//! commands, call counts, and coverage percentage.
6
7use serde_json::Value;
8
9use crate::client::VictauriClient;
10use crate::error::TestError;
11
12/// IPC coverage report showing which commands were exercised.
13#[derive(Debug)]
14pub struct CoverageReport {
15    /// Total number of registered commands.
16    pub total_commands: usize,
17    /// Number of commands invoked at least once.
18    pub tested_commands: usize,
19    /// Coverage percentage (0.0 to 100.0).
20    pub coverage_percentage: f64,
21    /// Commands that were never invoked during the session.
22    pub untested: Vec<String>,
23    /// Commands sorted by invocation count (descending).
24    pub most_called: Vec<CommandCalls>,
25}
26
27/// A command name with its invocation count.
28#[derive(Debug)]
29pub struct CommandCalls {
30    /// Name of the Tauri command.
31    pub name: String,
32    /// Number of times invoked during the session.
33    pub calls: usize,
34}
35
36impl CoverageReport {
37    /// Returns true if coverage meets or exceeds the given threshold.
38    #[must_use]
39    pub fn meets_threshold(&self, threshold_percent: f64) -> bool {
40        self.coverage_percentage >= threshold_percent
41    }
42
43    /// Formats the report for human-readable output.
44    #[must_use]
45    pub fn to_summary(&self) -> String {
46        let mut out = String::with_capacity(512);
47        out.push_str(&format!(
48            "IPC Coverage: {:.1}% ({}/{} commands tested)\n",
49            self.coverage_percentage, self.tested_commands, self.total_commands
50        ));
51
52        if !self.most_called.is_empty() {
53            out.push_str("\nMost called:\n");
54            for cmd in self.most_called.iter().take(10) {
55                out.push_str(&format!("  {:>4}x  {}\n", cmd.calls, cmd.name));
56            }
57        }
58
59        if !self.untested.is_empty() {
60            out.push_str(&format!("\nUntested ({}):\n", self.untested.len()));
61            for name in self.untested.iter().take(20) {
62                out.push_str(&format!("  - {name}\n"));
63            }
64            if self.untested.len() > 20 {
65                out.push_str(&format!("  ... and {} more\n", self.untested.len() - 20));
66            }
67        }
68
69        out
70    }
71}
72
73/// Builds a coverage report by comparing the command registry against IPC call logs.
74///
75/// Queries the running app for its command registry and IPC call history,
76/// then computes which commands were exercised and which remain untested.
77///
78/// # Errors
79///
80/// Returns errors from the underlying MCP tool calls.
81pub async fn coverage_report(client: &mut VictauriClient) -> Result<CoverageReport, TestError> {
82    let registry = client.get_registry().await?;
83    let ipc_log = client.get_ipc_log(None).await?;
84
85    let registered: Vec<String> = extract_command_names(&registry);
86    let called: Vec<String> = extract_ipc_commands(&ipc_log);
87
88    build_report(&registered, &called)
89}
90
91/// Asserts that IPC coverage meets the given threshold, panicking with a
92/// detailed report if it does not.
93///
94/// # Errors
95///
96/// Returns errors from the underlying MCP tool calls.
97///
98/// # Panics
99///
100/// Panics if coverage falls below `threshold_percent`.
101pub async fn assert_coverage_above(
102    client: &mut VictauriClient,
103    threshold_percent: f64,
104) -> Result<(), TestError> {
105    let report = coverage_report(client).await?;
106    assert!(
107        report.meets_threshold(threshold_percent),
108        "IPC coverage {:.1}% is below threshold {:.1}%\n{}",
109        report.coverage_percentage,
110        threshold_percent,
111        report.to_summary()
112    );
113    Ok(())
114}
115
116fn extract_command_names(registry: &Value) -> Vec<String> {
117    if let Some(arr) = registry.as_array() {
118        arr.iter()
119            .filter_map(|v| v.get("name").and_then(Value::as_str).map(String::from))
120            .collect()
121    } else if let Some(commands) = registry.get("commands").and_then(Value::as_array) {
122        commands
123            .iter()
124            .filter_map(|v| v.get("name").and_then(Value::as_str).map(String::from))
125            .collect()
126    } else {
127        Vec::new()
128    }
129}
130
131fn extract_ipc_commands(ipc_log: &Value) -> Vec<String> {
132    if let Some(arr) = ipc_log.as_array() {
133        arr.iter()
134            .filter_map(|v| v.get("command").and_then(Value::as_str).map(String::from))
135            .collect()
136    } else {
137        Vec::new()
138    }
139}
140
141fn build_report(registered: &[String], called: &[String]) -> Result<CoverageReport, TestError> {
142    let mut call_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
143    for cmd in called {
144        let name = cmd
145            .strip_prefix("plugin:")
146            .and_then(|s| s.split('|').nth(1))
147            .unwrap_or(cmd);
148        *call_counts.entry(name).or_default() += 1;
149    }
150
151    let total_commands = registered.len();
152    let mut tested = 0;
153    let mut untested = Vec::new();
154    let mut most_called: Vec<CommandCalls> = Vec::new();
155
156    for name in registered {
157        let clean = name
158            .strip_prefix("plugin:")
159            .and_then(|s| s.split('|').nth(1))
160            .unwrap_or(name);
161        if let Some(&count) = call_counts.get(clean) {
162            tested += 1;
163            most_called.push(CommandCalls {
164                name: clean.to_string(),
165                calls: count,
166            });
167        } else {
168            untested.push(clean.to_string());
169        }
170    }
171
172    most_called.sort_by_key(|c| std::cmp::Reverse(c.calls));
173    untested.sort();
174
175    // An empty registry has NOTHING to cover, so report 0% rather than a
176    // misleading 100% "fully covered" — reading a no-commands run as success is a
177    // false positive (red-team P3). The CLI surfaces this as an explicit
178    // "no commands registered" warning (and exits non-zero unless
179    // `--allow-empty-registry`); callers of the library can check
180    // `total_commands == 0` to distinguish "unmeasurable" from "0% covered".
181    let coverage_percentage = if total_commands == 0 {
182        0.0
183    } else {
184        (tested as f64 / total_commands as f64) * 100.0
185    };
186
187    Ok(CoverageReport {
188        total_commands,
189        tested_commands: tested,
190        coverage_percentage,
191        untested,
192        most_called,
193    })
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn build_report_full_coverage() {
202        let registered = vec!["cmd_a".to_string(), "cmd_b".to_string()];
203        let called = vec![
204            "cmd_a".to_string(),
205            "cmd_b".to_string(),
206            "cmd_a".to_string(),
207        ];
208
209        let report = build_report(&registered, &called).unwrap();
210        assert_eq!(report.total_commands, 2);
211        assert_eq!(report.tested_commands, 2);
212        assert_eq!(report.coverage_percentage, 100.0);
213        assert!(report.untested.is_empty());
214        assert_eq!(report.most_called[0].name, "cmd_a");
215        assert_eq!(report.most_called[0].calls, 2);
216    }
217
218    #[test]
219    fn build_report_partial_coverage() {
220        let registered = vec![
221            "cmd_a".to_string(),
222            "cmd_b".to_string(),
223            "cmd_c".to_string(),
224        ];
225        let called = vec!["cmd_a".to_string()];
226
227        let report = build_report(&registered, &called).unwrap();
228        assert_eq!(report.tested_commands, 1);
229        assert!((report.coverage_percentage - 33.333).abs() < 0.01);
230        assert_eq!(report.untested.len(), 2);
231        assert!(report.untested.contains(&"cmd_b".to_string()));
232        assert!(report.untested.contains(&"cmd_c".to_string()));
233    }
234
235    #[test]
236    fn build_report_no_commands() {
237        // No registered commands = nothing measurable = 0%, NOT a false 100%.
238        let report = build_report(&[], &[]).unwrap();
239        assert_eq!(report.coverage_percentage, 0.0);
240        assert_eq!(report.total_commands, 0);
241    }
242
243    #[test]
244    fn build_report_strips_plugin_prefix() {
245        let registered = vec!["save_data".to_string()];
246        let called = vec!["plugin:myapp|save_data".to_string()];
247
248        let report = build_report(&registered, &called).unwrap();
249        assert_eq!(report.tested_commands, 1);
250        assert_eq!(report.coverage_percentage, 100.0);
251    }
252
253    #[test]
254    fn meets_threshold_boundary() {
255        let report = CoverageReport {
256            total_commands: 10,
257            tested_commands: 8,
258            coverage_percentage: 80.0,
259            untested: vec!["a".to_string(), "b".to_string()],
260            most_called: vec![],
261        };
262        assert!(report.meets_threshold(80.0));
263        assert!(!report.meets_threshold(80.1));
264    }
265
266    #[test]
267    fn summary_formatting() {
268        let report = CoverageReport {
269            total_commands: 3,
270            tested_commands: 1,
271            coverage_percentage: 33.3,
272            untested: vec!["cmd_b".to_string(), "cmd_c".to_string()],
273            most_called: vec![CommandCalls {
274                name: "cmd_a".to_string(),
275                calls: 5,
276            }],
277        };
278        let summary = report.to_summary();
279
280        assert!(summary.contains("33.3%"));
281        assert!(summary.contains("1/3"));
282        assert!(summary.contains("cmd_a"));
283        assert!(summary.contains("cmd_b"));
284        assert!(summary.contains("Untested (2)"));
285    }
286
287    #[test]
288    fn extract_command_names_from_array() {
289        let registry = serde_json::json!([
290            {"name": "cmd_a", "description": "A"},
291            {"name": "cmd_b", "description": "B"}
292        ]);
293        let names = extract_command_names(&registry);
294        assert_eq!(names, vec!["cmd_a", "cmd_b"]);
295    }
296
297    #[test]
298    fn extract_command_names_from_commands_field() {
299        let registry = serde_json::json!({
300            "commands": [
301                {"name": "cmd_x"},
302                {"name": "cmd_y"}
303            ]
304        });
305        let names = extract_command_names(&registry);
306        assert_eq!(names, vec!["cmd_x", "cmd_y"]);
307    }
308
309    #[test]
310    fn extract_ipc_commands_from_log() {
311        let log = serde_json::json!([
312            {"command": "greet", "status": "ok"},
313            {"command": "save", "status": "ok"}
314        ]);
315        let cmds = extract_ipc_commands(&log);
316        assert_eq!(cmds, vec!["greet", "save"]);
317    }
318
319    #[test]
320    fn meets_threshold_exact_boundary() {
321        let report = build_report(&["a".to_string(), "b".to_string()], &["a".to_string()]).unwrap();
322        // 1 out of 2 = 50.0%
323        assert!(report.meets_threshold(50.0));
324        assert!(!report.meets_threshold(50.1));
325    }
326
327    #[test]
328    fn summary_includes_all_sections() {
329        let report = build_report(
330            &[
331                "cmd_a".to_string(),
332                "cmd_b".to_string(),
333                "cmd_c".to_string(),
334            ],
335            &["cmd_a".to_string(), "cmd_a".to_string()],
336        )
337        .unwrap();
338        let summary = report.to_summary();
339        assert!(summary.contains("IPC Coverage:"));
340        assert!(summary.contains("Most called:"));
341        assert!(summary.contains("Untested"));
342        assert!(summary.contains("cmd_a"));
343        assert!(summary.contains("cmd_b"));
344        assert!(summary.contains("cmd_c"));
345    }
346
347    #[test]
348    fn extract_command_names_empty_object() {
349        let registry = serde_json::json!({});
350        let names = extract_command_names(&registry);
351        assert!(names.is_empty());
352    }
353
354    #[test]
355    fn extract_command_names_null_input() {
356        let registry = serde_json::json!(null);
357        let names = extract_command_names(&registry);
358        assert!(names.is_empty());
359    }
360
361    #[test]
362    fn extract_ipc_commands_empty_array() {
363        let log = serde_json::json!([]);
364        let cmds = extract_ipc_commands(&log);
365        assert!(cmds.is_empty());
366    }
367
368    #[test]
369    fn extract_ipc_commands_non_array() {
370        let log = serde_json::json!("not an array");
371        let cmds = extract_ipc_commands(&log);
372        assert!(cmds.is_empty());
373    }
374}