Skip to content

Commit 98822b9

Browse files
authored
Merge pull request #301254 from microsoft/connor4312/testing-tool-refactoring
testing: improve test coverage representation from the runtests tools
2 parents 33f334e + fa144f0 commit 98822b9

File tree

2 files changed

+925
-131
lines changed

2 files changed

+925
-131
lines changed

src/vs/workbench/contrib/testing/common/testingChatAgentTool.ts

Lines changed: 204 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ import {
3131
ToolProgress,
3232
} from '../../chat/common/tools/languageModelToolsService.js';
3333
import { TestId } from './testId.js';
34-
import { FileCoverage, getTotalCoveragePercent } from './testCoverage.js';
34+
import { FileCoverage, TestCoverage, getTotalCoveragePercent } from './testCoverage.js';
3535
import { TestingContextKeys } from './testingContextKeys.js';
3636
import { collectTestStateCounts, getTestProgressText } from './testingProgressMessages.js';
3737
import { isFailedState } from './testingStates.js';
3838
import { LiveTestResult } from './testResult.js';
3939
import { ITestResultService } from './testResultService.js';
4040
import { 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';
4242
import { Position } from '../../../../editor/common/core/position.js';
4343
import { 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

Comments
 (0)