Skip to content

Commit 157c80f

Browse files
committed
Add support for Coverage in Dart projects
Currently this only supports coverage for the project hosting the tests, we need `--coverage-package` to be implemented in Dart to support capturing it for other projects in the workspace like we do for Flutter. Fixes #5575
1 parent c19df0e commit 157c80f

13 files changed

Lines changed: 212 additions & 29 deletions

File tree

‎src/extension/extension.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ export async function activate(context: vs.ExtensionContext, isRestart = false)
466466

467467
util.logTime("All other stuff before debugger..");
468468

469-
const testModel = new TestModel(logger, config, util.isPathInsideFlutterProject);
469+
const testModel = new TestModel(logger, workspaceContext, config, util.isPathInsideFlutterProject);
470470
const testCoordinator = new TestSessionCoordinator(logger, testModel, analyzer.fileTracker);
471471
context.subscriptions.push(
472472
testCoordinator,

‎src/extension/test/vs_test_controller.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,17 @@ export class VsCodeTestController implements TestEventListener, IAmDisposable {
336336
this.nodeForItem.set(item, node);
337337
this.itemForNode.set(node, item);
338338

339+
const tags: vs.TestTag[] = [];
340+
if (this.isRunnableTest(node)) {
341+
tags.push(runnableTestTag);
342+
void node.suiteData.supportsCoverage.then((supportsCoverage) => {
343+
if (supportsCoverage) {
344+
item.tags = [...item.tags, runnableWithCoverageTestTag]; // Reassign to force update.
345+
}
346+
});
347+
}
348+
item.tags = tags;
349+
339350
item.children.replace(
340351
node.children.map((c) => this.createTestItem(c)),
341352
);
@@ -344,13 +355,6 @@ export class VsCodeTestController implements TestEventListener, IAmDisposable {
344355
}
345356

346357
private updateFields(item: vs.TestItem, node: TreeNode) {
347-
const tags: vs.TestTag[] = [];
348-
if (this.isRunnableTest(node)) {
349-
tags.push(runnableTestTag);
350-
if (node.suiteData.isFlutterSuite)
351-
tags.push(runnableWithCoverageTestTag);
352-
}
353-
item.tags = tags;
354358
item.description = node.description;
355359
if ((node instanceof GroupNode || node instanceof TestNode) && node.range) {
356360
// Only update locations of tests that already have locations if they are from the Outline, because

‎src/shared/capabilities/dart_test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export class DartTestCapabilities {
1111

1212
get supportsIgnoreTimeouts() { return versionIsAtLeast(this.version, "1.20.1"); }
1313
get supportsRunTestsByLine() { return versionIsAtLeast(this.version, "1.23.1"); }
14+
get supportsLcovCoverage() { return versionIsAtLeast(this.version, "1.27.0"); }
1415
}
1516

1617
export class DartTestCapabilitiesFromHelpText extends DartTestCapabilities {
@@ -27,4 +28,7 @@ export class DartTestCapabilitiesFromHelpText extends DartTestCapabilities {
2728
// above.
2829
return this.helpText.includes("--compiler");
2930
}
31+
get supportsLcovCoverage() {
32+
return this.helpText.includes("--coverage-path") && this.helpText.includes("--branch-coverage");
33+
}
3034
}

‎src/shared/test/test_model.ts‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import { IAmDisposable, Logger, Range } from "../interfaces";
55
import { ErrorNotification, PrintNotification } from "../test_protocol";
66
import { uniq } from "../utils";
77
import { DocumentCache } from "../utils/document_cache";
8-
import { isWithinPath } from "../utils/fs";
8+
import { isFlutterProjectFolder, isWithinPath } from "../utils/fs";
99
import { rangesEqual } from "../utils/positions";
1010
import { makeRegexForTests } from "../utils/test";
11+
import { locateBestProjectRoot } from "../vscode/project";
1112
import { DocumentRangeTracker } from "../vscode/trackers";
13+
import { WorkspaceContext } from "../workspace";
14+
import { getPackageTestCapabilities } from "./version";
1215

1316
export abstract class TreeNode {
1417
public abstract parent: TreeNode | undefined;
@@ -175,7 +178,12 @@ export class TestModel {
175178
// TODO: Make private?
176179
public readonly suites = new DocumentCache<SuiteData>();
177180

178-
public constructor(private readonly logger: Logger, private readonly config: { dynamicTestTracking: boolean; showSkippedTests: boolean }, private readonly isPathInsideFlutterProject: (path: string) => boolean) { }
181+
public constructor(
182+
private readonly logger: Logger,
183+
private readonly workspaceContext: WorkspaceContext,
184+
private readonly config: { dynamicTestTracking: boolean; showSkippedTests: boolean },
185+
private readonly isPathInsideFlutterProject: (path: string) => boolean,
186+
) { }
179187

180188
public addTestEventListener(listener: TestEventListener) {
181189
this.testEventListeners.push(listener);
@@ -231,7 +239,7 @@ export class TestModel {
231239
public getOrCreateSuite(suitePath: string): [SuiteData, boolean] {
232240
let suite = this.suites.getForPath(suitePath);
233241
if (!suite) {
234-
suite = new SuiteData(suitePath, this.isPathInsideFlutterProject(suitePath));
242+
suite = new SuiteData(suitePath, this.logger, this.workspaceContext);
235243
this.suites.setForPath(suitePath, suite);
236244
return [suite, true];
237245
}
@@ -562,11 +570,30 @@ export class SuiteData {
562570
private readonly groupsByName = new Map<string, GroupNode>();
563571
private readonly testsById = new Map<string, TestNode>();
564572
private readonly testsByName = new Map<string, TestNode>();
565-
constructor(public readonly path: string, public readonly isFlutterSuite: boolean) {
573+
public readonly supportsCoverage: Promise<boolean>;
574+
575+
constructor(public readonly path: string, logger: Logger, context: WorkspaceContext) {
566576
this.node = new SuiteNode(this);
577+
this.supportsCoverage = this.suiteSupportsCoverage(path, logger, context);
567578
}
568579
private static unnamedItemMarker = "<!!!###unnamed-test-item###!!!>";
569580

581+
private async suiteSupportsCoverage(path: string, logger: Logger, context: WorkspaceContext): Promise<boolean> {
582+
const projectRoot = locateBestProjectRoot(path);
583+
if (!projectRoot)
584+
return false;
585+
586+
if (isFlutterProjectFolder(projectRoot))
587+
return true;
588+
589+
try {
590+
const testCapabilities = await getPackageTestCapabilities(logger, context, projectRoot);
591+
return testCapabilities.supportsLcovCoverage;
592+
} catch {
593+
return false;
594+
}
595+
}
596+
570597
public getAllGroups(): GroupNode[] {
571598
// We need to uniq() these because we store values in the map from
572599
// other runs so that concurrent runs can look up parent nodes from

‎src/shared/utils/test.ts‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,35 @@ export function getLaunchConfig(noDebug: boolean, includeCoverage: boolean, isFl
2020
}
2121
if (runSkippedTests)
2222
toolArgs.push("--run-skipped");
23-
if (includeCoverage && isFlutter) {
24-
const coverageFilePath = path.join(os.tmpdir(), `flutter-coverage-${getRandomInt(0x1000, 0x10000).toString(16)}.lcov`);
25-
toolArgs.push("--coverage");
26-
toolArgs.push("--branch-coverage");
27-
toolArgs.push("--coverage-path");
28-
toolArgs.push(coverageFilePath);
29-
if (workspacePackageNames) {
30-
for (const packageName of workspacePackageNames) {
31-
toolArgs.push("--coverage-package", `^${escapeRegExp(packageName)}$`);
23+
if (includeCoverage) {
24+
const coverageFilePath = path.join(os.tmpdir(), `coverage-${getRandomInt(0x1000, 0x10000).toString(16)}.lcov`);
25+
if (isFlutter) {
26+
toolArgs.push("--coverage");
27+
toolArgs.push("--branch-coverage");
28+
toolArgs.push("--coverage-path");
29+
toolArgs.push(coverageFilePath);
30+
if (workspacePackageNames) {
31+
for (const packageName of workspacePackageNames) {
32+
toolArgs.push("--coverage-package", `^${escapeRegExp(packageName)}$`);
33+
}
34+
35+
template ??= {};
36+
template.coverageFilePath = coverageFilePath;
3237
}
38+
} else {
39+
toolArgs.push("--branch-coverage");
40+
toolArgs.push("--coverage-path");
41+
toolArgs.push(coverageFilePath);
42+
// TODO(dantup): Add behind a capability once Dart supports this.
43+
// if (workspacePackageNames) {
44+
// for (const packageName of workspacePackageNames) {
45+
// toolArgs.push("--coverage-package", packageName);
46+
// }
47+
// }
48+
49+
template ??= {};
50+
template.coverageFilePath = coverageFilePath;
3351
}
34-
35-
template ??= {};
36-
template.coverageFilePath = coverageFilePath;
3752
}
3853

3954
return Object.assign(

‎src/test/dart/utils/test_outline_visitor.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe("test_outline_visitor", () => {
1919
const visitor = new TestOutlineVisitor(logger, fsPath(helloWorldTestMainFile));
2020
visitor.visit(outline);
2121

22-
assert.equal(visitor.tests.length, 11);
22+
assert.equal(visitor.tests.length, 12);
2323
assert.equal(visitor.tests[0].isGroup, true);
2424
assert.equal(visitor.tests[0].fullName, "String");
2525
assert.equal(visitor.tests[1].isGroup, false);
@@ -44,5 +44,7 @@ describe("test_outline_visitor", () => {
4444
assert.equal(visitor.tests[9].fullName, `greater than with quotes ">= foo"`);
4545
assert.equal(visitor.tests[10].isGroup, false);
4646
assert.equal(visitor.tests[10].fullName, "`with backticks`");
47+
assert.equal(visitor.tests[11].isGroup, false);
48+
assert.equal(visitor.tests[11].fullName, "ensure coverage data");
4749
});
4850
});

‎src/test/dart_debug/dart_test.test.ts‎

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { strict as assert } from "assert";
22
import { writeFileSync } from "fs";
33
import * as path from "path";
4+
import { SinonStub } from "sinon";
45
import * as vs from "vscode";
56
import { URI } from "vscode-uri";
67
import { DebuggerType } from "../../shared/enums";
@@ -9,9 +10,10 @@ import { fsPath } from "../../shared/utils/fs";
910
import { TestOutlineVisitor } from "../../shared/utils/outline";
1011
import { waitFor } from "../../shared/utils/promises";
1112
import * as testUtils from "../../shared/utils/test";
13+
import { DartFileCoverage } from "../../shared/vscode/coverage";
1214
import { DartDebugClient } from "../dart_debug_client";
1315
import { createDebugClient, startDebugger, waitAllThrowIfTerminates } from "../debug_helpers";
14-
import { activateWithoutAnalysis, captureDebugSessionCustomEvents, checkTreeNodeResults, clearTestTree, currentEditor, customScriptExt, delay, ensureHasRunWithArgsStarting, fakeCancellationToken, getCodeLens, getExpectedResults, getPackages, getResolvedDebugConfiguration, helloWorldExampleSubFolderProjectTestFile, helloWorldFolder, helloWorldProjectTestFile, helloWorldTestBrokenFile, helloWorldTestDupeNameFile, helloWorldTestDynamicFile, helloWorldTestEmptyFile, helloWorldTestEnvironmentFile, helloWorldTestMainFile, helloWorldTestSelective1File, helloWorldTestSelective2File, helloWorldTestShortFile, helloWorldTestTreeFile, isTestDoneSuccessNotification, logger, makeTestTextTree, openFile as openFileBasic, positionOf, prepareHasRunFile, privateApi, setConfigForTest, setTestContent, waitForResult } from "../helpers";
16+
import { activateWithoutAnalysis, captureDebugSessionCustomEvents, checkTreeNodeResults, clearTestTree, currentEditor, customScriptExt, delay, ensureHasRunWithArgsStarting, fakeCancellationToken, getCodeLens, getExpectedResults, getPackages, getResolvedDebugConfiguration, helloWorldExampleSubFolderProjectTestFile, helloWorldFolder, helloWorldMainLibFile, helloWorldProjectTestFile, helloWorldTestBrokenFile, helloWorldTestDupeNameFile, helloWorldTestDynamicFile, helloWorldTestEmptyFile, helloWorldTestEnvironmentFile, helloWorldTestMainFile, helloWorldTestSelective1File, helloWorldTestSelective2File, helloWorldTestShortFile, helloWorldTestTreeFile, isTestDoneSuccessNotification, logger, makeTestTextTree, openFile as openFileBasic, positionOf, prepareHasRunFile, privateApi, sb, setConfigForTest, setTestContent, waitForResult } from "../helpers";
1517

1618
describe("dart test debugger", () => {
1719
// We have tests that require external packages.
@@ -636,6 +638,102 @@ test/empty_test.dart
636638
);
637639
});
638640

641+
describe("collects coverage", () => {
642+
let addCoverageStub: SinonStub | undefined;
643+
644+
beforeEach(() => {
645+
const controller = privateApi.testController;
646+
const createTestRunOriginal = controller.controller.createTestRun.bind(controller.controller);
647+
const createTestRunStub = sb.stub(controller.controller, "createTestRun");
648+
createTestRunStub.callsFake((request: vs.TestRunRequest) => {
649+
const originalResult = createTestRunOriginal(request);
650+
addCoverageStub = sb.stub(originalResult, "addCoverage").returns(null);
651+
return originalResult;
652+
});
653+
});
654+
655+
it("for a basic test", async () => {
656+
// Discover tests.
657+
await openFile(helloWorldTestMainFile);
658+
await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(helloWorldTestMainFile));
659+
const controller = privateApi.testController;
660+
661+
const suiteNode = controller.controller.items.get(`SUITE:${fsPath(helloWorldTestMainFile)}`)!;
662+
const testRequest = new vs.TestRunRequest([suiteNode]);
663+
664+
await controller.runTests(false, true, testRequest, fakeCancellationToken);
665+
666+
assert(addCoverageStub?.calledOnce);
667+
const coverage = addCoverageStub.firstCall.args[0] as DartFileCoverage;
668+
assert.equal(fsPath(coverage.uri), fsPath(helloWorldMainLibFile)); // App file, not test file.
669+
assert.ok(coverage.statementCoverage.covered > 0);
670+
assert.ok(coverage.statementCoverage.total > 0);
671+
});
672+
673+
it.skip("and includes dependencies", async () => {
674+
// Requires --coverage-package for Dart
675+
// // Discover tests.
676+
// await openFile(helloWorldExampleTestFile);
677+
// await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(helloWorldExampleTestFile));
678+
679+
// const controller = privateApi.testController;
680+
// const suiteNode = controller.controller.items.get(`SUITE:${fsPath(helloWorldExampleTestFile)}`)!;
681+
// const testRequest = new vs.TestRunRequest([suiteNode]);
682+
683+
// await controller.runTests(false, true, testRequest, fakeCancellationToken);
684+
685+
// assert(addCoverageStub?.called);
686+
// const coverageFiles = addCoverageStub.getCalls().map((call) => fsPath((call.args[0] as DartFileCoverage).uri));
687+
// assert.deepStrictEqual(coverageFiles, [fsPath(helloWorldExamplePrinterFile), fsPath(helloWorldPrinterFile)]);
688+
});
689+
690+
it.skip("and excludes configured paths", async () => {
691+
// Requires --coverage-package for Dart
692+
// await setConfigForTest("dart", "coverageExcludePatterns", ["**/example/**"]);
693+
694+
// // Discover tests.
695+
// await openFile(helloWorldExampleSubFolderProjectTestFile);
696+
// await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(helloWorldExampleSubFolderProjectTestFile));
697+
698+
// const controller = privateApi.testController;
699+
// const suiteNode = controller.controller.items.get(`SUITE:${fsPath(helloWorldExampleSubFolderProjectTestFile)}`)!;
700+
// const testRequest = new vs.TestRunRequest([suiteNode]);
701+
702+
// await controller.runTests(false, true, testRequest, fakeCancellationToken);
703+
704+
// assert(addCoverageStub?.called);
705+
// const coverageFiles = addCoverageStub.getCalls().map((call) => fsPath((call.args[0] as DartFileCoverage).uri));
706+
// assert.deepStrictEqual(coverageFiles, [fsPath(helloWorldPrinterFile)]);
707+
});
708+
709+
it.skip("uses correct regex for --coverage-package", () => {
710+
// Requires --coverage-package for Dart
711+
// const config = testUtils.getLaunchConfig(
712+
// false, // noDebug
713+
// true, // includeCoverage
714+
// true, // isFlutter
715+
// fsPath(helloWorldExampleTestFile),
716+
// undefined, // testSelection
717+
// false, // shouldRunTestByLine
718+
// undefined, // runSkipped
719+
// undefined, // template
720+
// ["hello_world", "hello_world_example"], // workspacePackageNames
721+
// );
722+
723+
// const toolArgs = (config as { toolArgs?: string[] }).toolArgs ?? [];
724+
// const coveragePackages: string[] = [];
725+
// for (let i = 0; i < toolArgs.length - 1; i++) {
726+
// if (toolArgs[i] === "--coverage-package")
727+
// coveragePackages.push(toolArgs[i + 1]);
728+
// }
729+
730+
// assert.deepStrictEqual(
731+
// coveragePackages,
732+
// [`^flutter_hello_world$`, `^flutter_hello_world_example$`],
733+
// );
734+
});
735+
});
736+
639737
function getTestRanges(testSuiteUri: vs.Uri) {
640738
const lines: string[] = [];
641739
function appendNode(item: vs.TestItem, indent = 0) {

‎src/test/helpers.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export const helloWorldTestDupeNameFile = vs.Uri.file(path.join(fsPath(helloWorl
100100
export const helloWorldTestBrokenFile = vs.Uri.file(path.join(fsPath(helloWorldTestFolder), "broken_test.dart"));
101101
export const helloWorldTestDynamicFile = vs.Uri.file(path.join(fsPath(helloWorldTestFolder), "dynamic_test.dart"));
102102
export const helloWorldProjectTestFile = vs.Uri.file(path.join(fsPath(helloWorldTestFolder), "project_test.dart"));
103+
export const helloWorldPrinterFile = vs.Uri.file(path.join(fsPath(helloWorldFolder), "lib/printer.dart"));
103104
export const helloWorldExampleSubFolderProjectTestFile = vs.Uri.file(path.join(fsPath(helloWorldExampleSubFolder), "test", "project_test.dart"));
104105
// Go To Tests
105106
export const helloWorldGoToLibFile = vs.Uri.file(path.join(fsPath(helloWorldFolder), "lib/goto/foo.dart"));
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:hello_world/printer.dart' as parent;
2+
13
void printHello() {
2-
print('Hello!');
4+
parent.printHello();
35
}

‎src/test/test_projects/hello_world/example/pubspec.yaml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,9 @@ publish_to: none
44
environment:
55
sdk: '>=2.12.0 <4.0.0'
66

7+
dependencies:
8+
hello_world:
9+
path: ../
10+
711
dev_dependencies:
8-
test:
12+
test: '^1.27.0'

0 commit comments

Comments
 (0)