| // 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); |
| } |