blob: 5aa97fd1d7decd73f2b7f376d5403e671c8e03bb [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: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>[];
void _killSubprocessesAndExit(ProcessSignal signal) {
for (final process in _allProcesses) {
process.kill(signal);
}
exit(1);
}
void _watchExitSignal(ProcessSignal signal) {
signal.watch().listen(_killSubprocessesAndExit);
}
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',
help: 'The platform(s) on which to run the tests (e.g. vm, chrome).',
)
..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);
final args = parser.parse(arguments);
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);
}
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.',
);
}
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'),
args.option('platform'),
rest: args.rest,
);
}
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);
}
final pkgConfig = await findPackageConfig(Directory(flags.packageDir));
if (pkgConfig == null) {
stderr.writeln(
'warning: package_config.json was not found for ${flags.packageDir}. '
'Make sure to run "dart pub get" in the package directory.',
);
} else {
final testPkg = pkgConfig['test'] ?? pkgConfig['test_core'];
if (testPkg == null) {
stderr.writeln(
'warning: package:test is not listed in package_config.json for '
'${flags.packageDir}. Make sure to run "dart pub get" in the package '
'directory.',
);
}
}
_watchExitSignal(ProcessSignal.sighup);
_watchExitSignal(ProcessSignal.sigint);
if (!Platform.isWindows) {
_watchExitSignal(ProcessSignal.sigterm);
}
var exitCode = 0;
if (flags.functionCoverage &&
(flags.platform == null || flags.platform == 'vm')) {
final serviceUriCompleter = Completer<Uri>();
final process = await Process.start(Platform.executable, [
if (flags.branchCoverage) '--branch-coverage',
'run',
'--pause-isolates-on-exit',
'--disable-service-auth-codes',
'--enable-vm-service=${flags.port}',
flags.testScript,
...flags.rest,
], workingDirectory: flags.packageDir);
_allProcesses.add(process);
void listen(
Stream<List<int>> stream,
IOSink sink,
void Function(String) onLine,
) {
final broadStream = stream.asBroadcastStream();
broadStream.listen(sink.add);
broadStream.lines().listen(onLine);
}
listen(process.stdout, stdout, (line) {
if (!serviceUriCompleter.isCompleted) {
final uri = extractVMServiceUri(line);
if (uri != null) {
serviceUriCompleter.complete(uri);
}
}
});
listen(process.stderr, stderr, (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
? getAllWorkspaceNames(flags.packageDir)
: flags.scopeOutput;
await 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,
]);
exitCode = await process.exitCode.timeout(
const Duration(seconds: 3),
onTimeout: () {
process.kill();
return 0;
},
);
} else {
final tempDir = Directory.systemTemp.createTempSync('coverage_');
try {
final testArgs = [
'test',
'--coverage=${tempDir.path}',
if (flags.branchCoverage) '--branch-coverage',
if (flags.platform != null) ...['-p', flags.platform!],
flags.testScript,
...flags.rest,
];
final process = await Process.start(
Platform.executable,
testArgs,
workingDirectory: flags.packageDir,
mode: ProcessStartMode.inheritStdio,
);
_allProcesses.add(process);
exitCode = await process.exitCode;
final coverageFiles = tempDir
.listSync(recursive: true)
.whereType<File>()
.where((f) => f.path.endsWith('.json'))
.toList();
if (coverageFiles.isNotEmpty) {
final hitmap = await HitMap.parseFiles(
coverageFiles,
packagePath: flags.packageDir,
);
final scopes =
(flags.scopeOutput.isEmpty
? getAllWorkspaceNames(flags.packageDir)
: flags.scopeOutput)
.toSet();
final allCoverage = <Map<String, dynamic>>[];
hitmap.forEach((uriStr, map) {
var uri = Uri.tryParse(uriStr);
if (uri == null) return;
if (uri.scheme == 'file' && pkgConfig != null) {
final packageUri = pkgConfig.toPackageUri(uri);
if (packageUri != null) uri = packageUri;
}
final targetUri = uri;
final isIncluded =
scopes.isEmpty ||
(targetUri.scheme == 'package' &&
targetUri.pathSegments.isNotEmpty &&
scopes.contains(targetUri.pathSegments.first)) ||
(targetUri.scheme == 'file' &&
scopes.any((scope) {
final package = pkgConfig?[scope];
if (package != null) {
return targetUri.toString().startsWith(
package.root.toString(),
);
}
return targetUri.path.contains('/$scope/');
}));
if (isIncluded) {
allCoverage.add(hitmapToJson(map, targetUri));
}
});
final jsonOutput = jsonEncode({
'type': 'CodeCoverage',
'coverage': allCoverage,
});
File(outJson).writeAsStringSync(jsonOutput);
} else {
File(outJson).writeAsStringSync(
jsonEncode({
'type': 'CodeCoverage',
'coverage': <Map<String, dynamic>>[],
}),
);
}
} finally {
try {
tempDir.deleteSync(recursive: true);
} catch (_) {}
}
}
await format_coverage.main([
'--lcov',
'--check-ignore',
'--package=${flags.packageDir}',
'-i',
outJson,
'-o',
outLcov,
if (flags.failUnder != null) '--fail-under=${flags.failUnder}',
]);
exit(exitCode);
}