@@ -31,14 +31,14 @@ import {
3131 ToolProgress ,
3232} from '../../chat/common/tools/languageModelToolsService.js' ;
3333import { TestId } from './testId.js' ;
34- import { FileCoverage , getTotalCoveragePercent } from './testCoverage.js' ;
34+ import { FileCoverage , TestCoverage , getTotalCoveragePercent } from './testCoverage.js' ;
3535import { TestingContextKeys } from './testingContextKeys.js' ;
3636import { collectTestStateCounts , getTestProgressText } from './testingProgressMessages.js' ;
3737import { isFailedState } from './testingStates.js' ;
3838import { LiveTestResult } from './testResult.js' ;
3939import { ITestResultService } from './testResultService.js' ;
4040import { ITestService , testsInFile , waitForTestToBeIdle } from './testService.js' ;
41- import { IncrementalTestCollectionItem , TestItemExpandState , TestMessageType , TestResultState , TestRunProfileBitset } from './testTypes.js' ;
41+ import { DetailType , IncrementalTestCollectionItem , TestItemExpandState , TestMessageType , TestResultState , TestRunProfileBitset } from './testTypes.js' ;
4242import { Position } from '../../../../editor/common/core/position.js' ;
4343import { ITestProfileService } from './testProfileService.js' ;
4444
@@ -70,7 +70,7 @@ interface IRunTestToolParams {
7070 mode ?: Mode ;
7171}
7272
73- class RunTestTool implements IToolImpl {
73+ export class RunTestTool implements IToolImpl {
7474 public static readonly ID = 'runTests' ;
7575 public static readonly DEFINITION : IToolData = {
7676 id : this . ID ,
@@ -101,7 +101,7 @@ class RunTestTool implements IToolImpl {
101101 coverageFiles : {
102102 type : 'array' ,
103103 items : { type : 'string' } ,
104- description : 'When mode="coverage": absolute file paths to include detailed coverage info for. Only the first matching file will be summarized .'
104+ description : 'When mode="coverage": absolute file paths to include detailed coverage info for. If not provided, a file-level summary of all files with incomplete coverage is shown .'
105105 }
106106 } ,
107107 } ,
@@ -168,7 +168,7 @@ class RunTestTool implements IToolImpl {
168168 } ;
169169 }
170170
171- const summary = await this . _buildSummary ( result , mode , coverageFiles ) ;
171+ const summary = await buildTestRunSummary ( result , mode , coverageFiles ) ;
172172 const content = [ { kind : 'text' , value : summary } as const ] ;
173173
174174 return {
@@ -177,132 +177,6 @@ class RunTestTool implements IToolImpl {
177177 } ;
178178 }
179179
180- private async _buildSummary ( result : LiveTestResult , mode : Mode , coverageFiles : string [ ] | undefined ) : Promise < string > {
181- const failures = result . counts [ TestResultState . Errored ] + result . counts [ TestResultState . Failed ] ;
182- let str = `<summary passed=${ result . counts [ TestResultState . Passed ] } failed=${ failures } />\n` ;
183- if ( failures !== 0 ) {
184- str += await this . _getFailureDetails ( result ) ;
185- }
186- if ( mode === 'coverage' ) {
187- str += await this . _getCoverageSummary ( result , coverageFiles ) ;
188- }
189- return str ;
190- }
191-
192- private async _getCoverageSummary ( result : LiveTestResult , coverageFiles : string [ ] | undefined ) : Promise < string > {
193- if ( ! coverageFiles || ! coverageFiles . length ) {
194- return '' ;
195- }
196- for ( const task of result . tasks ) {
197- const coverage = task . coverage . get ( ) ;
198- if ( ! coverage ) {
199- continue ;
200- }
201- const normalized = coverageFiles . map ( file => URI . file ( file ) . fsPath ) ;
202- const coveredFilesMap = new Map < string , FileCoverage > ( ) ;
203- for ( const file of coverage . getAllFiles ( ) . values ( ) ) {
204- coveredFilesMap . set ( file . uri . fsPath , file ) ;
205- }
206- for ( const path of normalized ) {
207- const file = coveredFilesMap . get ( path ) ;
208- if ( ! file ) {
209- continue ;
210- }
211- let summary = `<coverage task=${ JSON . stringify ( task . name || '' ) } >\n` ;
212- const pct = getTotalCoveragePercent ( file . statement , file . branch , file . declaration ) * 100 ;
213- summary += `<firstUncoveredFile path=${ JSON . stringify ( path ) } statementsCovered=${ file . statement . covered } statementsTotal=${ file . statement . total } ` ;
214- if ( file . branch ) {
215- summary += ` branchesCovered=${ file . branch . covered } branchesTotal=${ file . branch . total } ` ;
216- }
217- if ( file . declaration ) {
218- summary += ` declarationsCovered=${ file . declaration . covered } declarationsTotal=${ file . declaration . total } ` ;
219- }
220- summary += ` percent=${ pct . toFixed ( 2 ) } ` ;
221- try {
222- const details = await file . details ( ) ;
223- for ( const detail of details ) {
224- if ( detail . count || ! detail . location ) {
225- continue ;
226- }
227- let startLine : number ;
228- let endLine : number ;
229- if ( Position . isIPosition ( detail . location ) ) {
230- startLine = endLine = detail . location . lineNumber ;
231- } else {
232- startLine = detail . location . startLineNumber ;
233- endLine = detail . location . endLineNumber ;
234- }
235- summary += ` firstUncoveredStart=${ startLine } firstUncoveredEnd=${ endLine } ` ;
236- break ;
237- }
238- } catch { /* ignore */ }
239- summary += ` />\n` ;
240- summary += `</coverage>\n` ;
241- return summary ;
242- }
243- }
244- return '' ;
245- }
246-
247- private async _getFailureDetails ( result : LiveTestResult ) : Promise < string > {
248- let str = '' ;
249- let hadMessages = false ;
250- for ( const failure of result . tests ) {
251- if ( ! isFailedState ( failure . ownComputedState ) ) {
252- continue ;
253- }
254-
255- const [ , ...testPath ] = TestId . split ( failure . item . extId ) ;
256- const testName = testPath . pop ( ) ;
257- str += `<testFailure name=${ JSON . stringify ( testName ) } path=${ JSON . stringify ( testPath . join ( ' > ' ) ) } >\n` ;
258- // Extract detailed failure information from error messages
259- for ( const task of failure . tasks ) {
260- for ( const message of task . messages . filter ( m => m . type === TestMessageType . Error ) ) {
261- hadMessages = true ;
262-
263- // Add expected/actual outputs if available
264- if ( message . expected !== undefined && message . actual !== undefined ) {
265- str += `<expectedOutput>\n${ message . expected } \n</expectedOutput>\n` ;
266- str += `<actualOutput>\n${ message . actual } \n</actualOutput>\n` ;
267- } else {
268- // Fallback to the message content
269- const messageText = typeof message . message === 'string' ? message . message : message . message . value ;
270- str += `<message>\n${ messageText } \n</message>\n` ;
271- }
272-
273- // Add stack trace information if available (limit to first 10 frames)
274- if ( message . stackTrace && message . stackTrace . length > 0 ) {
275- for ( const frame of message . stackTrace . slice ( 0 , 10 ) ) {
276- if ( frame . uri && frame . position ) {
277- str += `<stackFrame path="${ frame . uri . fsPath } " line="${ frame . position . lineNumber } " col="${ frame . position . column } " />\n` ;
278- } else if ( frame . uri ) {
279- str += `<stackFrame path="${ frame . uri . fsPath } ">${ frame . label } </stackFrame>\n` ;
280- } else {
281- str += `<stackFrame>${ frame . label } </stackFrame>\n` ;
282- }
283- }
284- }
285-
286- // Add location information if available
287- if ( message . location ) {
288- str += `<location path="${ message . location . uri . fsPath } " line="${ message . location . range . startLineNumber } " col="${ message . location . range . startColumn } " />\n` ;
289- }
290- }
291- }
292-
293- str += `</testFailure>\n` ;
294- }
295-
296- if ( ! hadMessages ) { // some adapters don't have any per-test messages and just output
297- const output = result . tasks . map ( t => t . output . getRange ( 0 , t . output . length ) . toString ( ) . trim ( ) ) . join ( '\n' ) ;
298- if ( output ) {
299- str += `<output>\n${ output } \n</output>\n` ;
300- }
301- }
302-
303- return str ;
304- }
305-
306180 /** Updates the UI progress as the test runs, resolving when the run is finished. */
307181 private async _monitorRunProgress ( result : LiveTestResult , progress : ToolProgress , token : CancellationToken ) : Promise < void > {
308182 const store = new DisposableStore ( ) ;
@@ -451,3 +325,202 @@ class RunTestTool implements IToolImpl {
451325 } ) ;
452326 }
453327}
328+
329+ /** Builds the full summary string for a completed test run. */
330+ export async function buildTestRunSummary ( result : LiveTestResult , mode : Mode , coverageFiles : string [ ] | undefined ) : Promise < string > {
331+ const failures = result . counts [ TestResultState . Errored ] + result . counts [ TestResultState . Failed ] ;
332+ let str = `<summary passed=${ result . counts [ TestResultState . Passed ] } failed=${ failures } />\n` ;
333+ if ( failures !== 0 ) {
334+ str += await getFailureDetails ( result ) ;
335+ }
336+ if ( mode === 'coverage' ) {
337+ str += await getCoverageSummary ( result , coverageFiles ) ;
338+ }
339+ return str ;
340+ }
341+
342+ /** Gets a coverage summary from a test result, either overall or per-file. */
343+ export async function getCoverageSummary ( result : LiveTestResult , coverageFiles : string [ ] | undefined ) : Promise < string > {
344+ let str = '' ;
345+ for ( const task of result . tasks ) {
346+ const coverage = task . coverage . get ( ) ;
347+ if ( ! coverage ) {
348+ continue ;
349+ }
350+
351+ if ( ! coverageFiles || ! coverageFiles . length ) {
352+ str += getOverallCoverageSummary ( coverage ) ;
353+ continue ;
354+ }
355+
356+ const normalized = coverageFiles . map ( file => URI . file ( file ) . fsPath ) ;
357+ const coveredFilesMap = new Map < string , FileCoverage > ( ) ;
358+ for ( const file of coverage . getAllFiles ( ) . values ( ) ) {
359+ coveredFilesMap . set ( file . uri . fsPath , file ) ;
360+ }
361+
362+ for ( const path of normalized ) {
363+ const file = coveredFilesMap . get ( path ) ;
364+ if ( ! file ) {
365+ continue ;
366+ }
367+ str += await getFileCoverageDetails ( file , path ) ;
368+ }
369+ }
370+ return str ;
371+ }
372+
373+ /** Gets a file-level coverage overview sorted by lowest coverage first. */
374+ export function getOverallCoverageSummary ( coverage : TestCoverage ) : string {
375+ const files = [ ...coverage . getAllFiles ( ) . values ( ) ]
376+ . map ( f => ( { path : f . uri . fsPath , pct : getTotalCoveragePercent ( f . statement , f . branch , f . declaration ) * 100 } ) )
377+ . filter ( f => f . pct < 100 )
378+ . sort ( ( a , b ) => a . pct - b . pct ) ;
379+
380+ if ( ! files . length ) {
381+ return '<coverageSummary>All files have 100% coverage.</coverageSummary>\n' ;
382+ }
383+
384+ let str = '<coverageSummary>\n' ;
385+ for ( const f of files ) {
386+ str += `<file path="${ f . path } " percent=${ f . pct . toFixed ( 1 ) } />\n` ;
387+ }
388+ str += '</coverageSummary>\n' ;
389+ return str ;
390+ }
391+
392+ /** Gets detailed coverage information for a single file including uncovered items. */
393+ export async function getFileCoverageDetails ( file : FileCoverage , path : string ) : Promise < string > {
394+ const pct = getTotalCoveragePercent ( file . statement , file . branch , file . declaration ) * 100 ;
395+ let str = `<coverage path="${ path } " percent=${ pct . toFixed ( 1 ) } statements=${ file . statement . covered } /${ file . statement . total } ` ;
396+ if ( file . branch ) {
397+ str += ` branches=${ file . branch . covered } /${ file . branch . total } ` ;
398+ }
399+ if ( file . declaration ) {
400+ str += ` declarations=${ file . declaration . covered } /${ file . declaration . total } ` ;
401+ }
402+ str += '>\n' ;
403+
404+ try {
405+ const details = await file . details ( ) ;
406+
407+ const uncoveredDeclarations : { name : string ; line : number } [ ] = [ ] ;
408+ const uncoveredBranches : { line : number ; label ?: string } [ ] = [ ] ;
409+ const uncoveredLines : [ number , number ] [ ] = [ ] ;
410+
411+ for ( const detail of details ) {
412+ if ( detail . type === DetailType . Declaration ) {
413+ if ( ! detail . count ) {
414+ const line = Position . isIPosition ( detail . location ) ? detail . location . lineNumber : detail . location . startLineNumber ;
415+ uncoveredDeclarations . push ( { name : detail . name , line } ) ;
416+ }
417+ } else {
418+ if ( ! detail . count ) {
419+ const startLine = Position . isIPosition ( detail . location ) ? detail . location . lineNumber : detail . location . startLineNumber ;
420+ const endLine = Position . isIPosition ( detail . location ) ? detail . location . lineNumber : detail . location . endLineNumber ;
421+ uncoveredLines . push ( [ startLine , endLine ] ) ;
422+ }
423+ if ( detail . branches ) {
424+ for ( const branch of detail . branches ) {
425+ if ( ! branch . count ) {
426+ let line : number ;
427+ if ( branch . location ) {
428+ line = Position . isIPosition ( branch . location ) ? branch . location . lineNumber : branch . location . startLineNumber ;
429+ } else {
430+ line = Position . isIPosition ( detail . location ) ? detail . location . lineNumber : detail . location . startLineNumber ;
431+ }
432+ uncoveredBranches . push ( { line, label : branch . label } ) ;
433+ }
434+ }
435+ }
436+ }
437+ }
438+
439+ if ( uncoveredDeclarations . length ) {
440+ str += 'uncovered functions: ' + uncoveredDeclarations . map ( d => `${ d . name } (L${ d . line } )` ) . join ( ', ' ) + '\n' ;
441+ }
442+ if ( uncoveredBranches . length ) {
443+ str += 'uncovered branches: ' + uncoveredBranches . map ( b => b . label ? `L${ b . line } (${ b . label } )` : `L${ b . line } ` ) . join ( ', ' ) + '\n' ;
444+ }
445+ if ( uncoveredLines . length ) {
446+ str += 'uncovered lines: ' + mergeLineRanges ( uncoveredLines ) + '\n' ;
447+ }
448+ } catch { /* ignore - details not available */ }
449+
450+ str += '</coverage>\n' ;
451+ return str ;
452+ }
453+
454+ /** Merges overlapping/contiguous line ranges and formats them compactly. */
455+ export function mergeLineRanges ( ranges : [ number , number ] [ ] ) : string {
456+ if ( ! ranges . length ) {
457+ return '' ;
458+ }
459+ ranges . sort ( ( a , b ) => a [ 0 ] - b [ 0 ] ) ;
460+ const merged : [ number , number ] [ ] = [ ranges [ 0 ] ] ;
461+ for ( let i = 1 ; i < ranges . length ; i ++ ) {
462+ const last = merged [ merged . length - 1 ] ;
463+ const [ start , end ] = ranges [ i ] ;
464+ if ( start <= last [ 1 ] + 1 ) {
465+ last [ 1 ] = Math . max ( last [ 1 ] , end ) ;
466+ } else {
467+ merged . push ( [ start , end ] ) ;
468+ }
469+ }
470+ return merged . map ( ( [ s , e ] ) => s === e ? `${ s } ` : `${ s } -${ e } ` ) . join ( ', ' ) ;
471+ }
472+
473+ /** Formats failure details from a test result into an XML-like string. */
474+ export async function getFailureDetails ( result : LiveTestResult ) : Promise < string > {
475+ let str = '' ;
476+ let hadMessages = false ;
477+ for ( const failure of result . tests ) {
478+ if ( ! isFailedState ( failure . ownComputedState ) ) {
479+ continue ;
480+ }
481+
482+ const [ , ...testPath ] = TestId . split ( failure . item . extId ) ;
483+ const testName = testPath . pop ( ) ;
484+ str += `<testFailure name=${ JSON . stringify ( testName ) } path=${ JSON . stringify ( testPath . join ( ' > ' ) ) } >\n` ;
485+ for ( const task of failure . tasks ) {
486+ for ( const message of task . messages . filter ( m => m . type === TestMessageType . Error ) ) {
487+ hadMessages = true ;
488+
489+ if ( message . expected !== undefined && message . actual !== undefined ) {
490+ str += `<expectedOutput>\n${ message . expected } \n</expectedOutput>\n` ;
491+ str += `<actualOutput>\n${ message . actual } \n</actualOutput>\n` ;
492+ } else {
493+ const messageText = typeof message . message === 'string' ? message . message : message . message . value ;
494+ str += `<message>\n${ messageText } \n</message>\n` ;
495+ }
496+
497+ if ( message . stackTrace && message . stackTrace . length > 0 ) {
498+ for ( const frame of message . stackTrace . slice ( 0 , 10 ) ) {
499+ if ( frame . uri && frame . position ) {
500+ str += `<stackFrame path="${ frame . uri . fsPath } " line="${ frame . position . lineNumber } " col="${ frame . position . column } " />\n` ;
501+ } else if ( frame . uri ) {
502+ str += `<stackFrame path="${ frame . uri . fsPath } ">${ frame . label } </stackFrame>\n` ;
503+ } else {
504+ str += `<stackFrame>${ frame . label } </stackFrame>\n` ;
505+ }
506+ }
507+ }
508+
509+ if ( message . location ) {
510+ str += `<location path="${ message . location . uri . fsPath } " line="${ message . location . range . startLineNumber } " col="${ message . location . range . startColumn } " />\n` ;
511+ }
512+ }
513+ }
514+
515+ str += `</testFailure>\n` ;
516+ }
517+
518+ if ( ! hadMessages ) {
519+ const output = result . tasks . map ( t => t . output . getRange ( 0 , t . output . length ) . toString ( ) . trim ( ) ) . join ( '\n' ) ;
520+ if ( output ) {
521+ str += `<output>\n${ output } \n</output>\n` ;
522+ }
523+ }
524+
525+ return str ;
526+ }
0 commit comments