Skip to content

[coverage] Partial workspace support #2095

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions .github/workflows/coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
sdk: [3.4, dev]
exclude:
# VM service times out on windows before Dart 3.5
# https://github.com/dart-lang/coverage/issues/490
- os: windows-latest
sdk: 3.4
sdk: [3.6, dev]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c
Expand Down Expand Up @@ -89,7 +84,7 @@ jobs:
name: Install dependencies
run: dart pub get
- name: Collect and report coverage
run: dart run bin/test_with_coverage.dart --port=9292
run: dart run bin/test_with_coverage.dart
- name: Upload coverage
uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b
with:
Expand Down
8 changes: 8 additions & 0 deletions pkgs/coverage/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 1.14.0

- Require Dart ^3.6.0
- Partial support for workspace packages in `test_wth_coverage`.
- Deprecate `test_wth_coverage`'s `--package-name` flag, because it doesn't make
sense for workspaces.
- Change the default `--port` to 0, allowing the VM to choose a free port.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any possibility that a user will be assuming that 8181 is being used by default? If the VM service from the spawned process is something we expect users to want to connect to, this is a breaking change in behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered that, but it seems pretty unlikely. I'd expect people to be manually specifying the port if they need that sort of thing.


## 1.13.1

- Fix a bug where the VM service can be shut down while some coverage
Expand Down
24 changes: 24 additions & 0 deletions pkgs/coverage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ dart pub global run coverage:format_coverage --packages=.dart_tool/package_confi
For more complicated use cases, where you want to control each of these stages,
see the sections below.

#### Workspaces

