66//! These tests spawn the pet server as a subprocess and communicate via JSONRPC
77//! to measure discovery performance from a client perspective.
88
9+ use pet_core:: telemetry:: refresh_progress:: {
10+ RefreshProgress , RefreshProgressPhase , RefreshProgressStatus ,
11+ } ;
912use serde:: Deserialize ;
1013use serde_json:: { json, Value } ;
11- use std:: collections:: { HashMap , VecDeque } ;
14+ use std:: collections:: { BTreeMap , HashMap , VecDeque } ;
1215use std:: env;
1316use std:: io:: { BufRead , BufReader , Read , Write } ;
1417use std:: path:: PathBuf ;
@@ -199,6 +202,7 @@ pub struct Manager {
199202struct SharedState {
200203 environments : Mutex < Vec < Environment > > ,
201204 managers : Mutex < Vec < Manager > > ,
205+ refresh_progress : Mutex < Vec < RefreshProgress > > ,
202206 first_env_time : Mutex < Option < Instant > > ,
203207}
204208
@@ -207,6 +211,7 @@ impl SharedState {
207211 Self {
208212 environments : Mutex :: new ( Vec :: new ( ) ) ,
209213 managers : Mutex :: new ( Vec :: new ( ) ) ,
214+ refresh_progress : Mutex :: new ( Vec :: new ( ) ) ,
210215 first_env_time : Mutex :: new ( None ) ,
211216 }
212217 }
@@ -231,9 +236,23 @@ impl SharedState {
231236 self . managers . lock ( ) . unwrap ( ) . push ( mgr) ;
232237 }
233238 }
234- "log" | "telemetry" => {
235- // Ignore log and telemetry notifications
239+ "telemetry" => {
240+ if params. get ( "event" ) . and_then ( Value :: as_str) == Some ( "RefreshProgress" ) {
241+ if let Some ( progress) = params
242+ . get ( "data" )
243+ . and_then ( |data| data. get ( "refreshProgress" ) )
244+ . and_then ( |value| {
245+ serde_json:: from_value :: < RefreshProgress > ( value. clone ( ) ) . ok ( )
246+ } )
247+ {
248+ self . refresh_progress
249+ . lock ( )
250+ . expect ( "refresh progress mutex poisoned" )
251+ . push ( progress) ;
252+ }
253+ }
236254 }
255+ "log" => { }
237256 _ => {
238257 // Unknown notification
239258 }
@@ -243,6 +262,10 @@ impl SharedState {
243262 fn clear ( & self ) {
244263 self . environments . lock ( ) . unwrap ( ) . clear ( ) ;
245264 self . managers . lock ( ) . unwrap ( ) . clear ( ) ;
265+ self . refresh_progress
266+ . lock ( )
267+ . expect ( "refresh progress mutex poisoned" )
268+ . clear ( ) ;
246269 * self . first_env_time . lock ( ) . unwrap ( ) = None ;
247270 }
248271}
@@ -417,6 +440,14 @@ impl PetClient {
417440 self . state . managers . lock ( ) . unwrap ( ) . clone ( )
418441 }
419442
443+ fn get_refresh_progress ( & self ) -> Vec < RefreshProgress > {
444+ self . state
445+ . refresh_progress
446+ . lock ( )
447+ . expect ( "refresh progress mutex poisoned" )
448+ . clone ( )
449+ }
450+
420451 /// Get time from start to first environment
421452 pub fn time_to_first_env ( & self ) -> Option < Duration > {
422453 self . state
@@ -594,6 +625,102 @@ fn stderr_reader_drains_input_and_bounds_diagnostic_tail() {
594625 assert_eq ! ( tail. back( ) . map( String :: as_str) , Some ( "line 104" ) ) ;
595626}
596627
628+ fn refresh_phase_name ( phase : RefreshProgressPhase ) -> & ' static str {
629+ match phase {
630+ RefreshProgressPhase :: Locators => "locators" ,
631+ RefreshProgressPhase :: Path => "path" ,
632+ RefreshProgressPhase :: GlobalVirtualEnvs => "globalVirtualEnvs" ,
633+ RefreshProgressPhase :: Workspaces => "workspaces" ,
634+ }
635+ }
636+
637+ fn collect_refresh_progress (
638+ progress : & [ RefreshProgress ] ,
639+ phase_stats : & mut BTreeMap < String , StatisticalMetrics > ,
640+ locator_stats : & mut BTreeMap < String , StatisticalMetrics > ,
641+ ) {
642+ for event in progress
643+ . iter ( )
644+ . filter ( |event| event. status == RefreshProgressStatus :: Completed )
645+ {
646+ if let ( Some ( locator) , Some ( duration) ) = ( & event. locator_name , event. locator_elapsed_ms ) {
647+ locator_stats
648+ . entry ( locator. clone ( ) )
649+ . or_default ( )
650+ . add ( duration) ;
651+ } else if let Some ( duration) = event. phase_elapsed_ms {
652+ phase_stats
653+ . entry ( refresh_phase_name ( event. phase ) . to_string ( ) )
654+ . or_default ( )
655+ . add ( duration) ;
656+ }
657+ }
658+ }
659+
660+ fn statistics_json ( statistics : & BTreeMap < String , StatisticalMetrics > ) -> BTreeMap < String , Value > {
661+ statistics
662+ . iter ( )
663+ . map ( |( name, metrics) | ( name. clone ( ) , metrics. to_json ( ) ) )
664+ . collect ( )
665+ }
666+
667+ #[ test]
668+ fn refresh_progress_notifications_are_collected ( ) {
669+ let state = SharedState :: new ( ) ;
670+ state. handle_notification (
671+ "telemetry" ,
672+ json ! ( {
673+ "event" : "RefreshProgress" ,
674+ "data" : {
675+ "refreshProgress" : {
676+ "refreshId" : 7 ,
677+ "phase" : "locators" ,
678+ "status" : "completed" ,
679+ "elapsedMs" : 25 ,
680+ "locatorName" : "Conda" ,
681+ "locatorElapsedMs" : 20
682+ }
683+ }
684+ } ) ,
685+ ) ;
686+
687+ let progress = state. refresh_progress . lock ( ) . unwrap ( ) ;
688+ assert_eq ! ( progress. len( ) , 1 ) ;
689+ assert_eq ! ( progress[ 0 ] . locator_name. as_deref( ) , Some ( "Conda" ) ) ;
690+ assert_eq ! ( progress[ 0 ] . locator_elapsed_ms, Some ( 20 ) ) ;
691+ }
692+
693+ #[ test]
694+ fn refresh_progress_aggregation_separates_phases_and_locators ( ) {
695+ let progress = vec ! [
696+ RefreshProgress {
697+ refresh_id: 1 ,
698+ phase: RefreshProgressPhase :: Locators ,
699+ status: RefreshProgressStatus :: Completed ,
700+ elapsed_ms: 30 ,
701+ phase_elapsed_ms: Some ( 30 ) ,
702+ locator_name: None ,
703+ locator_elapsed_ms: None ,
704+ } ,
705+ RefreshProgress {
706+ refresh_id: 1 ,
707+ phase: RefreshProgressPhase :: Locators ,
708+ status: RefreshProgressStatus :: Completed ,
709+ elapsed_ms: 25 ,
710+ phase_elapsed_ms: None ,
711+ locator_name: Some ( "Conda" . to_string( ) ) ,
712+ locator_elapsed_ms: Some ( 20 ) ,
713+ } ,
714+ ] ;
715+ let mut phases = BTreeMap :: new ( ) ;
716+ let mut locators = BTreeMap :: new ( ) ;
717+
718+ collect_refresh_progress ( & progress, & mut phases, & mut locators) ;
719+
720+ assert_eq ! ( phases[ "locators" ] . samples, vec![ 30 ] ) ;
721+ assert_eq ! ( locators[ "Conda" ] . samples, vec![ 20 ] ) ;
722+ }
723+
597724// ============================================================================
598725// Performance Tests
599726// ============================================================================
@@ -1083,6 +1210,8 @@ fn test_performance_summary() {
10831210 let mut startup_stats = StatisticalMetrics :: new ( ) ;
10841211 let mut refresh_stats = StatisticalMetrics :: new ( ) ;
10851212 let mut time_to_first_env_stats = StatisticalMetrics :: new ( ) ;
1213+ let mut phase_stats = BTreeMap :: new ( ) ;
1214+ let mut locator_stats = BTreeMap :: new ( ) ;
10861215 let mut env_count = 0usize ;
10871216 let mut manager_count = 0usize ;
10881217
@@ -1120,6 +1249,11 @@ fn test_performance_summary() {
11201249 if let Some ( ttfe) = client. time_to_first_env ( ) {
11211250 time_to_first_env_stats. add ( ttfe. as_millis ( ) ) ;
11221251 }
1252+ collect_refresh_progress (
1253+ & client. get_refresh_progress ( ) ,
1254+ & mut phase_stats,
1255+ & mut locator_stats,
1256+ ) ;
11231257
11241258 println ! (
11251259 " Iteration {}: startup={}ms, refresh={}ms, envs={}" ,
@@ -1130,6 +1264,21 @@ fn test_performance_summary() {
11301264 ) ;
11311265 }
11321266
1267+ for phase in [ "locators" , "path" , "globalVirtualEnvs" , "workspaces" ] {
1268+ let count = phase_stats
1269+ . get ( phase)
1270+ . map ( StatisticalMetrics :: count)
1271+ . unwrap_or_default ( ) ;
1272+ assert_eq ! (
1273+ count, STAT_ITERATIONS ,
1274+ "Expected one completed {phase} phase per refresh iteration"
1275+ ) ;
1276+ }
1277+ assert ! (
1278+ !locator_stats. is_empty( ) ,
1279+ "Expected per-locator timing in RefreshProgress telemetry"
1280+ ) ;
1281+
11331282 // Print statistical summary
11341283 println ! ( "\n ----------------------------------------" ) ;
11351284 println ! ( " STATISTICS " ) ;
@@ -1139,10 +1288,19 @@ fn test_performance_summary() {
11391288 if time_to_first_env_stats. count ( ) > 0 {
11401289 time_to_first_env_stats. print_summary ( "Time to first env" ) ;
11411290 }
1291+ for ( phase, metrics) in & phase_stats {
1292+ metrics. print_summary ( & format ! ( "Phase {phase}" ) ) ;
1293+ }
1294+ for ( locator, metrics) in & locator_stats {
1295+ metrics. print_summary ( & format ! ( "Locator {locator}" ) ) ;
1296+ }
11421297 println ! ( "Environments found: {}" , env_count) ;
11431298 println ! ( "Managers found: {}" , manager_count) ;
11441299 println ! ( "========================================\n " ) ;
11451300
1301+ let phase_json = statistics_json ( & phase_stats) ;
1302+ let locator_json = statistics_json ( & locator_stats) ;
1303+
11461304 // Output as JSON for CI parsing
11471305 // Includes both P50 values at top level (for backwards compatibility) and full stats
11481306 let json_output = serde_json:: to_string_pretty ( & json ! ( {
@@ -1155,7 +1313,9 @@ fn test_performance_summary() {
11551313 "server_startup" : startup_stats. to_json( ) ,
11561314 "full_refresh" : refresh_stats. to_json( ) ,
11571315 "time_to_first_env" : time_to_first_env_stats. to_json( )
1158- }
1316+ } ,
1317+ "phases" : phase_json,
1318+ "locators" : locator_json
11591319 } ) )
11601320 . unwrap ( ) ;
11611321
0 commit comments