testing: polish attributable coverage API, fix coverage reporting in selfhost (#215559)

This commit is contained in:
Connor Peet
2024-06-14 11:39:38 -07:00
committed by GitHub
parent 4666c5068d
commit af867d774a
15 changed files with 337 additions and 265 deletions

View File

@@ -5,7 +5,7 @@
import { IstanbulCoverageContext } from 'istanbul-to-vscode';
import * as vscode from 'vscode';
import { SourceLocationMapper, SourceMapStore } from './testOutputScanner';
import { SearchStrategy, SourceLocationMapper, SourceMapStore } from './testOutputScanner';
import { IScriptCoverage, OffsetToPosition, RangeCoverageTracker } from './v8CoverageWrangling';
export const istanbulCoverageContext = new IstanbulCoverageContext();
@@ -18,7 +18,7 @@ export const istanbulCoverageContext = new IstanbulCoverageContext();
export class PerTestCoverageTracker {
private readonly scripts = new Map</* script ID */ string, Script>();
constructor(private readonly maps: SourceMapStore) {}
constructor(private readonly maps: SourceMapStore) { }
public add(coverage: IScriptCoverage, test?: vscode.TestItem) {
const script = this.scripts.get(coverage.scriptId);
@@ -71,11 +71,7 @@ class Script {
public async report(run: vscode.TestRun) {
const mapper = await this.maps.getSourceLocationMapper(this.uri.toString());
const originalUri = (await this.maps.getSourceFile(this.uri.toString())) || this.uri;
run.addCoverage(this.overall.report(originalUri, this.converter, mapper));
for (const [test, projection] of this.perItem) {
run.addCoverage(projection.report(originalUri, this.converter, mapper, test));
}
run.addCoverage(this.overall.report(originalUri, this.converter, mapper, this.perItem));
}
}
@@ -88,6 +84,43 @@ class ScriptCoverageTracker {
}
}
public *toDetails(
uri: vscode.Uri,
convert: OffsetToPosition,
mapper: SourceLocationMapper | undefined,
) {
for (const range of this.coverage) {
if (range.start === range.end) {
continue;
}
const startCov = convert.toLineColumn(range.start);
let start = new vscode.Position(startCov.line, startCov.column);
const endCov = convert.toLineColumn(range.end);
let end = new vscode.Position(endCov.line, endCov.column);
if (mapper) {
const startMap = mapper(start.line, start.character, SearchStrategy.FirstAfter);
const endMap = startMap && mapper(end.line, end.character, SearchStrategy.FirstBefore);
if (!endMap || uri.toString().toLowerCase() !== endMap.uri.toString().toLowerCase()) {
continue;
}
start = startMap.range.start;
end = endMap.range.end;
}
for (let i = start.line; i <= end.line; i++) {
yield new vscode.StatementCoverage(
range.covered,
new vscode.Range(
new vscode.Position(i, i === start.line ? start.character : 0),
new vscode.Position(i, i === end.line ? end.character : Number.MAX_SAFE_INTEGER)
)
);
}
}
}
/**
* Generates the script's coverage for the test run.
*
@@ -98,53 +131,27 @@ class ScriptCoverageTracker {
uri: vscode.Uri,
convert: OffsetToPosition,
mapper: SourceLocationMapper | undefined,
item?: vscode.TestItem
items: Map<vscode.TestItem, ScriptCoverageTracker>,
): V8CoverageFile {
const file = new V8CoverageFile(uri, item);
for (const range of this.coverage) {
if (range.start === range.end) {
continue;
}
const startCov = convert.toLineColumn(range.start);
let start = new vscode.Position(startCov.line, startCov.column);
const endCov = convert.toLineColumn(range.end);
let end = new vscode.Position(endCov.line, endCov.column);
if (mapper) {
const startMap = mapper(start.line, start.character);
const endMap = startMap && mapper(end.line, end.character);
if (!endMap || uri.toString().toLowerCase() !== endMap.uri.toString().toLowerCase()) {
continue;
}
start = startMap.range.start;
end = endMap.range.end;
}
for (let i = start.line; i <= end.line; i++) {
file.add(
new vscode.StatementCoverage(
range.covered,
new vscode.Range(
new vscode.Position(i, i === start.line ? start.character : 0),
new vscode.Position(i, i === end.line ? end.character : Number.MAX_SAFE_INTEGER)
)
)
);
}
const file = new V8CoverageFile(uri, items, convert, mapper);
for (const detail of this.toDetails(uri, convert, mapper)) {
file.add(detail);
}
return file;
}
}
export class V8CoverageFile extends vscode.FileCoverage {
export class V8CoverageFile extends vscode.FileCoverage2 {
public details: vscode.StatementCoverage[] = [];
constructor(uri: vscode.Uri, item?: vscode.TestItem) {
super(uri, { covered: 0, total: 0 });
(this as vscode.FileCoverage2).testItem = item;
constructor(
uri: vscode.Uri,
private readonly perTest: Map<vscode.TestItem, ScriptCoverageTracker>,
private readonly convert: OffsetToPosition,
private readonly mapper: SourceLocationMapper | undefined,
) {
super(uri, { covered: 0, total: 0 }, undefined, undefined, [...perTest.keys()]);
}
public add(detail: vscode.StatementCoverage) {
@@ -154,4 +161,9 @@ export class V8CoverageFile extends vscode.FileCoverage {
this.statementCoverage.covered++;
}
}
public testDetails(test: vscode.TestItem): vscode.FileCoverageDetail[] {
const t = this.perTest.get(test);
return t ? [...t.toDetails(this.uri, this.convert, this.mapper)] : [];
}
}

View File

@@ -196,9 +196,9 @@ export async function activate(context: vscode.ExtensionContext) {
true
);
coverage.loadDetailedCoverage = async (_run, coverage) => {
(coverage as vscode.TestRunProfile2).loadDetailedCoverage = async (_run, coverage, _token, test) => {
if (coverage instanceof V8CoverageFile) {
return coverage.details;
return test ? coverage.testDetails(test) : coverage.details;
}
return [];

View File

@@ -424,36 +424,62 @@ const tryMakeMarkdown = (message: string) => {
const inlineSourcemapRe = /^\/\/# sourceMappingURL=data:application\/json;base64,(.+)/m;
const sourceMapBiases = [GREATEST_LOWER_BOUND, LEAST_UPPER_BOUND] as const;
export type SourceLocationMapper = (line: number, col: number) => vscode.Location | undefined;
export const enum SearchStrategy {
FirstBefore = -1,
FirstAfter = 1,
}
export type SourceLocationMapper = (line: number, col: number, strategy: SearchStrategy) => vscode.Location | undefined;
export class SourceMapStore {
private readonly cache = new Map</* file uri */ string, Promise<TraceMap | undefined>>();
async getSourceLocationMapper(fileUri: string) {
async getSourceLocationMapper(fileUri: string): Promise<SourceLocationMapper> {
const sourceMap = await this.loadSourceMap(fileUri);
return (line: number, col: number) => {
return (line, col, strategy) => {
if (!sourceMap) {
return undefined;
}
let smLine = line + 1;
// 1. Look for the ideal position on this line if it exists
const idealPosition = originalPositionFor(sourceMap, { column: col, line: line + 1, bias: SearchStrategy.FirstAfter ? GREATEST_LOWER_BOUND : LEAST_UPPER_BOUND });
if (idealPosition.line !== null && idealPosition.column !== null && idealPosition.source !== null) {
return new vscode.Location(
this.completeSourceMapUrl(sourceMap, idealPosition.source),
new vscode.Position(idealPosition.line - 1, idealPosition.column)
);
}
// if the range is after the end of mappings, adjust it to the last mapped line
// Otherwise get the first/last valid mapping on another line.
const decoded = decodedMappings(sourceMap);
if (decoded.length <= line) {
smLine = decoded.length; // base 1, no -1 needed
col = Number.MAX_SAFE_INTEGER;
const enum MapField {
COLUMN = 0,
SOURCES_INDEX = 1,
SOURCE_LINE = 2,
SOURCE_COLUMN = 3,
}
for (const bias of sourceMapBiases) {
const position = originalPositionFor(sourceMap, { column: col, line: smLine, bias });
if (position.line !== null && position.column !== null && position.source !== null) {
return new vscode.Location(
this.completeSourceMapUrl(sourceMap, position.source),
new vscode.Position(position.line - 1, position.column)
);
do {
line += strategy;
const segments = decoded[line];
if (!segments?.length) {
continue;
}
}
const index = strategy === SearchStrategy.FirstBefore
? findLastIndex(segments, s => s.length !== 1)
: segments.findIndex(s => s.length !== 1);
const segment = segments[index];
if (!segment || segment.length === 1) {
continue;
}
return new vscode.Location(
this.completeSourceMapUrl(sourceMap, sourceMap.sources[segment[MapField.SOURCES_INDEX]]!),
new vscode.Position(segment[MapField.SOURCE_LINE] - 1, segment[MapField.SOURCE_COLUMN])
);
} while (strategy === SearchStrategy.FirstBefore ? line > 0 : line < decoded.length);
return undefined;
};
@@ -461,7 +487,31 @@ export class SourceMapStore {
/** Gets an original location from a base 0 line and column */
async getSourceLocation(fileUri: string, line: number, col = 0) {
return this.getSourceLocationMapper(fileUri).then(m => m(line, col));
const sourceMap = await this.loadSourceMap(fileUri);
if (!sourceMap) {
return undefined;
}
let smLine = line + 1;
// if the range is after the end of mappings, adjust it to the last mapped line
const decoded = decodedMappings(sourceMap);
if (decoded.length <= line) {
smLine = decoded.length; // base 1, no -1 needed
col = Number.MAX_SAFE_INTEGER;
}
for (const bias of sourceMapBiases) {
const position = originalPositionFor(sourceMap, { column: col, line: smLine, bias });
if (position.line !== null && position.column !== null && position.source !== null) {
return new vscode.Location(
this.completeSourceMapUrl(sourceMap, position.source),
new vscode.Position(position.line - 1, position.column)
);
}
}
return undefined;
}
async getSourceFile(compiledUri: string) {
@@ -602,3 +652,13 @@ async function deriveSourceLocation(store: SourceMapStore, parts: RegExpMatchArr
const [, fileUri, line, col] = parts;
return store.getSourceLocation(fileUri, Number(line) - 1, Number(col));
}
function findLastIndex<T>(arr: T[], predicate: (value: T) => boolean) {
for (let i = arr.length - 1; i >= 0; i--) {
if (predicate(arr[i])) {
return i;
}
}
return -1;
}