package:coverage has partial support for
[workspaces](https://dart.dev/tools/pub/workspaces). You can run
`test_with_coverage` from the root of the workspace to collect coverage for all
the tests in all the subpackages, but you must specify the test directories to
run.

For example, in a workspace with subpackages `pkgs/foo` and `pkgs/bar`, you
could run the following command from the root directory of the workspace:

```
dart run coverage:test_with_coverage -- pkgs/foo/test pkgs/bar/test
```

This would output coverage to ./coverage/ as normal. An important caveat is that
the working directory of the tests will be the workspace's root directory. So
this approach won't work if your tests assume that they are being run from the
subpackage directory.

[Full support](https://github.com/dart-lang/tools/issues/2083) for workspaces
will likely be added in a future version. This will mean you won't need to
explicitly specify the test directories: `dart run coverage:test_with_coverage`

#### Collecting coverage from the VM

```
Expand Down
90 changes: 46 additions & 44 deletions pkgs/coverage/bin/test_with_coverage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import 'dart:io';

import 'package:args/args.dart';
import 'package:coverage/src/coverage_options.dart';
import 'package:coverage/src/util.dart'
show StandardOutExtension, extractVMServiceUri;
import 'package:coverage/src/util.dart';
import 'package:meta/meta.dart';
import 'package:package_config/package_config.dart';
import 'package:path/path.dart' as path;

import 'collect_coverage.dart' as collect_coverage;
Expand All @@ -19,37 +17,36 @@ import 'format_coverage.dart' as format_coverage;
final _allProcesses = <Process>[];

Future<void> _dartRun(List<String> args,
{void Function(String)? onStdout, String? workingDir}) async {
final process = await Process.start(
Platform.executable,
args,
workingDirectory: workingDir,
);
{required void Function(String) onStdout,
required void Function(String) onStderr}) async {
final process = await Process.start(Platform.executable, args);
_allProcesses.add(process);
final broadStdout = process.stdout.asBroadcastStream();
broadStdout.listen(stdout.add);
if (onStdout != null) {
broadStdout.lines().listen(onStdout);

void listen(
Stream<List<int>> stream, IOSink sink, void Function(String) onLine) {
final broadStream = stream.asBroadcastStream();
broadStream.listen(sink.add);
broadStream.lines().listen(onLine);
}
process.stderr.listen(stderr.add);

listen(process.stdout, stdout, onStdout);
listen(process.stderr, stderr, onStderr);

final result = await process.exitCode;
if (result != 0) {
throw ProcessException(Platform.executable, args, '', result);
}
}

Future<String?> _packageNameFromConfig(String packageDir) async {
final config = await findPackageConfig(Directory(packageDir));
return config?.packageOf(Uri.directory(packageDir))?.name;
void _killSubprocessesAndExit(ProcessSignal signal) {
for (final process in _allProcesses) {
process.kill(signal);
}
exit(1);
}

void _watchExitSignal(ProcessSignal signal) {
signal.watch().listen((sig) {
for (final process in _allProcesses) {
process.kill(sig);
}
exit(1);
});
signal.watch().listen(_killSubprocessesAndExit);
}

ArgParser _createArgParser(CoverageOptions defaultOptions) => ArgParser()
Expand All @@ -61,10 +58,10 @@ ArgParser _createArgParser(CoverageOptions defaultOptions) => ArgParser()
..addOption(
'package-name',
help: 'Name of the package to test. '
'Deduced from --package if not provided.',
defaultsTo: defaultOptions.packageName,
'Deduced from --package if not provided. '
'DEPRECATED: use --scope-output',
)
..addOption('port', help: 'VM service port.', defaultsTo: '8181')
..addOption('port', help: 'VM service port. Defaults to using any free port.')
..addOption(
'out',
defaultsTo: defaultOptions.outputDirectory,
Expand Down Expand Up @@ -93,13 +90,13 @@ ArgParser _createArgParser(CoverageOptions defaultOptions) => ArgParser()
defaultsTo: defaultOptions.scopeOutput,
help: 'restrict coverage results so that only scripts that start with '
'the provided package path are considered. Defaults to the name of '
'the package under test.')
'the current package (including all subpackages, if this is a '
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming one can still specify which subpackages one want coverage for with --scope-output manually?

E.g.

  • pkgs/
    • my_package/
      • lib/ <-- I want coverage for this.
      • test_data/
        • my_test_package/
          • lib/ <-- I don't want coverage for this

If I use dart pub global run coverage:test_with_coverage --scope-output my_package -- pkgs/my_package/test will that do what I want?

The test projects are usually part of the workspace w.r.t. analysis. However, they are not part of the packages we want code coverage stats for.

(I know it's not what this PR is doing, because it's automating the --scope-output if not provided. But maybe we can add a test for this use case. Fine to do that in a separate PR.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scoping is based on the package URI:

package:my_package/my_package.dart
        \________/
          scope

my_test_package is a separate package with a different URI, so dart pub global run coverage:test_with_coverage -- pkgs/my_package/test should work. But yeah, you can also provide the scopes manually if you want.

'workspace).')
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');

class Flags {
Flags(
this.packageDir,
this.packageName,
this.outDir,
this.port,
this.testScript,
Expand All @@ -111,7 +108,6 @@ class Flags {
});

final String packageDir;
final String packageName;
final String outDir;
final String port;
final String testScript;
Expand Down Expand Up @@ -157,25 +153,23 @@ ${parser.usage}
fail('--package is not a valid directory.');
}

final packageName = (args['package-name'] as String?) ??
await _packageNameFromConfig(packageDir);
if (packageName == null) {
final pubspecPath = getPubspecPath(packageDir);
if (!File(pubspecPath).existsSync()) {
fail(
"Couldn't figure out package name from --package. Make sure this is a "
'package directory, or try passing --package-name explicitly.',
"Couldn't find $pubspecPath. Make sure this command is run in a "
'package directory, or pass --package to explicitly set the directory.',
);
}

return Flags(
packageDir,
packageName,
(args['out'] as String?) ?? path.join(packageDir, 'coverage'),
args['port'] as String,
args['test'] as String,
args['function-coverage'] as bool,
args['branch-coverage'] as bool,
args['scope-output'] as List<String>,
args['fail-under'] as String?,
args.option('out') ?? path.join(packageDir, 'coverage'),
args.option('port') ?? '0',
args.option('test')!,
args.flag('function-coverage'),
args.flag('branch-coverage'),
args.multiOption('scope-output'),
args.option('fail-under'),
rest: args.rest,
);
}
Expand Down Expand Up @@ -215,11 +209,19 @@ Future<void> main(List<String> arguments) async {
}
}
},
onStderr: (line) {
if (!serviceUriCompleter.isCompleted) {
if (line.contains('Could not start the VM service')) {
_killSubprocessesAndExit(ProcessSignal.sigkill);
}
}
},
);
final serviceUri = await serviceUriCompleter.future;

final scopes =
flags.scopeOutput.isEmpty ? [flags.packageName] : flags.scopeOutput;
final scopes = flags.scopeOutput.isEmpty
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This completely removes the packageName rather than deprecating it right? Better to fully remove it then if it's not supported anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be a breaking change.

? getAllWorkspaceNames(flags.packageDir)
: flags.scopeOutput;
await collect_coverage.main([
'--wait-paused',
'--resume-isolates',
Expand Down
5 changes: 5 additions & 0 deletions pkgs/coverage/dart_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
tags:
# Tests that start subprocesses, so are slower and can be a bit flaky.
integration:
timeout: 2x
retry: 3
19 changes: 11 additions & 8 deletions pkgs/coverage/lib/src/collect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ Future<Map<String, dynamic>> _getAllCoverage(
isolateReport,
includeDart,
functionCoverage,
branchCoverage,
coverableLineCache,
scopedOutput);
allCoverage.addAll(coverage);
Expand Down Expand Up @@ -244,6 +245,7 @@ Future<List<Map<String, dynamic>>> _processSourceReport(
SourceReport report,
bool includeDart,
bool functionCoverage,
bool branchCoverage,
Map<String, Set<int>>? coverableLineCache,
Set<String> scopedOutput) async {
final hitMaps = <Uri, HitMap>{};
Expand All @@ -262,7 +264,10 @@ Future<List<Map<String, dynamic>>> _processSourceReport(
return scripts[scriptRef];
}

HitMap getHitMap(Uri scriptUri) => hitMaps.putIfAbsent(scriptUri, HitMap.new);
HitMap getHitMap(Uri scriptUri) => hitMaps.putIfAbsent(
scriptUri,
() => HitMap.empty(
functionCoverage: functionCoverage, branchCoverage: branchCoverage));

Future<void> processFunction(FuncRef funcRef) async {
final func = await service.getObject(isolateRef.id!, funcRef.id!) as Func;
Expand Down Expand Up @@ -290,8 +295,7 @@ Future<List<Map<String, dynamic>>> _processSourceReport(
return;
}
final hits = getHitMap(Uri.parse(script.uri!));
hits.funcHits ??= <int, int>{};
(hits.funcNames ??= <int, String>{})[line] = funcName;
hits.funcNames![line] = funcName;
}

for (var range in report.ranges!) {
Expand Down Expand Up @@ -385,13 +389,12 @@ Future<List<Map<String, dynamic>>> _processSourceReport(
hits.funcHits?.putIfAbsent(line, () => 0);
});

final branchCoverage = range.branchCoverage;
if (branchCoverage != null) {
hits.branchHits ??= <int, int>{};
forEachLine(branchCoverage.hits, (line) {
final branches = range.branchCoverage;
if (branchCoverage && branches != null) {
forEachLine(branches.hits, (line) {
hits.branchHits!.increment(line);
});
forEachLine(branchCoverage.misses, (line) {
forEachLine(branches.misses, (line) {
hits.branchHits!.putIfAbsent(line, () => 0);
});
}
Expand Down
5 changes: 0 additions & 5 deletions pkgs/coverage/lib/src/coverage_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ class CoverageOptions {
required this.functionCoverage,
required this.branchCoverage,
required this.packageDirectory,
this.packageName,
required this.testScript,
});

Expand Down Expand Up @@ -40,8 +39,6 @@ class CoverageOptions {
branchCoverage: options.optionalBool('branch_coverage') ??
defaultOptions.branchCoverage,
packageDirectory: packageDirectory,
packageName:
options.optionalString('package_name') ?? defaultOptions.packageName,
testScript:
options.optionalString('test_script') ?? defaultOptions.testScript,
);
Expand All @@ -52,7 +49,6 @@ class CoverageOptions {
final bool functionCoverage;
final bool branchCoverage;
final String packageDirectory;
final String? packageName;
final String testScript;
}

Expand Down Expand Up @@ -119,7 +115,6 @@ class CoverageOptionsProvider {
functionCoverage: false,
branchCoverage: false,
packageDirectory: '.',
packageName: null,
testScript: 'test',
);
}
28 changes: 15 additions & 13 deletions pkgs/coverage/lib/src/formatter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ extension FileHitMapsFormatter on Map<String, HitMap> {
);
final buf = StringBuffer();
for (final entry in entries) {
final source = resolver.resolve(entry.key);
if (source == null) {
continue;
}

if (!pathFilter(source)) {
continue;
}

final lines = await loader.load(source);
if (lines == null) {
continue;
}

final v = entry.value;
if (reportFuncs && v.funcHits == null) {
throw StateError(
Expand All @@ -165,24 +179,12 @@ extension FileHitMapsFormatter on Map<String, HitMap> {
'missing branch coverage information. Did you run '
'collect_coverage with the --branch-coverage flag?');
}

final hits = reportFuncs
? v.funcHits!
: reportBranches
? v.branchHits!
: v.lineHits;
final source = resolver.resolve(entry.key);
if (source == null) {
continue;
}

if (!pathFilter(source)) {
continue;
}

final lines = await loader.load(source);
if (lines == null) {
continue;
}
buf.writeln(source);
for (var line = 1; line <= lines.length; line++) {
var prefix = _prefix;
Expand Down
9 changes: 9 additions & 0 deletions pkgs/coverage/lib/src/hitmap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ class HitMap {
this.branchHits,
]) : lineHits = lineHits ?? {};

/// Constructs an empty hitmap, optionally with function and branch coverage
/// tables.
HitMap.empty({bool functionCoverage = false, bool branchCoverage = false})
: this(
null,
functionCoverage ? <int, int>{} : null,
functionCoverage ? <int, String>{} : null,
branchCoverage ? <int, int>{} : null);

/// Map from line to hit count for that line.
final Map<int, int> lineHits;

Expand Down
Loading