diff --git a/.vscode/extensions/vscode-selfhost-test-provider/package.json b/.vscode/extensions/vscode-selfhost-test-provider/package.json index cda2d07ddf3..80ecda4306f 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/package.json +++ b/.vscode/extensions/vscode-selfhost-test-provider/package.json @@ -69,13 +69,12 @@ "test": "npx mocha --ui tdd 'out/*.test.js'" }, "devDependencies": { - "@types/mocha": "^10.0.6", - "@types/node": "18.x", - "v8-to-istanbul": "^9.2.0" + "@types/node": "18.x" }, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "ansi-styles": "^5.2.0", - "istanbul-to-vscode": "^2.0.1" + "istanbul-to-vscode": "^2.0.1", + "v8-to-istanbul": "^9.2.0" } } diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/coverageProvider.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/coverageProvider.ts index dd100be9a26..b27b69d3f53 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/coverageProvider.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/coverageProvider.ts @@ -3,121 +3,104 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { TraceMap } from '@jridgewell/trace-mapping'; import { IstanbulCoverageContext } from 'istanbul-to-vscode'; -import { SourceMapStore } from './testOutputScanner'; +import { fileURLToPath } from 'url'; import * as vscode from 'vscode'; -import { IScriptCoverage, OffsetToPosition, RangeCoverageTracker } from './v8CoverageWrangling'; -import * as v8ToIstanbul from 'v8-to-istanbul'; +import { SourceMapStore } from './sourceMapStore'; +import v8ToIstanbul = require('v8-to-istanbul'); export const istanbulCoverageContext = new IstanbulCoverageContext(); +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[]; +} + /** * Tracks coverage in per-script coverage mode. There are two modes of coverage - * in this extension: generic istanbul reports, and reports from the runtime + * in this extension: generic istanbul reports, and V8 reports from the runtime * sent before and after each test case executes. This handles the latter. */ export class PerTestCoverageTracker { private readonly scripts = new Map(); - constructor( - private readonly initialCoverage: IScriptCoverage, - private readonly maps: SourceMapStore, - ) {} + constructor(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'); + /** Adds new coverage data to the run, optionally for a test item. */ + public add(run: vscode.TestRun, coverage: IScriptCoverage, test?: vscode.TestItem) { + let script = this.scripts.get(coverage.scriptId); + if (!script) { + if (!coverage.source) { + throw new Error('expected to have source the first time a script is seen'); + } + + script = new Script(coverage.url, coverage.source, this.maps); + this.scripts.set(coverage.scriptId, script); } - const src = new Script(coverage.url, coverage.source, this.maps); + return script.add(run, coverage, test); } } 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(); + private sourceMap?: Promise; + private originalContent?: Promise; constructor( public readonly url: string, - source: string, + private readonly 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 async add(run: vscode.TestRun, coverage: IScriptCoverage, test?: vscode.TestItem) { + if (!coverage.url.startsWith('file://')) { + return; } - } - 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(); - - 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++; - } - } + const sourceMap = await (this.sourceMap ??= this.maps.loadSourceMap(coverage.url)); + const originalSource = await (this.originalContent ??= this.maps.getSourceFileContents(coverage.url)); + const istanbuled = v8ToIstanbul(fileURLToPath(coverage.url), undefined, sourceMap && originalSource + ? { source: this.source, originalSource, sourceMap: { sourcemap: sourceMap } } + : { source: this.source } + ); + await istanbuled.load(); + + const coverages = await istanbulCoverageContext.fromJson(istanbuled.toIstanbul(), { + mapFileUri: uri => this.maps.getSourceFile(uri.toString()), + mapLocation: (uri, position) => + this.maps.getSourceLocation(uri.toString(), position.line, position.character), + }); + + for (const coverage of coverages) { + if (test) { + (coverage as vscode.FileCoverage2).testItem = test; + } + run.addCoverage(coverage); + } } } diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/sourceMapStore.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/sourceMapStore.ts new file mode 100644 index 00000000000..bc29b891fd2 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/sourceMapStore.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + GREATEST_LOWER_BOUND, + LEAST_UPPER_BOUND, + originalPositionFor, + TraceMap +} from '@jridgewell/trace-mapping'; +import * as vscode from 'vscode'; +import { getContentFromFilesystem } from './testTree'; + +const inlineSourcemapRe = /^\/\/# sourceMappingURL=data:application\/json;base64,(.+)/m; +const sourceMapBiases = [GREATEST_LOWER_BOUND, LEAST_UPPER_BOUND] as const; + +export class SourceMapStore { + private readonly cache = new Map>(); + + async getSourceLocation(fileUri: string, line: number, col = 1) { + const sourceMap = await this.loadSourceMap(fileUri); + if (!sourceMap) { + return undefined; + } + + for (const bias of sourceMapBiases) { + const position = originalPositionFor(sourceMap, { column: col, line: line + 1, 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) { + const sourceMap = await this.loadSourceMap(compiledUri); + if (!sourceMap) { + return undefined; + } + + if (sourceMap.sources[0]) { + return this.completeSourceMapUrl(sourceMap, sourceMap.sources[0]); + } + + for (const bias of sourceMapBiases) { + const position = originalPositionFor(sourceMap, { column: 0, line: 1, bias }); + if (position.source !== null) { + return this.completeSourceMapUrl(sourceMap, position.source); + } + } + + return undefined; + } + + async getSourceFileContents(compiledUri: string) { + const sourceUri = await this.getSourceFile(compiledUri); + return sourceUri ? getContentFromFilesystem(sourceUri) : undefined; + } + + private completeSourceMapUrl(sm: TraceMap, source: string) { + if (sm.sourceRoot) { + try { + return vscode.Uri.parse(new URL(source, sm.sourceRoot).toString()); + } catch { + // ignored + } + } + + return vscode.Uri.parse(source); + } + + public loadSourceMap(fileUri: string) { + const existing = this.cache.get(fileUri); + if (existing) { + return existing; + } + + const promise = (async () => { + try { + const contents = await getContentFromFilesystem(vscode.Uri.parse(fileUri)); + const sourcemapMatch = inlineSourcemapRe.exec(contents); + if (!sourcemapMatch) { + return; + } + + const decoded = Buffer.from(sourcemapMatch[1], 'base64').toString(); + return new TraceMap(decoded, fileUri); + } catch (e) { + console.warn(`Error parsing sourcemap for ${fileUri}: ${(e as Error).stack}`); + return; + } + })(); + + this.cache.set(fileUri, promise); + return promise; + } +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts index 6ed9b7fd973..d0fec1fb606 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts @@ -3,19 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - GREATEST_LOWER_BOUND, - LEAST_UPPER_BOUND, - originalPositionFor, - TraceMap, -} from '@jridgewell/trace-mapping'; import * as styles from 'ansi-styles'; import { ChildProcessWithoutNullStreams } from 'child_process'; import * as vscode from 'vscode'; -import { IScriptCoverage, istanbulCoverageContext } from './coverageProvider'; +import { IScriptCoverage, PerTestCoverageTracker, istanbulCoverageContext } from './coverageProvider'; import { attachTestMessageMetadata } from './metadata'; import { snapshotComment } from './snapshot'; -import { getContentFromFilesystem } from './testTree'; +import { SourceMapStore } from './sourceMapStore'; import { StreamSplitter } from './streamSplitter'; export const enum MochaEvent { @@ -174,6 +168,7 @@ export async function scanTestOutput( let lastTest: vscode.TestItem | undefined; let ranAnyTest = false; + let perTestCoverage: PerTestCoverageTracker | undefined; try { if (cancellation.isCancellationRequested) { @@ -319,6 +314,19 @@ export async function scanTestOutput( case MochaEvent.End: // no-op, we wait until the process exits to ensure coverage is written out break; + case MochaEvent.CoverageInit: + perTestCoverage ??= new PerTestCoverageTracker(store); + enqueueExitBlocker(perTestCoverage.add(task, evt[1])); + break; + case MochaEvent.CoverageIncrement: { + const { fullTitle, coverage } = evt[1]; + const tcase = tests.get(fullTitle); + if (tcase) { + perTestCoverage ??= new PerTestCoverageTracker(store); + enqueueExitBlocker(perTestCoverage.add(task, coverage, tcase)); + } + break; + } } }); }); @@ -400,90 +408,6 @@ const tryMakeMarkdown = (message: string) => { return new vscode.MarkdownString(lines.join('\n')); }; -const inlineSourcemapRe = /^\/\/# sourceMappingURL=data:application\/json;base64,(.+)/m; -const sourceMapBiases = [GREATEST_LOWER_BOUND, LEAST_UPPER_BOUND] as const; - -export class SourceMapStore { - private readonly cache = new Map>(); - - async getSourceLocation(fileUri: string, line: number, col = 1) { - const sourceMap = await this.loadSourceMap(fileUri); - if (!sourceMap) { - return undefined; - } - - for (const bias of sourceMapBiases) { - const position = originalPositionFor(sourceMap, { column: col, line: line + 1, 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) { - const sourceMap = await this.loadSourceMap(compiledUri); - if (!sourceMap) { - return undefined; - } - - if (sourceMap.sources[0]) { - return this.completeSourceMapUrl(sourceMap, sourceMap.sources[0]); - } - - for (const bias of sourceMapBiases) { - const position = originalPositionFor(sourceMap, { column: 0, line: 1, bias }); - if (position.source !== null) { - return this.completeSourceMapUrl(sourceMap, position.source); - } - } - - return undefined; - } - - private completeSourceMapUrl(sm: TraceMap, source: string) { - if (sm.sourceRoot) { - try { - return vscode.Uri.parse(new URL(source, sm.sourceRoot).toString()); - } catch { - // ignored - } - } - - return vscode.Uri.parse(source); - } - - private loadSourceMap(fileUri: string) { - const existing = this.cache.get(fileUri); - if (existing) { - return existing; - } - - const promise = (async () => { - try { - const contents = await getContentFromFilesystem(vscode.Uri.parse(fileUri)); - const sourcemapMatch = inlineSourcemapRe.exec(contents); - if (!sourcemapMatch) { - return; - } - - const decoded = Buffer.from(sourcemapMatch[1], 'base64').toString(); - return new TraceMap(decoded, fileUri); - } catch (e) { - console.warn(`Error parsing sourcemap for ${fileUri}: ${(e as Error).stack}`); - return; - } - })(); - - this.cache.set(fileUri, promise); - return promise; - } -} - const locationRe = /(file:\/{3}.+):([0-9]+):([0-9]+)/g; async function replaceAllLocations(store: SourceMapStore, str: string) { diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/v8CoverageWrangling.test.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/v8CoverageWrangling.test.ts deleted file mode 100644 index ad22e317860..00000000000 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/v8CoverageWrangling.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 }, - ]); - }); - }); -}); diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/v8CoverageWrangling.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/v8CoverageWrangling.ts deleted file mode 100644 index 60ed7da4dd9..00000000000 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/v8CoverageWrangling.ts +++ /dev/null @@ -1,156 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { - /** - * 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] }; - } -} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json b/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json index b21867bb03d..4ae0a106f09 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json +++ b/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json @@ -4,12 +4,12 @@ "outDir": "./out", "types": [ "node", - "mocha", ] }, "include": [ "src/**/*", "../../../src/vscode-dts/vscode.d.ts", "../../../src/vscode-dts/vscode.proposed.testObserver.d.ts", + "../../../src/vscode-dts/vscode.proposed.attributableCoverage.d.ts", ] } diff --git a/.vscode/extensions/vscode-selfhost-test-provider/yarn.lock b/.vscode/extensions/vscode-selfhost-test-provider/yarn.lock index bff97849843..74a6f4caf5b 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/yarn.lock +++ b/.vscode/extensions/vscode-selfhost-test-provider/yarn.lock @@ -25,11 +25,6 @@ resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== -"@types/mocha@^10.0.6": - version "10.0.6" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.6.tgz#818551d39113081048bdddbef96701b4e8bb9d1b" - integrity sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg== - "@types/node@18.x": version "18.19.26" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.26.tgz#18991279d0a0e53675285e8cf4a0823766349729" diff --git a/test/unit/electron/index.js b/test/unit/electron/index.js index cfc2a5a9890..908fb2de97d 100644 --- a/test/unit/electron/index.js +++ b/test/unit/electron/index.js @@ -41,12 +41,13 @@ const minimist = require('minimist'); * coverage: boolean; * coveragePath: string; * coverageFormats: string | string[]; + * 'per-test-coverage': boolean; * help: boolean; * }} */ const args = minimist(process.argv.slice(2), { string: ['grep', 'run', 'runGlob', 'reporter', 'reporter-options', 'waitServer', 'timeout', 'crash-reporter-directory', 'tfs', 'coveragePath', 'coverageFormats'], - boolean: ['build', 'coverage', 'help', 'dev'], + boolean: ['build', 'coverage', 'help', 'dev', 'per-test-coverage'], alias: { 'grep': ['g', 'f'], 'runGlob': ['glob', 'runGrep'], @@ -68,10 +69,11 @@ Options: --runGlob, --glob, --runGrep only run tests matching --build run with build output (out-build) --coverage generate coverage report +--per-test-coverage generate a per-test V8 coverage report, only valid with the full-json-stream reporter --dev, --dev-tools, --devTools open dev tools, keep window open, reuse app data --reporter the mocha reporter (default: "spec") ---reporter-options the mocha reporter options (default: "") ---waitServer port to connect to and wait before running tests +--reporter-options the mocha reporter options (default: "") +--waitServer port to connect to and wait before running tests --timeout timeout for tests --crash-reporter-directory crash reporter directory --tfs TFS server URL @@ -160,7 +162,7 @@ function deserializeError(err) { class IPCRunner extends events.EventEmitter { - constructor() { + constructor(win) { super(); this.didFail = false; @@ -183,6 +185,34 @@ class IPCRunner extends events.EventEmitter { this.emit('fail', deserializeRunnable(test), deserializeError(err)); }); ipcMain.on('pending', (e, test) => this.emit('pending', deserializeRunnable(test))); + + ipcMain.handle('startCoverage', async () => { + win.webContents.debugger.attach(); + await win.webContents.debugger.sendCommand('Debugger.enable'); + await win.webContents.debugger.sendCommand('Profiler.enable'); + await win.webContents.debugger.sendCommand('Profiler.startPreciseCoverage', { + detailed: true, + allowTriggeredUpdates: false, + }); + }); + + const coverageScriptsReported = new Set(); + ipcMain.handle('snapshotCoverage', async (_, test) => { + const coverage = await win.webContents.debugger.sendCommand('Profiler.takePreciseCoverage'); + await Promise.all(coverage.result.map(async (r) => { + if (!coverageScriptsReported.has(r.scriptId)) { + coverageScriptsReported.add(r.scriptId); + const src = await win.webContents.debugger.sendCommand('Debugger.getScriptSource', { scriptId: r.scriptId }); + r.source = src.scriptSource; + } + })); + + if (!test) { + this.emit('coverage init', coverage); + } else { + this.emit('coverage increment', test, coverage); + } + }); } } @@ -274,7 +304,7 @@ app.on('ready', () => { win.loadURL(url.format({ pathname: path.join(__dirname, 'renderer.html'), protocol: 'file:', slashes: true })); - const runner = new IPCRunner(); + const runner = new IPCRunner(win); createStatsCollector(runner); // Handle renderer crashes, #117068 diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index fd22a6e9512..26e45f9ff9d 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -6,6 +6,7 @@ /*eslint-env mocha*/ const fs = require('fs'); +const inspector = require('inspector'); (function () { const originals = {}; @@ -169,9 +170,10 @@ function loadTestModules(opts) { }).then(loadModules); } -let currentTestTitle; +/** @type Mocha.Test */ +let currentTest; -function loadTests(opts) { +async function loadTests(opts) { //#region Unexpected Output @@ -185,6 +187,8 @@ function loadTests(opts) { _allowedTestOutput.push(/Deleting [0-9]+ old snapshots/); } + const perTestCoverage = opts['per-test-coverage'] ? await PerTestCoverage.init() : undefined; + const _allowedTestsWithOutput = new Set([ 'creates a snapshot', // self-testing 'validates a snapshot', // self-testing @@ -202,7 +206,7 @@ function loadTests(opts) { for (const consoleFn of [console.log, console.error, console.info, console.warn, console.trace, console.debug]) { console[consoleFn.name] = function (msg) { - if (!_allowedTestOutput.some(a => a.test(msg)) && !_allowedTestsWithOutput.has(currentTestTitle)) { + if (!_allowedTestOutput.some(a => a.test(msg)) && !_allowedTestsWithOutput.has(currentTest.title)) { _testsWithUnexpectedOutput = true; consoleFn.apply(console, arguments); } @@ -256,7 +260,7 @@ function loadTests(opts) { event.preventDefault(); // Do not log to test output, we show an error later when test ends event.stopPropagation(); - if (!_allowedTestsWithUnhandledRejections.has(currentTestTitle)) { + if (!_allowedTestsWithUnhandledRejections.has(currentTest.title)) { onUnexpectedError(event.reason); } }); @@ -275,7 +279,12 @@ function loadTests(opts) { }); }); - teardown(() => { + setup(async () => { + await perTestCoverage?.startTest(); + }); + + teardown(async () => { + await perTestCoverage?.finishTest(currentTest.file, currentTest.fullTitle()); // should not have unexpected output if (_testsWithUnexpectedOutput && !opts.dev) { @@ -410,7 +419,7 @@ function runTests(opts) { }); }); - runner.on('test', test => currentTestTitle = test.title); + runner.on('test', test => currentTest = test); if (opts.dev) { runner.on('fail', (test, err) => { @@ -432,3 +441,21 @@ ipcRenderer.on('run', (e, opts) => { ipcRenderer.send('error', err); }); }); + +class PerTestCoverage { + static async init() { + await ipcRenderer.invoke('startCoverage'); + return new PerTestCoverage(); + } + + async startTest() { + if (!this.didInit) { + this.didInit = true; + await ipcRenderer.invoke('snapshotCoverage'); + } + } + + async finishTest(file, fullTitle) { + await ipcRenderer.invoke('snapshotCoverage', { file, fullTitle }); + } +} diff --git a/test/unit/fullJsonStreamReporter.js b/test/unit/fullJsonStreamReporter.js index 07b2315a004..c92870cb0d8 100644 --- a/test/unit/fullJsonStreamReporter.js +++ b/test/unit/fullJsonStreamReporter.js @@ -29,6 +29,10 @@ module.exports = class FullJsonStreamReporter extends BaseRunner { runner.once(EVENT_RUN_BEGIN, () => writeEvent(['start', { total }])); runner.once(EVENT_RUN_END, () => writeEvent(['end', this.stats])); + // custom coverage events: + runner.on('coverage init', (c) => writeEvent(['coverageInit', c])); + runner.on('coverage increment', (context, c) => writeEvent(['coverageIncrement', context, c])); + runner.on(EVENT_TEST_BEGIN, test => writeEvent(['testStart', clean(test)])); runner.on(EVENT_TEST_PASS, test => writeEvent(['pass', clean(test)])); runner.on(EVENT_TEST_FAIL, (test, err) => {