11import { mkdir , readFile , unlink , writeFile } from 'node:fs/promises' ;
22import { createServer } from 'node:http' ;
3- import { dirname , relative , resolve } from 'pathe' ;
4- import { isTTY } from './helper' ;
3+ import { dirname , resolve } from 'pathe' ;
4+ import { displayPath , isTTY } from './helper' ;
55import { color , logger } from './logger' ;
6+ import { formatTraceSummary , summarizeTrace } from './traceSummary' ;
67
78// ---------------------------------------------------------------------------
89// Public event type
@@ -46,12 +47,17 @@ export const noopTraceSpan: TraceSpan = async (_name, _cat, fn) => fn();
4647// ---------------------------------------------------------------------------
4748
4849/**
49- * Build the absolute path for a Perfetto trace dump. The filename embeds an
50- * ISO-like timestamp so repeated runs do not overwrite each other.
50+ * Build the absolute paths for a run's artifacts: the Perfetto trace dump and
51+ * its markdown summary sidecar. Both share one timestamped stem so the pair is
52+ * named from a single source (no fragile extension rewriting), and the stamp
53+ * keeps repeated runs from overwriting each other.
5154 */
52- const getTraceOutputPath = ( rootPath : string ) : string => {
55+ const getTraceOutputPaths = (
56+ rootPath : string ,
57+ ) : { tracePath : string ; summaryPath : string } => {
5358 const stamp = new Date ( ) . toISOString ( ) . replace ( / [: .] / g, '-' ) . replace ( 'Z' , '' ) ;
54- return resolve ( rootPath , '.rstest' , `trace-${ stamp } .json` ) ;
59+ const stem = resolve ( rootPath , '.rstest' , `trace-${ stamp } ` ) ;
60+ return { tracePath : `${ stem } .json` , summaryPath : `${ stem } .summary.md` } ;
5561} ;
5662
5763/**
@@ -100,11 +106,6 @@ const buildTraceFile = (
100106 threadIds . add ( ev . tid ) ;
101107 }
102108
103- const displayPath = ( testPath : string ) : string => {
104- const rel = relative ( rootPath , testPath ) ;
105- return rel && ! rel . startsWith ( '..' ) ? rel : testPath ;
106- } ;
107-
108109 const seenPid = new Set < number > ( ) ;
109110 const seenThread = new Set < string > ( ) ;
110111 const metadata : TraceEvent [ ] = [ ] ;
@@ -119,7 +120,9 @@ const buildTraceFile = (
119120 const sharedWorker = ( threadIdsByPid . get ( ev . pid ) ?. size ?? 0 ) > 1 ;
120121 metadata . push (
121122 meta ( 'process_name' , ev . pid , ev . tid , {
122- name : sharedWorker ? `worker ${ ev . pid } ` : displayPath ( testPath ) ,
123+ name : sharedWorker
124+ ? `worker ${ ev . pid } `
125+ : displayPath ( testPath , rootPath ) ,
123126 } ) ,
124127 meta ( 'process_sort_index' , ev . pid , ev . tid , {
125128 sort_index : sortIndex ++ ,
@@ -131,7 +134,9 @@ const buildTraceFile = (
131134 if ( ! seenThread . has ( threadKey ) ) {
132135 seenThread . add ( threadKey ) ;
133136 metadata . push (
134- meta ( 'thread_name' , ev . pid , ev . tid , { name : displayPath ( testPath ) } ) ,
137+ meta ( 'thread_name' , ev . pid , ev . tid , {
138+ name : displayPath ( testPath , rootPath ) ,
139+ } ) ,
135140 ) ;
136141 }
137142 }
@@ -300,6 +305,9 @@ export const createTraceController = (options: {
300305 // In watch mode we replace it on each rerun so .rstest/ does not accumulate
301306 // multi-MB JSONs; files from earlier sessions are left alone.
302307 let lastTracePath : string | undefined ;
308+ // Sidecar `.summary.md` produced alongside the trace; replaced on rerun in
309+ // watch mode for the same reason as `lastTracePath`.
310+ let lastSummaryPath : string | undefined ;
303311
304312 const beginRun = ( ) : TraceRun => {
305313 if ( ! enabled ) {
@@ -343,7 +351,7 @@ export const createTraceController = (options: {
343351 span : pushHostSlice ,
344352 finalize : async ( ) => {
345353 if ( ! events . length ) return ;
346- const tracePath = getTraceOutputPath ( rootPath ) ;
354+ const { tracePath, summaryPath } = getTraceOutputPaths ( rootPath ) ;
347355 await mkdir ( dirname ( tracePath ) , { recursive : true } ) ;
348356 await writeFile (
349357 tracePath ,
@@ -354,10 +362,29 @@ export const createTraceController = (options: {
354362 unlink ( lastTracePath ) . catch ( ( ) => { } ) ;
355363 }
356364 lastTracePath = tracePath ;
365+
366+ // Agent/CI-friendly text summary: the Perfetto JSON is a raw event
367+ // dump meant for the visual UI, so always emit a ranked markdown
368+ // digest (printed to stdout and written next to the trace) that
369+ // answers "where did time go" without opening a flame graph.
370+ const summaryMarkdown = formatTraceSummary (
371+ summarizeTrace ( events , rootPath ) ,
372+ ) ;
373+ await writeFile ( summaryPath , `${ summaryMarkdown } \n` ) ;
374+ if ( lastSummaryPath && lastSummaryPath !== summaryPath ) {
375+ unlink ( lastSummaryPath ) . catch ( ( ) => { } ) ;
376+ }
377+ lastSummaryPath = summaryPath ;
378+
379+ logger . log ( `\n${ summaryMarkdown } \n` ) ;
357380 logger . log (
358381 color . gray ( ' Perfetto trace file: ' ) ,
359382 color . cyan ( tracePath ) ,
360383 ) ;
384+ logger . log (
385+ color . gray ( ' Trace summary file: ' ) ,
386+ color . cyan ( summaryPath ) ,
387+ ) ;
361388 // The helper server keeps the event loop alive until SIGINT, which
362389 // would hang `rstest run` in CI. Only start it in an interactive TTY,
363390 // otherwise leave the file for the user to download from the CI
0 commit comments