This commit is contained in:
Connor Peet
2024-05-08 15:01:23 -07:00
parent 89ee8b8cc6
commit afb251a694
8 changed files with 437 additions and 22 deletions

View File

@@ -4,5 +4,120 @@
*--------------------------------------------------------------------------------------------*/
import { IstanbulCoverageContext } from 'istanbul-to-vscode';
import { SourceMapStore } from './testOutputScanner';
import * as vscode from 'vscode';
import { IScriptCoverage, OffsetToPosition, RangeCoverageTracker } from './v8CoverageWrangling';
import * as v8ToIstanbul from 'v8-to-istanbul';
export const coverageContext = new IstanbulCoverageContext();
export const istanbulCoverageContext = new IstanbulCoverageContext();
/**
* Tracks coverage in per-script coverage mode. There are two modes of coverage
* in this extension: generic istanbul reports, and reports from the runtime
* sent before and after each test case executes. This handles the latter.
*/
export class PerTestCoverageTracker {
private readonly scripts = new Map</* script ID */ string, Script>();
constructor(
private readonly initialCoverage: IScriptCoverage,
private readonly maps: SourceMapStore,
) {}
public add(coverage: IScriptCoverage, test?: vscode.TestItem) {
const script = this.scripts.get(coverage.scriptId);
if (script) {
return script.add(coverage, test);
}
if (!coverage.source) {
throw new Error('expected to have source the first time a script is seen');
}
const src = new Script(coverage.url, coverage.source, this.maps);
}
}
class Script {
private converter: OffsetToPosition;
/** Tracking the overall coverage for the file */
private overall = new ScriptProjection();
/** Range tracking per-test item */
private readonly perItem = new Map<vscode.TestItem, ScriptProjection>();
constructor(
public readonly url: string,
source: string,
private readonly maps: SourceMapStore,
) {
this.converter = new OffsetToPosition(source);
}
public add(coverage: IScriptCoverage, test?: vscode.TestItem) {
this.overall.add(coverage);
if (test) {
const p = new ScriptProjection();
p.add(coverage);
this.perItem.set(test, p);
}
}
public report(run: vscode.TestRun) {
}
}
class ScriptProjection {
/** Range tracking for non-block coverage in the file */
private file = new RangeCoverageTracker();
/** Range tracking for block coverage in the file */
private readonly blocks = new Map<string, RangeCoverageTracker>();
public add(coverage: IScriptCoverage) {
for (const fn of coverage.functions) {
if (fn.isBlockCoverage) {
const key = `${fn.ranges[0].startOffset}/${fn.ranges[0].endOffset}`;
const block = this.blocks.get(key);
if (block) {
for (let i = 1; i < fn.ranges.length; i++) {
block.setCovered(fn.ranges[i].startOffset, fn.ranges[i].endOffset, fn.ranges[i].count > 0);
}
} else {
this.blocks.set(key, RangeCoverageTracker.initializeBlock(fn.ranges));
}
} else {
for (const range of fn.ranges) {
this.file.setCovered(range.startOffset, range.endOffset, range.count > 0);
}
}
}
}
public report(run: vscode.TestRun, convert: OffsetToPosition, item?: vscode.TestItem) {
const ranges = [...this.file];
for (const block of this.blocks.values()) {
for (const range of block) {
ranges.push(range);
}
}
let ri = 0;
ranges.sort((a, b) => a.end - b.end);
let offset = 0;
for (let i = 0; i < convert.lines.length; i++) {
const lineEnd = offset + convert.lines[i] + 1;
const coverage = new RangeCoverageTracker();
for (let i = ri; i < ranges.length && ranges[i].start < lineEnd; i++) {
coverage.setCovered(ranges[i].start - offset, ranges[i].end - offset, ranges[i].covered);
}
while (ri < ranges.length && ranges[ri].end < lineEnd) {
ri++;
}
}
}
}

View File

