blob: c1c97c1fdbdc1f5d319e26bc115bc2429574b13b [file] [edit]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:cli_util/cli_util.dart';
import 'package:coverage/src/coverage_options.dart';
import 'package:coverage/src/hitmap.dart';
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;
import 'format_coverage.dart' as format_coverage;
final _allProcesses = <Process>[];
String get _dartExecutable =>
dartExecutable ?? (throw StateError('Could not locate a Dart executable.'));
const _supportedPlatforms = ['vm', 'chrome'];
Future<void> _dartRun(
List<String> args, {
void Function(String)? onStdout,
void Function(String)? onStderr,
String? workingDirectory,
}) async {
final process = await Process.start(
_dartExecutable,
args,
workingDirectory: workingDirectory,
);
_allProcesses.add(process);
void listen(
Stream<List<int>> stream,
IOSink sink,
void Function(String)? onLine,
) {
final broadStream = stream.asBroadcastStream();
broadStream.listen(sink.add);
if (onLine != null) {
broadStream.lines().listen(onLine);
}
}
listen(process.stdout, stdout, onStdout);
listen(process.stderr, stderr, onStderr);
final result = await process.exitCode;
if (result != 0) {
throw ProcessException(_dartExecutable, args, '', result);
}
}
void _killSubprocessesAndExit(ProcessSignal signal) {
for (final process in _allProcesses) {
process.kill(signal);
}
exit(1);
}
void _watchExitSignal(ProcessSignal signal) {
signal.watch().listen(_killSubprocessesAndExit);
}
void _registerSignalHandlers() {
_watchExitSignal(ProcessSignal.sighup);
_watchExitSignal(ProcessSignal.sigint);
if (!Platform.isWindows) {
_watchExitSignal(ProcessSignal.sigterm);
}
}
ArgParser _createArgParser(CoverageOptions defaultOptions) => ArgParser()
..addOption(
'package',
help: 'Root directory of the package to test.',
defaultsTo: defaultOptions.packageDirectory,
)
..addOption(
'package-name',
help:
'Name of the package to test. '
'Deduced from --package if not provided. '
'DEPRECATED: use --scope-output',
)
..addOption('port', help: 'VM service port. Defaults to using any free port.')
..addOption(
'out',
defaultsTo: defaultOptions.outputDirectory,
abbr: 'o',
help: 'Output directory. Defaults to <package-dir>/coverage.',
)
..addOption(
'test',
help: 'Test script or directory to run.',
defaultsTo: defaultOptions.testScript,
)
..addFlag(
'function-coverage',
abbr: 'f',
defaultsTo: defaultOptions.functionCoverage,
help: 'Collect function coverage info.',
)
..addFlag(
'branch-coverage',
abbr: 'b',
defaultsTo: defaultOptions.branchCoverage,
help: 'Collect branch coverage info.',
)
..addOption(
'fail-under',
help: 'Fail if coverage is less than the given percentage (0-100)',
)
..addMultiOption(
'scope-output',
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 current package (including all subpackages, if this is a '
'workspace).',
)
..addOption(
'platform',
abbr: 'p',
allowed: _supportedPlatforms,
help:
'The platform on which to run the tests. '
'Supported: ${_supportedPlatforms.join(', ')}. '
'Web coverage is measured on compiled JavaScript, so its line counts '
'and percentages are not comparable to vm: code the compiler drops '
'cannot be reported as uncovered. Re-tune --fail-under per platform.',
)
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
class Flags {
Flags(
this.packageDir,
this.outDir,
this.port,
this.testScript,
this.functionCoverage,
this.branchCoverage,
this.scopeOutput,
this.failUnder,
this.platform, {
required this.rest,
});
final String packageDir;
final String outDir;
final String port;
final String testScript;
final bool functionCoverage;
final bool branchCoverage;
final List<String> scopeOutput;
final String? failUnder;
final String? platform;
final List<String> rest;
}
@visibleForTesting
Future<Flags> parseArgs(
List<String> arguments,
CoverageOptions defaultOptions,
) async {
final parser = _createArgParser(defaultOptions);
void printUsage() {
print('''
Runs tests and collects coverage for a package.
By default this script assumes it's being run from the root directory of a
package, and outputs a coverage.json and lcov.info to ./coverage/
Usage: test_with_coverage [OPTIONS...] [-- <test script OPTIONS>]
${parser.usage}
''');
}
Never fail(String msg) {
print('\n$msg\n');
printUsage();
exit(1);
}
final ArgResults args;
try {
args = parser.parse(arguments);
} on ArgParserException catch (e) {
fail(e.message);
}
if (args['help'] as bool) {
printUsage();
exit(0);
}
final packageDir = path.normalize(path.absolute(args['package'] as String));
if (!FileSystemEntity.isDirectorySync(packageDir)) {
fail('--package is not a valid directory.');
}
final pubspecPath = getPubspecPath(packageDir);
if (!File(pubspecPath).existsSync()) {
fail(
"Couldn't find $pubspecPath. Make sure this command is run in a "
'package directory, or pass --package to explicitly set the directory.',
);
}
final platform = args.option('platform');
if (platform == 'chrome') {
if (args.flag('function-coverage')) {
fail('--function-coverage is not supported for web platforms.');
}
if (args.flag('branch-coverage')) {
fail('--branch-coverage is not supported for web platforms.');
}
}
// Extra args are forwarded to `dart test`, where a second platform flag
// would silently override the validated one.
for (final arg in args.rest) {
if (arg == '-p' ||
arg == '--platform' ||
arg.startsWith('-p=') ||
arg.startsWith('--platform=')) {
fail(
'Pass the platform with --platform, not as an argument to the test '
'script. Coverage can only be collected for the platform this '
'command runs.',
);
}
}
return Flags(
packageDir,
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'),
platform,
rest: args.rest,
);
}
Future<void> _validatePackageConfig(String packageDir) async {
final pkgConfig = await findPackageConfig(Directory(packageDir));
if (pkgConfig == null) {
stderr.writeln(
'warning: package_config.json was not found for $packageDir. '
'Make sure to run "dart pub get" in the package directory.',
);
return;
}
final testPkg = pkgConfig['test'] ?? pkgConfig['test_core'];
if (testPkg == null) {
stderr.writeln(
'warning: package:test is not listed in package_config.json for '
'$packageDir. Make sure to run "dart pub get" in the package '
'directory.',
);
}
}
Future<int> _runVmTestsAndCollectCoverage(Flags flags, String outJson) async {
final serviceUriCompleter = Completer<Uri>();
final testArgs = [
if (flags.branchCoverage) '--branch-coverage',
'run',
'--pause-isolates-on-exit',
'--disable-service-auth-codes',
'--enable-vm-service=${flags.port}',
flags.testScript,
...flags.rest,
];
final testProcess = _dartRun(
testArgs,
workingDirectory: flags.packageDir,
onStdout: (line) {
if (!serviceUriCompleter.isCompleted) {
final uri = extractVMServiceUri(line);
if (uri != null) {
serviceUriCompleter.complete(uri);
}
}
},
onStderr: (line) {
if (!serviceUriCompleter.isCompleted &&
line.contains('Could not start the VM service')) {
_killSubprocessesAndExit(ProcessSignal.sigkill);
}
},
);
var testExitCode = 0;
final testProcessDone = testProcess
.then((_) => 0)
.catchError(
(Object error) => testExitCode = (error as ProcessException).errorCode,
test: (error) => error is ProcessException,
);
// If the test process exits before reporting a VM service URI there is
// nothing to collect from, and waiting for the URI would hang forever.
unawaited(
testProcessDone.then((code) {
if (!serviceUriCompleter.isCompleted) {
stderr.writeln(
'Test process exited before the VM service was ready '
'(exit code $code).',
);
exit(code == 0 ? 1 : code);
}
}),
);
final serviceUri = await serviceUriCompleter.future;
final scopes = flags.scopeOutput.isEmpty
? getAllWorkspaceNames(flags.packageDir)
: flags.scopeOutput;
final collection = collect_coverage.main([
'--wait-paused',
'--resume-isolates',
'--uri=$serviceUri',
for (final scope in scopes) '--scope-output=$scope',
if (flags.branchCoverage) '--branch-coverage',
if (flags.functionCoverage) '--function-coverage',
'-o',
outJson,
]);
// The test process is paused on exit, so collection is what lets it
// finish. If it exits with an error first, it never reached that pause
// (for example, the test script failed to load) and `--wait-paused` would
// wait forever. Some SDKs report a service URI even in that case, so the
// check above is not enough on its own.
final collected = await Future.any<Object?>([
collection.then((_) => null),
testProcessDone,
]);
if (collected is int && collected != 0) {
stderr.writeln(
'Test process exited with code $collected before coverage collection '
'finished.',
);
exit(collected);
}
await collection;
return testProcessDone.timeout(
const Duration(seconds: 3),
onTimeout: () {
for (final process in _allProcesses) {
process.kill();
}
return testExitCode;
},
);
}
/// Writes intermediate `coverage.json` (`outJson`) for [_formatCoverage] to
/// format into `lcov.info` (`outLcov`), matching `collect_coverage.main` in
/// the VM flow.
Future<void> _writeWebCoverageJson(
List<File> coverageFiles,
Flags flags,
String outJson,
) async {
if (coverageFiles.isEmpty) {
stderr.writeln(
'warning: the test run produced no coverage files; the report will '
'be empty.',
);
File(outJson).writeAsStringSync(
jsonEncode({
'type': 'CodeCoverage',
'coverage': <Map<String, dynamic>>[],
}),
);
return;
}
final scopes =
(flags.scopeOutput.isEmpty
? getAllWorkspaceNames(flags.packageDir)
: flags.scopeOutput)
.toSet();
final hitmap = await HitMap.parseFiles(
coverageFiles,
packagePath: flags.packageDir,
scopeOutput: scopes,
);
if (hitmap.isEmpty) {
stderr.writeln(
'warning: no coverage data matched ${scopes.join(', ')}. '
'Compiled web output may not map back to library sources.',
);
}
final allCoverage = [
for (final MapEntry(key: uriStr, value: map) in hitmap.entries)
hitmapToJson(map, Uri.parse(uriStr)),
];
final jsonOutput = jsonEncode({
'type': 'CodeCoverage',
'coverage': allCoverage,
});
File(outJson).writeAsStringSync(jsonOutput);
}
Future<int> _runWebTestsAndCollectCoverage(Flags flags, String outJson) async {
await _validatePackageConfig(flags.packageDir);
final tempDir = Directory.systemTemp.createTempSync('coverage_');
try {
final testArgs = [
'test',
'--coverage=${tempDir.path}',
if (flags.branchCoverage) '--branch-coverage',
'-p',
flags.platform!,
if (flags.testScript != 'test') flags.testScript,
...flags.rest,
];
var exitCode = 0;
try {
await _dartRun(testArgs, workingDirectory: flags.packageDir);
} on ProcessException catch (e) {
exitCode = e.errorCode;
}
final coverageFiles = tempDir
.listSync(recursive: true)
.whereType<File>()
.where((f) => f.path.endsWith('.json'))
.toList();
await _writeWebCoverageJson(coverageFiles, flags, outJson);
return exitCode;
} finally {
try {
tempDir.deleteSync(recursive: true);
} catch (_) {}
}
}
Future<void> _formatCoverage(Flags flags, String outJson, String outLcov) =>
format_coverage.main([
'--lcov',
'--check-ignore',
'--package=${flags.packageDir}',
'-i',
outJson,
'-o',
outLcov,
if (flags.failUnder != null) '--fail-under=${flags.failUnder}',
]);
Future<void> main(List<String> arguments) async {
final defaultOptions = CoverageOptionsProvider().coverageOptions;
final flags = await parseArgs(arguments, defaultOptions);
final outJson = path.join(flags.outDir, 'coverage.json');
final outLcov = path.join(flags.outDir, 'lcov.info');
if (!FileSystemEntity.isDirectorySync(flags.outDir)) {
await Directory(flags.outDir).create(recursive: true);
}
_registerSignalHandlers();
final isVm = flags.platform == null || flags.platform == 'vm';
final exitCode = isVm
? await _runVmTestsAndCollectCoverage(flags, outJson)
: await _runWebTestsAndCollectCoverage(flags, outJson);
await _formatCoverage(flags, outJson, outLcov);
exit(exitCode);
}