@@ -24,7 +24,7 @@ use egui::mutex::Mutex;
2424use crate :: inspector_api:: {
2525 Frame , HarnessMessage , InspectorCommand , SourceView , read_message, write_message,
2626} ;
27- use crate :: { Harness , Plugin } ;
27+ use crate :: { Harness , Plugin , TestResult } ;
2828
2929/// Environment variable: when set to a truthy value, every harness auto-launches an inspector.
3030pub const INSPECTOR_ENV_VAR : & str = "KITTEST_INSPECTOR" ;
@@ -115,16 +115,48 @@ impl<S> Plugin<S> for InspectorPlugin {
115115 self . block_until_resume ( harness) ;
116116 }
117117 }
118+
119+ /// Test ended — send `Finished` (carrying the panic location in its `SourceView` when
120+ /// the panic's file matches the test entry), then block until the user dismisses with a
121+ /// Step / Run / Play command. The dismiss unblocks us; the harness finishes dropping on
122+ /// the way out.
123+ fn on_test_result ( & mut self , harness : & mut Harness < ' _ , S > , result : TestResult < ' _ > ) {
124+ if self . conn . broken {
125+ return ;
126+ }
127+
128+ let ( ok, message, panic_loc) = match result {
129+ TestResult :: Pass => ( true , None , None ) ,
130+ TestResult :: Fail { message, location } => (
131+ false ,
132+ message. map ( str:: to_owned) ,
133+ location. map ( |loc| ( loc. file . clone ( ) , loc. line ) ) ,
134+ ) ,
135+ } ;
136+
137+ let source = build_source_view (
138+ harness. entry_location ( ) ,
139+ harness. consumed_event_locations ( ) ,
140+ panic_loc. as_ref ( ) ,
141+ ) ;
142+ self . conn . write ( & HarnessMessage :: Finished {
143+ ok,
144+ message,
145+ source,
146+ } ) ;
147+ // Park here until the user dismisses with Step/Run/Play. `block_until_resume` exits
148+ // on any of those (they all transition out of `Paused`); `Pause` is a no-op; `Handle`
149+ // still works so the user can poke at the final UI on failure. The mode mutation it
150+ // leaves behind is harmless — the plugin is about to drop.
151+ self . mode = Mode :: Paused ;
152+ self . block_until_resume ( harness) ;
153+ }
118154}
119155
120156impl InspectorPlugin {
121157 /// Send a frame for this step and apply the current mode's blocking / draining policy.
122158 /// `after_run` is handled separately — it only transitions `RunOnce → Paused`.
123- fn handle_after_step < S > (
124- & mut self ,
125- harness : & mut Harness < ' _ , S > ,
126- tree : & accesskit:: TreeUpdate ,
127- ) {
159+ fn handle_after_step < S > ( & mut self , harness : & mut Harness < ' _ , S > , tree : & accesskit:: TreeUpdate ) {
128160 if self . conn . broken {
129161 return ;
130162 }
@@ -209,7 +241,7 @@ impl InspectorPlugin {
209241 harness. input_mut ( ) . events . push ( event) ;
210242 }
211243 // `step_no_side_effects` returns the tree directly — we can't receive it via
212- // `on_accesskit_update ` because we're inside an outer `plugin_dispatch` .
244+ // `after_step ` because nested plugin dispatches are suppressed .
213245 let tree = harness. step_no_side_effects ( ) ;
214246 self . send_frame ( harness, Some ( tree) ) ;
215247 }
@@ -231,10 +263,12 @@ impl InspectorPlugin {
231263 }
232264 } ;
233265 let ppp = harness. ctx . pixels_per_point ( ) ;
234- let call_site = harness. entry_location ( ) ;
235- let event_sites = harness. consumed_event_locations ( ) . to_vec ( ) ;
236- self . conn
237- . send_frame ( & image, ppp, tree, call_site, & event_sites) ;
266+ let source = build_source_view (
267+ harness. entry_location ( ) ,
268+ harness. consumed_event_locations ( ) ,
269+ None ,
270+ ) ;
271+ self . conn . send_frame ( & image, ppp, tree, source) ;
238272 }
239273}
240274
@@ -298,8 +332,7 @@ impl Connection {
298332 image : & image:: RgbaImage ,
299333 pixels_per_point : f32 ,
300334 accesskit : Option < accesskit:: TreeUpdate > ,
301- call_site : Option < & ' static Location < ' static > > ,
302- event_sites : & [ & ' static Location < ' static > ] ,
335+ source : Option < SourceView > ,
303336 ) {
304337 if self . broken {
305338 return ;
@@ -313,7 +346,7 @@ impl Connection {
313346 rgba : image. as_raw ( ) . clone ( ) ,
314347 accesskit,
315348 label : self . label . clone ( ) ,
316- source : build_source_view ( call_site , event_sites ) ,
349+ source,
317350 } ;
318351 self . write ( & HarnessMessage :: Frame ( Box :: new ( frame) ) ) ;
319352 }
@@ -353,13 +386,16 @@ fn run_reader(mut reader: BufReader<ChildStdout>, tx: &mpsc::Sender<InspectorCom
353386}
354387
355388/// Build the [`SourceView`] payload for a frame: pick the `.run()`/`.step()` caller's file
356- /// as the anchor, and record each event's line within that same file.
389+ /// as the anchor, and record each event's line within that same file. `panic_loc` is set
390+ /// only on the final frame after a failed test — and only included in the output if the
391+ /// panic's file matches the anchor (otherwise there's no highlight to attach).
357392///
358393/// `#[track_caller]` chains through the entire event-queuing API, so each `Location` points
359394/// directly at the user's test source — no backtrace walking needed.
360395fn build_source_view (
361396 call_site : Option < & ' static Location < ' static > > ,
362397 event_sites : & [ & ' static Location < ' static > ] ,
398+ panic_loc : Option < & ( String , u32 ) > ,
363399) -> Option < SourceView > {
364400 let call = call_site?;
365401 let path = call. file ( ) . to_owned ( ) ;
@@ -368,25 +404,53 @@ fn build_source_view(
368404 . filter ( |loc| loc. file ( ) == path)
369405 . map ( |loc| loc. line ( ) )
370406 . collect ( ) ;
407+ let panic_line = panic_loc
408+ . filter ( |( file, _) | file == & path)
409+ . map ( |( _, line) | * line) ;
371410 Some ( SourceView {
372411 path : path. clone ( ) ,
373412 contents : read_source_file ( & path) ,
374413 call_site_line : Some ( call. line ( ) ) ,
375414 event_lines,
415+ panic_line,
376416 } )
377417}
378418
379419/// Read the full contents of a source file, cached per path (including negative results).
420+ ///
421+ /// `path` comes from `std::panic::Location::file()`, which the compiler reports relative to
422+ /// the *workspace* root. Cargo runs tests with CWD set to the *crate* root, so for a
423+ /// workspace crate at `<workspace>/crates/foo/` the compiler-reported path is
424+ /// `crates/foo/src/…` but CWD is `<workspace>/crates/foo/`. We try as-is first (handles
425+ /// absolute paths and single-crate layouts), then walk up from CWD looking for an ancestor
426+ /// where `ancestor.join(path)` resolves.
380427fn read_source_file ( path : & str ) -> Option < String > {
381428 static CACHE : LazyLock < Mutex < HashMap < String , Option < String > > > > =
382429 LazyLock :: new ( || Mutex :: new ( HashMap :: new ( ) ) ) ;
383430 let mut cache = CACHE . lock ( ) ;
384431 cache
385432 . entry ( path. to_owned ( ) )
386- . or_insert_with ( || std :: fs :: read_to_string ( path) . ok ( ) )
433+ . or_insert_with ( || resolve_and_read ( path) )
387434 . clone ( )
388435}
389436
437+ fn resolve_and_read ( path : & str ) -> Option < String > {
438+ if let Ok ( contents) = std:: fs:: read_to_string ( path) {
439+ return Some ( contents) ;
440+ }
441+ if std:: path:: Path :: new ( path) . is_absolute ( ) {
442+ return None ;
443+ }
444+ let mut cursor = std:: env:: current_dir ( ) . ok ( ) ?;
445+ // `pop()` returns false once we've hit the root, which terminates the search.
446+ while cursor. pop ( ) {
447+ if let Ok ( contents) = std:: fs:: read_to_string ( cursor. join ( path) ) {
448+ return Some ( contents) ;
449+ }
450+ }
451+ None
452+ }
453+
390454/// Read [`INSPECTOR_ENV_VAR`] once and cache. Exposed to [`crate::Harness::from_builder`]
391455/// so it can auto-register an [`InspectorPlugin`].
392456pub ( crate ) fn env_enabled ( ) -> bool {
0 commit comments