@@ -7,7 +7,7 @@ import { randomBytes } from 'crypto';
import { tmpdir } from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import { coverageContext } from './coverageProvider';
import { istanbulCoverageContext } from './coverageProvider';
import { FailingDeepStrictEqualAssertFixer } from './failingDeepStrictEqualAssertFixer';
import { registerSnapshotUpdate } from './snapshot';
import { scanTestOutput } from './testOutputScanner';
@@ -86,17 +86,20 @@ export async function activate(context: vscode.ExtensionContext) {
let coverageDir: string | undefined;
let currentArgs = args;
if (kind === vscode.TestRunProfileKind.Coverage) {
coverageDir = path.join(tmpdir(), `vscode-test-coverage-${randomBytes(8).toString('hex')}`);
currentArgs = [
...currentArgs,
'--coverage',
'--coveragePath',
coverageDir,
'--coverageFormats',
'json',
'--coverageFormats',
'html',
];
// todo: browser runs currently don't support per-test coverage
if (args.includes('--browser')) {
coverageDir = path.join(tmpdir(), `vscode-test-coverage-${randomBytes(8).toString('hex')}`);
currentArgs = [
...currentArgs,
'--coverage',
'--coveragePath',
coverageDir,
'--coverageFormats',
'json',
];
} else {
currentArgs = [...currentArgs, '--per-test-coverage'];
}
}
return await scanTestOutput(
@@ -180,7 +183,7 @@ export async function activate(context: vscode.ExtensionContext) {
true
);
coverage.loadDetailedCoverage = coverageContext.loadDetailedCoverage;
coverage.loadDetailedCoverage = istanbulCoverageContext.loadDetailedCoverage;
for (const [name, arg] of browserArgs) {
const cfg = ctrl.createRunProfile(

View File

@@ -12,7 +12,7 @@ import {
import * as styles from 'ansi-styles';
import { ChildProcessWithoutNullStreams } from 'child_process';
import * as vscode from 'vscode';
import { coverageContext } from './coverageProvider';
import { IScriptCoverage, istanbulCoverageContext } from './coverageProvider';
import { attachTestMessageMetadata } from './metadata';
import { snapshotComment } from './snapshot';
import { getContentFromFilesystem } from './testTree';
@@ -24,6 +24,10 @@ export const enum MochaEvent {
Pass = 'pass',
Fail = 'fail',
End = 'end',
// custom events:
CoverageInit = 'coverage init',
CoverageIncrement = 'coverage increment',
}
export interface IStartEvent {
@@ -62,12 +66,20 @@ export interface IEndEvent {
end: string /* ISO date */;
}
export interface ITestCoverageCoverage {
file: string;
fullTitle: string;
coverage: IScriptCoverage;
}
export type MochaEventTuple =
| [MochaEvent.Start, IStartEvent]
| [MochaEvent.TestStart, ITestStartEvent]
| [MochaEvent.Pass, IPassEvent]
| [MochaEvent.Fail, IFailEvent]
| [MochaEvent.End, IEndEvent];
| [MochaEvent.End, IEndEvent]
| [MochaEvent.CoverageInit, IScriptCoverage]
| [MochaEvent.CoverageIncrement, ITestCoverageCoverage];
const LF = '\n'.charCodeAt(0);
@@ -315,7 +327,7 @@ export async function scanTestOutput(
if (coverageDir) {
try {
await coverageContext.apply(task, coverageDir, {
await istanbulCoverageContext.apply(task, coverageDir, {
mapFileUri: uri => store.getSourceFile(uri.toString()),
mapLocation: (uri, position) =>
store.getSourceLocation(uri.toString(), position.line, position.character),

View File

@@ -0,0 +1,106 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { RangeCoverageTracker } from './v8CoverageWrangling';
suite('v8CoverageWrangling', () => {
suite('RangeCoverageTracker', () => {
test('covers new range', () => {
const rt = new RangeCoverageTracker();
rt.cover(5, 10);
assert.deepStrictEqual([...rt], [{ start: 5, end: 10, covered: true }]);
});
test('non overlapping ranges', () => {
const rt = new RangeCoverageTracker();
rt.cover(5, 10);
rt.cover(15, 20);
assert.deepStrictEqual([...rt], [
{ start: 5, end: 10, covered: true },
{ start: 15, end: 20, covered: true },
]);
});
test('covers exact', () => {
const rt = new RangeCoverageTracker();
rt.uncovered(5, 10);
rt.cover(5, 10);
assert.deepStrictEqual([...rt], [
{ start: 5, end: 10, covered: true },
]);
});
test('overlap at start', () => {
const rt = new RangeCoverageTracker();
rt.uncovered(5, 10);
rt.cover(2, 7);
assert.deepStrictEqual([...rt], [
{ start: 2, end: 5, covered: true },
{ start: 5, end: 7, covered: true },
{ start: 7, end: 10, covered: false },
]);
});
test('overlap at end', () => {
const rt = new RangeCoverageTracker();
rt.cover(5, 10);
rt.uncovered(2, 7);
assert.deepStrictEqual([...rt], [
{ start: 2, end: 5, covered: false },
{ start: 5, end: 7, covered: true },
{ start: 7, end: 10, covered: true },
]);
});
test('inner contained', () => {
const rt = new RangeCoverageTracker();
rt.cover(5, 10);
rt.uncovered(2, 12);
assert.deepStrictEqual([...rt], [
{ start: 2, end: 5, covered: false },
{ start: 5, end: 10, covered: true },
{ start: 10, end: 12, covered: false },
]);
});
test('outer contained', () => {
const rt = new RangeCoverageTracker();
rt.uncovered(5, 10);
rt.cover(7, 9);
assert.deepStrictEqual([...rt], [
{ start: 5, end: 7, covered: false },
{ start: 7, end: 9, covered: true },
{ start: 9, end: 10, covered: false },
]);
});
test('boundary touching', () => {
const rt = new RangeCoverageTracker();
rt.uncovered(5, 10);
rt.cover(10, 15);
rt.uncovered(15, 20);
assert.deepStrictEqual([...rt], [
{ start: 5, end: 10, covered: false },
{ start: 10, end: 15, covered: true },
{ start: 15, end: 20, covered: false },
]);
});
test('initializeBlock', () => {
const rt = RangeCoverageTracker.initializeBlock([
{ count: 1, startOffset: 5, endOffset: 30 },
{ count: 1, startOffset: 8, endOffset: 10 },
{ count: 0, startOffset: 15, endOffset: 20 },
]);
assert.deepStrictEqual([...rt], [
{ start: 5, end: 15, covered: true },
{ start: 15, end: 20, covered: false },
{ start: 20, end: 30, covered: true },
]);
});
});
});

View File

@@ -0,0 +1,156 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export interface ICoverageRange {
start: number;
end: number;
covered: boolean;
}
export interface IV8FunctionCoverage {
functionName: string;
isBlockCoverage: boolean;
ranges: IV8CoverageRange[];
}
export interface IV8CoverageRange {
startOffset: number;
endOffset: number;
count: number;
}
/** V8 Script coverage data */
export interface IScriptCoverage {
scriptId: string;
url: string;
// Script source added by the runner the first time the script is emitted.
source?: string;
functions: IV8FunctionCoverage[];
}
export class RangeCoverageTracker implements Iterable<ICoverageRange> {
/**
* A noncontiguous, non-overlapping, ordered set of ranges and whether
* that range has been covered.
*/
private ranges: readonly ICoverageRange[] = [];
/**
* Adds a coverage tracker initialized for a function with {@link isBlockCoverage} set to true.
*/
public static initializeBlock(ranges: IV8CoverageRange[]) {
let start = ranges[0].startOffset;
const rt = new RangeCoverageTracker();
if (!ranges[0].count) {
rt.uncovered(start, ranges[0].endOffset);
return rt;
}
for (let i = 1; i < ranges.length; i++) {
const range = ranges[i];
if (range.count) {
continue;
}
rt.cover(start, range.startOffset);
rt.uncovered(range.startOffset, range.endOffset);
start = range.endOffset;
}
rt.cover(start, ranges[0].endOffset);
return rt;
}
/** Marks a range covered */
public cover(start: number, end: number) {
this.setCovered(start, end, true);
}
/** Marks a range as uncovered */
public uncovered(start: number, end: number) {
this.setCovered(start, end, false);
}
/** Iterates over coverage ranges */
[Symbol.iterator]() {
return this.ranges[Symbol.iterator]();
}
public setCovered(start: number, end: number, covered: boolean) {
const newRanges: ICoverageRange[] = [];
let i = 0;
for (; i < this.ranges.length && this.ranges[i].end <= start; i++) {
newRanges.push(this.ranges[i]);
}
newRanges.push({ start, end, covered });
for (; i < this.ranges.length; i++) {
const range = this.ranges[i];
const last = newRanges[newRanges.length - 1];
if (range.start < last.start && range.end > last.end) {
// range contains last:
newRanges.pop();
newRanges.push({ start: range.start, end: last.start, covered: range.covered });
newRanges.push({ start: last.start, end: last.end, covered: range.covered || last.covered });
newRanges.push({ start: last.end, end: range.end, covered: range.covered });
} else if (range.start > last.start && range.end <= last.end) {
// last contains range:
newRanges.pop();
newRanges.push({ start: last.start, end: range.start, covered: last.covered });
newRanges.push({ start: range.start, end: range.end, covered: range.covered || last.covered });
newRanges.push({ start: range.end, end: last.end, covered: last.covered });
} else if (range.start < last.start && range.end <= last.end) {
// range overlaps start of last:
newRanges.pop();
newRanges.push({ start: range.start, end: last.start, covered: range.covered });
newRanges.push({ start: last.start, end: range.end, covered: range.covered || last.covered });
newRanges.push({ start: range.end, end: last.end, covered: last.covered });
} else if (range.start > last.start && range.end > last.end) {
// range overlaps end of last:
newRanges.pop();
newRanges.push({ start: last.start, end: range.start, covered: last.covered });
newRanges.push({ start: range.start, end: last.end, covered: range.covered || last.covered });
newRanges.push({ start: last.end, end: range.end, covered: range.covered });
} else {
// ranges are equal:
last.covered ||= range.covered;
}
}
this.ranges = newRanges;
}
}
export class OffsetToPosition {
/** Line numbers to byte offsets. */
public readonly lines: number[] = [];
constructor(public readonly source: string) {
this.lines.push(0);
for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1)) {
this.lines.push(i + 1);
}
}
/**
* Converts from a file offset to a base 0 line/column .
*/
public convert(offset: number): { line: number; column: number } {
let low = 0;
let high = this.lines.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (this.lines[mid] > offset) {
high = mid;
} else {
low = mid + 1;
}
}
return { line: low - 1, column: offset - this.lines[low - 1] };
}
}