diff --git a/packages/patrol/lib/src/constants.dart b/packages/patrol/lib/src/constants.dart index 01440e6ceb..a393dfcb07 100644 --- a/packages/patrol/lib/src/constants.dart +++ b/packages/patrol/lib/src/constants.dart @@ -3,3 +3,15 @@ import 'package:meta/meta.dart'; /// Whether Hot Restart is enabled. @internal const bool hotRestartEnabled = bool.fromEnvironment('PATROL_HOT_RESTART'); +/// Whether coverage is enabled. +@internal +const bool coverage = bool.fromEnvironment('PATROL_COVERAGE'); +/// Collect function coverage info +@internal +const bool functionCoverage = bool.fromEnvironment('PATROL_FUNCTION_COVERAGE'); +/// A regular expression matching packages names to include in the coverage report. +@internal +const String coveragePackageList = String.fromEnvironment('PATROL_COVERAGE_PACKAGES'); +/// The package config contents. +@internal +const String packageConfig = String.fromEnvironment('PATROL_PACKAGE_CONFIG'); \ No newline at end of file diff --git a/packages/patrol/lib/src/coverage/coverage_collector.dart b/packages/patrol/lib/src/coverage/coverage_collector.dart new file mode 100644 index 0000000000..739844a802 --- /dev/null +++ b/packages/patrol/lib/src/coverage/coverage_collector.dart @@ -0,0 +1,169 @@ +// ignore_for_file: avoid_print + +// TODO: Use a logger instead of print + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:coverage/coverage.dart' as coverage; +import 'package:path_provider/path_provider.dart'; +import 'package:patrol/src/coverage/coverage_options.dart'; +import 'package:vm_service/vm_service.dart'; +import 'package:vm_service/vm_service_io.dart'; + +import 'server_uri_processor.dart'; + +/// A singleton class responsible for collecting and managing code coverage data. +class CoverageCollector { + + /// Returns the singleton instance of [CoverageCollector]. + factory CoverageCollector() => _instance; + CoverageCollector._internal(); + static final CoverageCollector _instance = CoverageCollector._internal(); + + late final CoverageOptions _options; + + VmService? _service; + Set? _libraryNames; + + bool _isInitialized = false; + bool _isRunning = false; + final _completer = Completer(); + + String? _currentObservatoryUrlWs; + String? _currentObservatoryUrlHttp; + + late ServerUriProcessor _serverUriProcessor; + + /// Initializes the CoverageCollector with required dependencies. + Future initialize({CoverageOptions? options}) async { + if (_isInitialized) { + return; + } + _options = options ?? const CoverageOptions(); + _isInitialized = true; + _libraryNames = await _options.getCoveragePackages(); + + // Initialize ServerUriProcessor + _serverUriProcessor = ServerUriProcessor(_handleServerUri); + } + + /// Starts the coverage collection process in the background. + Future startInBackground() async { + _ensureInitialized(); + _isRunning = true; + + unawaited(_serverUriProcessor.start()); + unawaited(_runCoverageCollection()); + } + + /// Stops the coverage collection process and writes the collected data. + Future stop() async { + if (!_isRunning) { + return; + } + + _isRunning = false; + await _serverUriProcessor.stop(); + await _service?.dispose(); + _completer.complete(); + } + + /// Collects coverage data for a completed test. + Future handleTestCompletion(String testName) async { + if (!_isRunning || _service == null) { + print('Not running or service is null'); + return; + } + final vm = await _service!.getVM(); + final isolateId = vm.isolates!.first.id!; + await collectCoverage(isolateId); + await stop(); + print('Collecting coverage for test: $testName'); + } + + void _ensureInitialized() { + if (!_isInitialized) { + throw StateError('CoverageCollector is not initialized'); + } + } + + Future _runCoverageCollection() async { + try { + await _completer.future; + } catch (e) { + print('Error running coverage collection: $e'); + } + } + + /// Handles the server URI when it becomes available. + void _handleServerUri(Uri serverUri) { + _currentObservatoryUrlHttp = serverUri.toString(); + _currentObservatoryUrlWs = _convertToWebSocketUrl(_currentObservatoryUrlHttp!); + _connectToVmService(); + } + + Future __writeCoverageDataToJsonFile(String coverageJsonData) async { + try { + Directory? downloadsDir; + if (Platform.isAndroid) { + downloadsDir = Directory('/sdcard/Download'); + } else { + downloadsDir = await getDownloadsDirectory(); + } + + final file = File('${downloadsDir!.path}/coverage_${DateTime.now().millisecondsSinceEpoch}.json') + ..createSync(recursive: true) + ..writeAsStringSync(coverageJsonData, flush: true); + + print('Wrote coverage data to ${file.path}'); + } catch (err) { + print('Error writing coverage data: $err'); + } + } + + /// Collects coverage data for a specific isolate. + Future collectCoverage(String isolateId) async { + final libraryNamesList = _libraryNames?.toList(); + if (libraryNamesList == null || libraryNamesList.isEmpty) { + return; + } + + final data = await _collectCoverageData(libraryNamesList); + await __writeCoverageDataToJsonFile(jsonEncode(data)); + } + + Future _connectToVmService() async { + final wsUrl = _convertToWebSocketUrl(_currentObservatoryUrlWs!); + + try { + _service = await vmServiceConnectUri(wsUrl); + } catch (e) { + _isRunning = false; + _completer.complete(); + rethrow; + } + } + + Future> _collectCoverageData(List libraryNamesList) async { + return coverage.collect( + Uri.parse(_currentObservatoryUrlHttp!), + true, + false, + false, + libraryNamesList.toSet(), + branchCoverage: _options.branchCoverage, + functionCoverage: _options.functionCoverage, + timeout: _options.timeout, + ); + } + + String _convertToWebSocketUrl(String observatoryUri) { + var observatoryUriWs = observatoryUri.replaceFirst('http://', 'ws://'); + if (!observatoryUriWs.endsWith('/ws')) { + observatoryUriWs += 'ws'; + } + return observatoryUriWs; + } +} diff --git a/packages/patrol/lib/src/coverage/coverage_options.dart b/packages/patrol/lib/src/coverage/coverage_options.dart new file mode 100644 index 0000000000..f28e8c6255 --- /dev/null +++ b/packages/patrol/lib/src/coverage/coverage_options.dart @@ -0,0 +1,34 @@ +import '../constants.dart' as constants; + +/// Class representing coverage options for generating coverage reports. +class CoverageOptions { + /// Creates an instance of [CoverageOptions] with the given parameters. + const CoverageOptions({ + this.coverage = true, + this.timeout = const Duration(minutes: 1), + this.functionCoverage = false, + this.branchCoverage = false, + this.coveragePackageConfig = '', + }); + + /// Whether to include coverage information. + final bool coverage; + + /// Timeout duration for connecting, in seconds. + final Duration timeout; + + /// Whether to include function coverage. + final bool functionCoverage; + + /// Whether to include branch coverage. + final bool branchCoverage; + + /// Path to the coverage package configuration file. + final String? coveragePackageConfig; + + /// Returns the coverage packages to include in the coverage report. + Future> getCoveragePackages() async { + final packagesToInclude = constants.coveragePackageList.split(','); + return packagesToInclude.toSet(); + } +} diff --git a/packages/patrol/lib/src/coverage/server_uri_processor.dart b/packages/patrol/lib/src/coverage/server_uri_processor.dart new file mode 100644 index 0000000000..b652489e42 --- /dev/null +++ b/packages/patrol/lib/src/coverage/server_uri_processor.dart @@ -0,0 +1,41 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +/// A class that processes the server URI by periodically checking for it. +class ServerUriProcessor { + /// Creates an instance of [ServerUriProcessor] with a callback function to handle the server URI. + ServerUriProcessor(this.onServerUri); + + /// A callback function that handles the server URI when it's found. + final void Function(Uri) onServerUri; + + Timer? _checkTimer; + + /// Starts the server URI processing by initiating periodic checks. + Future start() async { + _startListening(); + } + + /// Stops the server URI processing by cancelling the periodic checks. + Future stop() async { + _checkTimer?.cancel(); + } + + void _startListening() { + _checkTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _checkForServerUri(); + }); + } + + Future _checkForServerUri() async { + try { + final info = await developer.Service.getInfo(); + if (info.serverUri != null) { + onServerUri(info.serverUri!); + await stop(); + } + } catch (e) { + developer.log('Error checking for server URI: $e'); + } + } +} diff --git a/packages/patrol/lib/src/native/patrol_app_service.dart b/packages/patrol/lib/src/native/patrol_app_service.dart index 5749ae8714..f01bdfb933 100644 --- a/packages/patrol/lib/src/native/patrol_app_service.dart +++ b/packages/patrol/lib/src/native/patrol_app_service.dart @@ -3,14 +3,19 @@ // TODO: Use a logger instead of print import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:patrol/src/common.dart'; +import 'package:patrol/src/coverage/coverage_options.dart'; import 'package:patrol/src/native/contracts/contracts.dart'; import 'package:patrol/src/native/contracts/patrol_app_service_server.dart'; import 'package:shelf/shelf.dart' as shelf; import 'package:shelf/shelf_io.dart' as shelf_io; +import '../constants.dart' as constans; +import '../coverage/coverage_collector.dart'; + const _idleTimeout = Duration(hours: 2); class _TestExecutionResult { @@ -37,11 +42,28 @@ Future runAppService(PatrolAppService service) async { final address = server.address; + if (constans.coverage) { + await _startCoverageService(); + } + print( 'PatrolAppService started, address: ${address.address}, host: ${address.host}, port: ${server.port}', ); } +Future _startCoverageService() async { + print('Starting coverage service'); + final packageConfig = utf8.decode(base64.decode(constans.packageConfig)); + final coverageOptions = CoverageOptions( + // ignore: avoid_redundant_argument_values + functionCoverage: constans.functionCoverage, + coveragePackageConfig: packageConfig, + ); + + await CoverageCollector().initialize(options: coverageOptions); + await CoverageCollector().startInBackground(); +} + /// Implements a stateful HTTP service for querying and executing Dart tests. /// /// This is an internal class and you don't want to use it. It's public so that @@ -101,6 +123,10 @@ class PatrolAppService extends PatrolAppServiceServer { 'that was most recently requested to run was $requestedDartTestName', ); + if(constans.coverage){ + await CoverageCollector().handleTestCompletion(dartFileName); + } + _testExecutionCompleted.complete( _TestExecutionResult(passed: passed, details: details), ); diff --git a/packages/patrol/pubspec.yaml b/packages/patrol/pubspec.yaml index 68cbc6d715..342309fea0 100644 --- a/packages/patrol/pubspec.yaml +++ b/packages/patrol/pubspec.yaml @@ -17,6 +17,7 @@ environment: flutter: '>=3.22.0' dependencies: + coverage: ^1.8.0 equatable: ^2.0.5 flutter: sdk: flutter @@ -26,8 +27,10 @@ dependencies: json_annotation: ^4.8.1 meta: ^1.10.0 patrol_finders: ^2.1.2 + path_provider: ^2.0.11 shelf: ^1.4.1 test_api: '^0.7.0' + vm_service: ^14.2.1 dev_dependencies: build_runner: ^2.4.6 diff --git a/packages/patrol_cli/lib/src/android/android_test_backend.dart b/packages/patrol_cli/lib/src/android/android_test_backend.dart index 305f69bee4..7b53532bdf 100644 --- a/packages/patrol_cli/lib/src/android/android_test_backend.dart +++ b/packages/patrol_cli/lib/src/android/android_test_backend.dart @@ -1,17 +1,23 @@ import 'dart:async'; import 'dart:io' show Process; +import 'dart:io' as io; import 'package:adb/adb.dart'; import 'package:dispose_scope/dispose_scope.dart'; import 'package:file/file.dart'; +import 'package:path/path.dart' show join; import 'package:patrol_cli/src/base/exceptions.dart'; import 'package:patrol_cli/src/base/logger.dart'; import 'package:patrol_cli/src/base/process.dart'; import 'package:patrol_cli/src/crossplatform/app_options.dart'; +import 'package:patrol_cli/src/crossplatform/coverage_collector.dart'; +import 'package:patrol_cli/src/crossplatform/coverage_options.dart'; import 'package:patrol_cli/src/devices.dart'; import 'package:platform/platform.dart'; import 'package:process/process.dart'; +import '../crossplatform/log_processor.dart'; + /// Provides functionality to build, install, run, and uninstall Android apps. /// /// This class must be stateless. @@ -39,6 +45,8 @@ class AndroidTestBackend { final DisposeScope _disposeScope; final Logger _logger; late final String? javaPath; + final CoverageCollector _coverageCollector = CoverageCollector(); + late CoverageOptions _coverageOptions; Future build(AndroidAppOptions options) async { await loadJavaPathFromFlutterDoctor(options.flutter.command.executable); @@ -159,7 +167,35 @@ class AndroidTestBackend { AndroidAppOptions options, Device device, { bool interruptible = false, + CoverageOptions coverageOptions = const CoverageOptions(), }) async { + + // String logFilePath; + // LogProcessor? logProcessor; + // _coverageOptions = CoverageOptions(coverage: false); + + // if (_coverageOptions.coverage) { + // logFilePath = join( + // io.Directory.systemTemp.path, + // 'patrol_${device.id}_${DateTime.now().millisecondsSinceEpoch}.log', + // ); + + // logProcessor = LogProcessor( + // device, + // logFilePath, + // (uri) => _handleStartTest(uri, device), + // _logger, + // ); + + // await _coverageCollector.initialize( + // logger: _logger, + // processManager: _processManager, + // options: coverageOptions, + // ); + + // await logProcessor.start(); + // } + await _disposeScope.run((scope) async { final subject = '${options.description} on ${device.description}'; final task = _logger.task('Executing tests of $subject'); @@ -190,6 +226,12 @@ class AndroidTestBackend { }).disposedBy(scope); final exitCode = await process.exitCode; + + // if (coverageOptions.coverage) { + // await logProcessor!.stop(); + // await _coverageCollector.stop(); + // } + if (exitCode == 0) { task.complete('Completed executing $subject'); } else if (exitCode != 0 && interruptible) { @@ -212,4 +254,20 @@ class AndroidTestBackend { _logger.detail('Uninstalling $appId.test from ${device.name}'); await _adb.uninstall('$appId.test', device: device.id); } + + Future _handleStartTest(String url, Device device) async { + _logger.detail('observatory URI found: $url'); + final observatoryUri = Uri.parse(url); + final fromHost = observatoryUri.port; + final toDevice = observatoryUri.port; + await _adb.forwardPorts( + fromHost: fromHost, + toDevice: toDevice, + device: device.id, + ); + if (_coverageOptions.coverage) { + _logger.info('Collecting coverage information'); + await _coverageCollector.start(url); + } + } } diff --git a/packages/patrol_cli/lib/src/commands/build_android.dart b/packages/patrol_cli/lib/src/commands/build_android.dart index e834f8790c..0d09ee8926 100644 --- a/packages/patrol_cli/lib/src/commands/build_android.dart +++ b/packages/patrol_cli/lib/src/commands/build_android.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:patrol_cli/src/analytics/analytics.dart'; import 'package:patrol_cli/src/android/android_test_backend.dart'; @@ -6,6 +7,7 @@ import 'package:patrol_cli/src/base/extensions/core.dart'; import 'package:patrol_cli/src/base/logger.dart'; import 'package:patrol_cli/src/commands/dart_define_utils.dart'; import 'package:patrol_cli/src/crossplatform/app_options.dart'; +import 'package:patrol_cli/src/crossplatform/coverage_options.dart'; import 'package:patrol_cli/src/dart_defines_reader.dart'; import 'package:patrol_cli/src/pubspec_reader.dart'; import 'package:patrol_cli/src/runner/patrol_command.dart'; @@ -38,6 +40,10 @@ class BuildAndroidCommand extends PatrolCommand { usesPortOptions(); usesAndroidOptions(); + + useCoverageOption(); + useFunctionCoverageOption(); + useCoveragePackageOption(); } final TestFinder _testFinder; @@ -101,12 +107,38 @@ class BuildAndroidCommand extends PatrolCommand { ..._dartDefinesReader.fromFile(), ..._dartDefinesReader.fromCli(args: stringsArg('dart-define')), }; + + final coverage = boolArg('coverage'); + _logger.detail('Received coverage: $coverage'); + final functionCoverage = boolArg('function-coverage'); + _logger.detail('Received function coverage: $functionCoverage'); + final packagesRegExps = stringsArg('coverage-package'); + _logger.detail('Received coverage package: $packagesRegExps'); + + final coverageOpts = CoverageOptions( + coverage: coverage, + functionCoverage: functionCoverage, + packagesRegExps: packagesRegExps, + ); + + final coveragePackages = await coverageOpts.getCoveragePackages(); + final coveragePackagesList = coveragePackages.toList().join(','); + _logger.detail('Received coverage packages: $coveragePackagesList'); + final packageConfig = await coverageOpts.getPackageConfigData(); + // convert to base64 to avoid issues with special characters + final packageConfigBase64 = base64Encode(utf8.encode(packageConfig)); + _logger.detail('Received package config: $packageConfig'); + final internalDartDefines = { 'PATROL_WAIT': defaultWait.toString(), 'PATROL_APP_PACKAGE_NAME': packageName, 'PATROL_ANDROID_APP_NAME': config.android.appName, 'PATROL_TEST_LABEL_ENABLED': displayLabel.toString(), 'INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE': 'false', + 'PATROL_COVERAGE': coverageOpts.coverage.toString(), + 'PATROL_FUNCTION_COVERAGE': coverageOpts.functionCoverage.toString(), + 'PATROL_COVERAGE_PACKAGES': coveragePackagesList, + 'PATROL_PACKAGE_CONFIG': packageConfigBase64, }.withNullsRemoved(); final dartDefines = {...customDartDefines, ...internalDartDefines}; diff --git a/packages/patrol_cli/lib/src/commands/build_ios.dart b/packages/patrol_cli/lib/src/commands/build_ios.dart index 05b6cc0da3..ef6d4a5982 100644 --- a/packages/patrol_cli/lib/src/commands/build_ios.dart +++ b/packages/patrol_cli/lib/src/commands/build_ios.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:convert'; import 'package:path/path.dart' show join; import 'package:patrol_cli/src/analytics/analytics.dart'; import 'package:patrol_cli/src/base/extensions/core.dart'; import 'package:patrol_cli/src/base/logger.dart'; import 'package:patrol_cli/src/crossplatform/app_options.dart'; +import 'package:patrol_cli/src/crossplatform/coverage_options.dart'; import 'package:patrol_cli/src/dart_defines_reader.dart'; import 'package:patrol_cli/src/ios/ios_test_backend.dart'; import 'package:patrol_cli/src/pubspec_reader.dart'; @@ -105,6 +107,28 @@ class BuildIOSCommand extends PatrolCommand { ..._dartDefinesReader.fromFile(), ..._dartDefinesReader.fromCli(args: stringsArg('dart-define')), }; + + final coverage = boolArg('coverage'); + _logger.detail('Received coverage: $coverage'); + final functionCoverage = boolArg('function-coverage'); + _logger.detail('Received function coverage: $functionCoverage'); + final packagesRegExps = stringsArg('coverage-package'); + _logger.detail('Received coverage package: $packagesRegExps'); + + final coverageOpts = CoverageOptions( + coverage: coverage, + functionCoverage: functionCoverage, + packagesRegExps: packagesRegExps, + ); + + final coveragePackages = await coverageOpts.getCoveragePackages(); + final coveragePackagesList = coveragePackages.toList().join(','); + _logger.detail('Received coverage packages: $coveragePackagesList'); + final packageConfig = await coverageOpts.getPackageConfigData(); + // convert to base64 to avoid issues with special characters + final packageConfigBase64 = base64Encode(utf8.encode(packageConfig)); + _logger.detail('Received package config: $packageConfig'); + final internalDartDefines = { 'PATROL_WAIT': defaultWait.toString(), 'PATROL_APP_BUNDLE_ID': bundleId, @@ -113,6 +137,10 @@ class BuildIOSCommand extends PatrolCommand { 'INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE': 'false', 'PATROL_TEST_SERVER_PORT': super.testServerPort.toString(), 'PATROL_APP_SERVER_PORT': super.appServerPort.toString(), + 'PATROL_COVERAGE': coverageOpts.coverage.toString(), + 'PATROL_FUNCTION_COVERAGE': coverageOpts.functionCoverage.toString(), + 'PATROL_COVERAGE_PACKAGES': coveragePackagesList, + 'PATROL_PACKAGE_CONFIG': packageConfigBase64, }.withNullsRemoved(); final dartDefines = {...customDartDefines, ...internalDartDefines}; diff --git a/packages/patrol_cli/lib/src/commands/test.dart b/packages/patrol_cli/lib/src/commands/test.dart index 6e69aad6ee..33eeb4acfc 100644 --- a/packages/patrol_cli/lib/src/commands/test.dart +++ b/packages/patrol_cli/lib/src/commands/test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:patrol_cli/src/analytics/analytics.dart'; import 'package:patrol_cli/src/android/android_test_backend.dart'; @@ -7,6 +8,7 @@ import 'package:patrol_cli/src/base/logger.dart'; import 'package:patrol_cli/src/commands/dart_define_utils.dart'; import 'package:patrol_cli/src/compatibility_checker.dart'; import 'package:patrol_cli/src/crossplatform/app_options.dart'; +import 'package:patrol_cli/src/crossplatform/coverage_options.dart'; import 'package:patrol_cli/src/dart_defines_reader.dart'; import 'package:patrol_cli/src/devices.dart'; import 'package:patrol_cli/src/ios/ios_test_backend.dart'; @@ -50,6 +52,12 @@ class TestCommand extends PatrolCommand { usesWaitOption(); usesPortOptions(); + useCoverageOption(); + useFunctionCoverageOption(); + useMergeCoverageOption(); + useCoveragePathOption(); + useCoveragePackageOption(); + usesUninstallOption(); usesAndroidOptions(); @@ -112,7 +120,7 @@ class TestCommand extends PatrolCommand { _logger.detail('Received Android flavor: $androidFlavor'); } if (iosFlavor != null) { - _logger.detail('Received iOS flavor: $iosFlavor'); + // _logger.detail('Received iOS flavor: $iosFlavor'); } if (macosFlavor != null) { _logger.detail('Received macOS flavor: $macosFlavor'); @@ -154,6 +162,28 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. ..._dartDefinesReader.fromFile(), ..._dartDefinesReader.fromCli(args: stringsArg('dart-define')), }; + + final coverage = boolArg('coverage'); + _logger.detail('Received coverage: $coverage'); + final functionCoverage = boolArg('function-coverage'); + _logger.detail('Received function coverage: $functionCoverage'); + final packagesRegExps = stringsArg('coverage-package'); + _logger.detail('Received coverage package: $packagesRegExps'); + + final coverageOpts = CoverageOptions( + coverage: coverage, + functionCoverage: functionCoverage, + packagesRegExps: packagesRegExps, + ); + + final coveragePackages = await coverageOpts.getCoveragePackages(); + final coveragePackagesList = coveragePackages.toList().join(','); + _logger.detail('Received coverage packages: $coveragePackagesList'); + final packageConfig = await coverageOpts.getPackageConfigData(); + // convert to base64 to avoid issues with special characters + final packageConfigBase64 = base64Encode(utf8.encode(packageConfig)); + _logger.detail('Received package config: $packageConfig'); + final internalDartDefines = { 'PATROL_WAIT': wait.toString(), 'PATROL_APP_PACKAGE_NAME': packageName, @@ -165,6 +195,10 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. 'PATROL_TEST_LABEL_ENABLED': displayLabel.toString(), 'PATROL_TEST_SERVER_PORT': super.testServerPort.toString(), 'PATROL_APP_SERVER_PORT': super.appServerPort.toString(), + 'PATROL_COVERAGE': coverageOpts.coverage.toString(), + 'PATROL_FUNCTION_COVERAGE': coverageOpts.functionCoverage.toString(), + 'PATROL_COVERAGE_PACKAGES': coveragePackagesList, + 'PATROL_PACKAGE_CONFIG': packageConfigBase64, }.withNullsRemoved(); final dartDefines = {...customDartDefines, ...internalDartDefines}; @@ -184,6 +218,8 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. final dartDefineFromFilePaths = stringsArg('dart-define-from-file'); + _logger.detail('Received coverage: $coverage'); + final mergedDartDefines = mergeDartDefines( dartDefineFromFilePaths, dartDefines, @@ -216,6 +252,8 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. testServerPort: super.testServerPort, ); + _logger.detail('Coverage options: ${coverageOpts.coverage}'); + final macosOpts = MacOSAppOptions( flutter: flutterOpts, scheme: buildMode.createScheme(macosFlavor), @@ -223,6 +261,7 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. appServerPort: super.appServerPort, testServerPort: super.testServerPort, ); + await _build(androidOpts, iosOpts, macosOpts, device); await _preExecute(androidOpts, iosOpts, macosOpts, device, uninstall); @@ -231,6 +270,7 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. androidOpts, iosOpts, macosOpts, + coverageOpts, uninstall: uninstall, device: device, ); @@ -304,7 +344,8 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. FlutterAppOptions flutterOpts, AndroidAppOptions android, IOSAppOptions ios, - MacOSAppOptions macos, { + MacOSAppOptions macos, + CoverageOptions coverageOptions, { required bool uninstall, required Device device, }) async { @@ -313,7 +354,7 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. switch (device.targetPlatform) { case TargetPlatform.android: - action = () => _androidTestBackend.execute(android, device); + action = () => _androidTestBackend.execute(android, device, coverageOptions: coverageOptions); final package = android.packageName; if (package != null && uninstall) { finalizer = () => _androidTestBackend.uninstall(package, device); @@ -321,7 +362,8 @@ See https://github.com/leancodepl/patrol/issues/1316 to learn more. case TargetPlatform.macOS: action = () async => _macosTestBackend.execute(macos, device); case TargetPlatform.iOS: - action = () async => _iosTestBackend.execute(ios, device); + _logger.detail('Will execute iOS tests'); + action = () async => _iosTestBackend.execute(ios, device, coverageOptions: coverageOptions); final bundleId = ios.bundleId; if (bundleId != null && uninstall) { finalizer = () => _iosTestBackend.uninstall( diff --git a/packages/patrol_cli/lib/src/crossplatform/coverage_collector.dart b/packages/patrol_cli/lib/src/crossplatform/coverage_collector.dart new file mode 100644 index 0000000000..6b883197c7 --- /dev/null +++ b/packages/patrol_cli/lib/src/crossplatform/coverage_collector.dart @@ -0,0 +1,505 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:coverage/coverage.dart' as coverage; +import 'package:path/path.dart' as path; +import 'package:patrol_cli/src/base/logger.dart'; +import 'package:process/process.dart'; +import 'package:vm_service/vm_service.dart'; +import 'package:vm_service/vm_service_io.dart'; + +import 'coverage_options.dart'; + +/// A singleton class responsible for collecting and managing code coverage data. +class CoverageCollector { + factory CoverageCollector() => _instance; + CoverageCollector._internal(); + static final CoverageCollector _instance = CoverageCollector._internal(); + + late final Logger _logger; + late final ProcessManager _processManager; + late final CoverageOptions _options; + + VmService? _service; + Map? _globalHitmap; + Set? _libraryNames; + coverage.Resolver? _resolver; + + bool _isInitialized = false; + bool _isRunning = false; + final _completer = Completer(); + + String? _currentObservatoryUrlWs; + String? _currentObservatoryUrlHttp; + + static const String _coverageDir = 'coverage'; + static const String _mergedLcovFile = '$_coverageDir/lcov.info'; + + /// Initializes the CoverageCollector with required dependencies. + Future initialize({ + required Logger logger, + required ProcessManager processManager, + CoverageOptions? options, + }) async { + if (_isInitialized) { + return; + } + + _logger = logger; + _processManager = processManager; + _options = options ?? const CoverageOptions(); + _isInitialized = true; + _libraryNames = await _options.getCoveragePackages(); + } + + /// Starts the coverage collection process. + Future start(String currentObservatoryUrlHttp) async { + _ensureInitialized(); + + _currentObservatoryUrlHttp = currentObservatoryUrlHttp; + _currentObservatoryUrlWs = + _convertToWebSocketUrl(currentObservatoryUrlHttp); + + await _connectToVmService(); + _isRunning = true; + + _setupEventListeners(); + + await _setBreakpointsForAllIsolates(); + + await _completer.future; + } + + /// Stops the coverage collection process and writes the collected data. + Future stop() async { + if (!_isRunning) { + return; + } + + _isRunning = false; + await _service?.dispose(); + _completer.complete(); + + final success = await collectCoverageData(_mergedLcovFile, + mergeCoverageData: _options.mergeCoverage,); + _logCoverageResult(success); + } + + /// Collects coverage data for a specific isolate. + Future collectCoverage(String isolateId) async { + _logger + .detail('Collecting coverage data from $_currentObservatoryUrlHttp...'); + + final libraryNamesList = _libraryNames?.toList(); + if (libraryNamesList == null || libraryNamesList.isEmpty) { + _logger.err('No library names found. Coverage collection aborted.'); + return; + } + + _logCoverageDetails(libraryNamesList); + + final data = await _collectCoverageData(libraryNamesList); + await _mergeCoverageData(data); + } + + /// Finalizes the coverage data and returns it as a formatted string. + Future finalizeCoverage({ + String Function(Map hitmap)? formatter, + coverage.Resolver? resolver, + Directory? coverageDirectory, + }) async { + _logger.detail('Finalizing coverage data...'); + if (_globalHitmap == null) { + _logger.warn('No coverage data to finalize.'); + return null; + } + + formatter ??= _createDefaultFormatter( + await _getResolver(resolver), coverageDirectory,); + + final result = formatter(_globalHitmap!); + _logger.detail('Coverage data finalized.'); + + _globalHitmap = null; + return result; + } + + /// Collects and writes coverage data to a file. + Future collectCoverageData(String? coveragePath, + {bool mergeCoverageData = false, Directory? coverageDirectory,}) async { + final coverageData = + await finalizeCoverage(coverageDirectory: coverageDirectory); + if (coverageData == null) { + return false; + } + + await _writeCoverageDataToFile(coveragePath!, coverageData); + + if (mergeCoverageData) { + return _mergeCoverageWithBaseData(coveragePath); + } + + return true; + } + + // Private methods + + Future _getResolver( + coverage.Resolver? providedResolver,) async { + if (providedResolver != null) { + return providedResolver; + } + if (_resolver != null) { + return _resolver!; + } + _resolver = await coverage.Resolver.create( + packagesPath: '.dart_tool/package_config.json',); + return _resolver!; + } + + void _ensureInitialized() { + if (!_isInitialized) { + throw StateError( + 'CoverageCollector not initialized. Call initialize() first.',); + } + } + + Future _connectToVmService() async { + final wsUrl = _convertToWebSocketUrl(_currentObservatoryUrlWs!); + _logger.detail('Connecting to $wsUrl'); + + try { + _service = await vmServiceConnectUri(wsUrl); + _logger.detail('Connected to VM service'); + } catch (e) { + _logger.err('Failed to connect to VM service: $e'); + _isRunning = false; + _completer.complete(); + rethrow; + } + } + + void _setupEventListeners() { + _service?.onIsolateEvent.listen(_handleIsolateEvent); + _service?.onDebugEvent.listen(_handleDebugEvent); + _service?.streamListen(EventStreams.kDebug); + _service?.streamListen(EventStreams.kIsolate); + _logger.detail('Listening for events...'); + } + + Future _setBreakpointsForAllIsolates() async { + final vm = await _service?.getVM(); + for (final isolateRef in vm?.isolates ?? []) { + isolateRef as IsolateRef; + await _setTestBreakpoints(isolateRef.id!); + } + } + + Future _handleIsolateEvent(Event event) async { + if (!_isRunning) { + return; + } + + if (event.kind == EventKind.kIsolateRunnable) { + _logger.detail('New isolate detected. Setting breakpoints...'); + await _setTestBreakpoints(event.isolate!.id!); + } + } + + void _handleDebugEvent(Event event) { + if (!_isRunning) { + return; + } + + if (event.kind == EventKind.kPauseBreakpoint) { + _handleBreakpoint(event); + } + } + + Future _setTestBreakpoints(String isolateId) async { + if (_service == null) { + _logger.warn('VM service is not available. Cannot set breakpoints.'); + return; + } + + try { + final scripts = await _service!.getScripts(isolateId); + + for (final scriptRef in scripts.scripts!) { + if (_isIntegrationTestScript(scriptRef.uri!)) { + await _setBreakpointsInScript(isolateId, scriptRef); + } + } + } catch (err) { + _logger.warn('Error setting breakpoints: $err'); + } + } + + bool _isIntegrationTestScript(String uri) { + return uri.contains('integration_test/') && uri.endsWith('_test.dart'); + } + + Future _setBreakpointsInScript( + String isolateId, ScriptRef scriptRef,) async { + _logger.detail('Setting breakpoints in ${scriptRef.uri}'); + + final script = await _getScript(isolateId, scriptRef); + if (script == null) { + return; + } + + final lines = script.source!.split('\n'); + + for (var i = 0; i < lines.length; i++) { + if (_isTestFunctionStart(lines[i])) { + final endLine = _findTestFunctionEnd(lines, i); + if (endLine != -1) { + await _addBreakpoint(isolateId, scriptRef.uri!, endLine); + } + } + } + } + + Future _getScript(String isolateId, ScriptRef scriptRef) async { + try { + return await _service!.getObject(isolateId, scriptRef.id!) as Script?; + } catch (e) { + _logger.warn('Failed to get script object: $e'); + return null; + } + } + + bool _isTestFunctionStart(String line) { + final trimmedLine = line.trim(); + return trimmedLine.startsWith('test(') || + trimmedLine.startsWith('testWidgets(') || + trimmedLine.startsWith('patrol(') || + trimmedLine.startsWith('patrolTest('); + } + + int _findTestFunctionEnd(List lines, int startLine) { + var bracketCount = 0; + var foundOpeningBracket = false; + for (var i = startLine; i < lines.length; i++) { + if (!foundOpeningBracket && lines[i].contains('{')) { + foundOpeningBracket = true; + } + if (foundOpeningBracket) { + bracketCount += '{'.allMatches(lines[i]).length; + bracketCount -= '}'.allMatches(lines[i]).length; + if (bracketCount == 0) { + return i + 1; + } + } + } + return -1; + } + + Future _addBreakpoint( + String isolateId, String scriptUri, int lineNumber, + ) async { + try { + final bp = await _service!.addBreakpointWithScriptUri( + isolateId, + scriptUri, + lineNumber, + ); + _logger.detail( + 'Breakpoint added: ${bp.id} at line $lineNumber (end of test)', + ); + } catch (err) { + _logger.warn('Error adding breakpoint: $err'); + } + } + + Future _handleBreakpoint(Event event) async { + if (!_isRunning || _service == null) { + _logger + .warn('TestMonitor is not running or VM service is not available.'); + return; + } + + final isolateId = event.isolate?.id; + if (isolateId == null) { + _logger.warn('Warning: Isolate ID is null'); + return; + } + + _logger + ..detail('Breakpoint hit in isolate: $isolateId') + ..detail('Breakpoint ID: ${event.breakpoint?.id ?? 'Unknown'}'); + + try { + await _logBreakpointLocation(event, isolateId); + _logger.detail('Collecting coverage...'); + await collectCoverage(isolateId); + } catch (e) { + _logger.err('Error handling breakpoint: $e'); + } + } + + Future _logBreakpointLocation(Event event, String isolateId) async { + if (event.topFrame?.location?.script == null) { + _logger.warn('Warning: Script information is not available'); + return; + } + + final scriptRef = event.topFrame!.location!.script!; + final script = + await _service!.getObject(isolateId, scriptRef.id!) as Script?; + + if (script != null) { + final lineNumber = event.topFrame?.location?.line ?? 'Unknown'; + _logger.detail('Paused at ${script.uri}:$lineNumber (end of test)'); + } else { + _logger.warn('Warning: Unable to retrieve script information'); + } + } + + void _logCoverageDetails(List libraryNamesList) { + _logger + ..detail('library names: ${libraryNamesList.join(',')}') + ..detail('branchCoverage: ${_options.branchCoverage}') + ..detail('functionCoverage: ${_options.functionCoverage}'); + } + + Future> _collectCoverageData( + List libraryNamesList,) async { + return coverage.collect( + Uri.parse(_currentObservatoryUrlHttp!), + true, + false, + false, + libraryNamesList.toSet(), + branchCoverage: _options.branchCoverage, + functionCoverage: _options.functionCoverage, + timeout: const Duration(minutes: 5), + ); + } + + Future _mergeCoverageData(Map data) async { + _logger.detail('Collected coverage data; merging...'); + + _addHitmap( + await coverage.HitMap.parseJson( + data['coverage'] as List>, + packagePath: Directory.current.path, + checkIgnoredLines: true, + ), + ); + + _logger.detail('Done merging coverage data into global coverage map.'); + } + + void _addHitmap(Map hitmap) { + if (_globalHitmap == null) { + _globalHitmap = hitmap; + } else { + _globalHitmap!.merge(hitmap); + } + } + + String Function(Map) _createDefaultFormatter( + coverage.Resolver resolver, + Directory? coverageDirectory, + ) { + return (hitmap) { + final packagePath = Directory.current.path; + final libraryPaths = _libraryNames + ?.map((e) => resolver.resolve('package:$e')) + .whereType() + .toList(); + + final reportOn = coverageDirectory == null + ? libraryPaths + : [coverageDirectory.path]; + + _logger + ..detail('Coverage report on: ${reportOn!.join(', ')}') + ..detail('Coverage package path: $packagePath'); + + return hitmap.formatLcov(resolver, + reportOn: reportOn, basePath: packagePath,); + }; + } + + Future _writeCoverageDataToFile( + String coveragePath, String coverageData,) async { + File(coveragePath) + ..createSync(recursive: true) + ..writeAsStringSync(coverageData, flush: true); + _logger.detail( + 'Wrote coverage data to $coveragePath (size=${coverageData.length})',); + } + + Future _mergeCoverageWithBaseData(String coveragePath) async { + const baseCoverageData = 'coverage/lcov.base.info'; + if (!File(baseCoverageData).existsSync()) { + _logger + .err('Missing "$baseCoverageData". Unable to merge coverage data.'); + return false; + } + + if (!await _isLcovInstalled()) { + return false; + } + + return _executeLcovMerge(coveragePath, baseCoverageData); + } + + Future _isLcovInstalled() async { + final lcovResult = await _processManager.run(['which', 'lcov']); + if (lcovResult.exitCode != 0) { + _logger.err( + 'Missing "lcov" tool. Unable to merge coverage data.\n${_getLcovInstallMessage()}',); + return false; + } + return true; + } + + String _getLcovInstallMessage() { + if (Platform.isLinux) { + return 'Consider running "sudo apt-get install lcov".'; + } else if (Platform.isMacOS) { + return 'Consider running "brew install lcov".'; + } + return 'Please install lcov.'; + } + + Future _executeLcovMerge( + String coveragePath, String baseCoverageData,) async { + final tempDir = Directory.systemTemp.createTempSync('patrol_coverage.'); + try { + final sourceFile = File(coveragePath) + .copySync(path.join(tempDir.path, 'lcov.source.info')); + final result = await _processManager.run([ + 'lcov', + '--add-tracefile', + baseCoverageData, + '--add-tracefile', + sourceFile.path, + '--output-file', + coveragePath, + ]); + return result.exitCode == 0; + } finally { + tempDir.deleteSync(recursive: true); + } + } + + void _logCoverageResult(bool success) { + if (success) { + _logger.detail('Coverage data written to $_mergedLcovFile'); + } else { + _logger.err('Failed to write coverage data to $_mergedLcovFile'); + } + } + + String _convertToWebSocketUrl(String observatoryUri) { + var observatoryUriWs = observatoryUri.replaceFirst('http://', 'ws://'); + if (!observatoryUriWs.endsWith('/ws')) { + observatoryUriWs += 'ws'; + } + return observatoryUriWs; + } +} diff --git a/packages/patrol_cli/lib/src/crossplatform/coverage_options.dart b/packages/patrol_cli/lib/src/crossplatform/coverage_options.dart new file mode 100644 index 0000000000..4db32aade9 --- /dev/null +++ b/packages/patrol_cli/lib/src/crossplatform/coverage_options.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:patrol_cli/src/base/exceptions.dart'; +import 'package:yaml/yaml.dart'; + + +class CoverageOptions { + const CoverageOptions({ + this.coverage = false, + this.host = '127.0.0.1', + this.port = 8181, + this.out = 'coverage/lcov.info', + this.connectTimeout = 10, + this.scopeOutput = const [], + this.waitPaused = false, + this.resumeIsolates = true, + this.includeDart = false, + this.functionCoverage = true, + this.branchCoverage = false, + this.mergeCoverage = false, + this.coveragePath, + this.packagesRegExps = const [], + this.appName = '', + }); + + final bool coverage; + final String host; + final int port; + final String out; + final int connectTimeout; + final List scopeOutput; + final bool waitPaused; + final bool resumeIsolates; + final bool includeDart; + final bool functionCoverage; + final bool branchCoverage; + final bool mergeCoverage; + final String? coveragePath; + final List packagesRegExps; + final String appName; + + /// Returns the coverage packages to include in the coverage report. + Future> getCoveragePackages( + ) async { + final packagesToInclude = { + if (packagesRegExps.isEmpty) await _getProjectName(), + }; + + try { + for (final regExpStr in packagesRegExps) { + final regExp = RegExp(regExpStr); + final packagesNames = await _getPackagesNameFromPackageConfig(); + packagesToInclude.addAll( + packagesNames + .where(regExp.hasMatch), + ); + } + } on FormatException catch (e) { + throwToolExit('Regular expression syntax is invalid. $e'); + } + return packagesToInclude; + } + + Future _getProjectName() async { + try { + final pubspecFile = File('pubspec.yaml'); + final pubspecContent = await pubspecFile.readAsString(); + final pubspec = loadYaml(pubspecContent); + // ignore: avoid_dynamic_calls + return pubspec['name'] as String; + } on FileSystemException catch (e) { + throwToolExit('Failed to read pubspec.yaml. $e'); + } on YamlException catch (e) { + throwToolExit('Failed to parse pubspec.yaml. $e'); + } + } + + Future> _getPackagesNameFromPackageConfig() async { + try { + final packagesConfig = File('.dart_tool/package_config.json').readAsStringSync(); + final packageJson = jsonDecode(packagesConfig) as Map; + final packagesNames = []; + + for (final package in packageJson['packages'] as List) { + // ignore: avoid_dynamic_calls + packagesNames.add(package['name'] as String); + } + + return packagesNames; + } catch (err) { + throwToolExit('Failed to read package_config.json. $err'); + } + } + + Future getPackageConfigData() async { + try { + final packagesConfig = File('.dart_tool/package_config.json').readAsStringSync(); + return packagesConfig; + } catch (err) { + throwToolExit('Failed to read package_config.json. $err'); + } + } +} diff --git a/packages/patrol_cli/lib/src/crossplatform/log_processor.dart b/packages/patrol_cli/lib/src/crossplatform/log_processor.dart new file mode 100644 index 0000000000..fd6a4a8a65 --- /dev/null +++ b/packages/patrol_cli/lib/src/crossplatform/log_processor.dart @@ -0,0 +1,116 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:patrol_cli/src/base/logger.dart'; +import 'package:patrol_cli/src/devices.dart'; + +/// Abstract class defining common behavior for log processors +abstract class BaseLogProcessor { + BaseLogProcessor(this.device, this.logFilePath, this.onObservatoryUri, this._logger, ); + + final Device device; + final String logFilePath; + final void Function(String) onObservatoryUri; + final Logger _logger; + + StreamSubscription? _logSubscription; + IOSink? _logSink; + + Future start(); + Future stop(); + + void _processLogLine(String line) { + _logSink?.writeln(line); + _checkForObservatoryUri(line); + } + + void _checkForObservatoryUri(String line) { + final match = RegExp(r'The Dart VM service is listening on (http://[^\s]+)') + .firstMatch(line); + if (match != null) { + final observatoryUri = match.group(1); + if (observatoryUri != null) { + onObservatoryUri(observatoryUri); + } + } + } +} + +/// Unified log processor for both iOS and Android platforms +class LogProcessor extends BaseLogProcessor { + LogProcessor( + super.device, super.logFilePath, super.onObservatoryUri, super.logger, + ); + + @override + Future start() async { + _logger.info('Starting log processor'); + + if (device.targetPlatform == TargetPlatform.iOS) { + await _startIOSLogStream(); + } else { + await _clearLogcatCache(); + await _startLogcatStream(); + } + + _logger.info('log processor started'); + } + + @override + Future stop() async { + await _logSubscription?.cancel(); + await _logSink?.close(); + } + + Future _startIOSLogStream() async { + final logFile = File(logFilePath); + _logSink = logFile.openWrite(mode: FileMode.writeOnly); + + late Process logProcess; + if (device.real) { + logProcess = await Process.start('idevicesyslog', ['-u', device.id]); + } else { + logProcess = await Process.start('xcrun', ['simctl', 'spawn', device.id, 'log', 'stream', '--style', 'syslog']); + } + + _logSubscription = logProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_processLogLine); + + logProcess.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => _logger.err('Log error: $line')); + } + + Future _clearLogcatCache() async { + try { + final result = await Process.run('adb', ['-s', device.id, 'logcat', '-c']); + if (result.exitCode != 0) { + _logger.err('Failed to clear logcat cache: ${result.stderr}'); + } + } catch (err) { + _logger.err('Error clearing logcat cache: $err'); + } + } + + Future _startLogcatStream() async { + final logFile = File(logFilePath); + _logSink = logFile.openWrite(mode: FileMode.writeOnly); + + final logcatProcess = + await Process.start('adb', ['-s', device.id, 'logcat']); + + _logSubscription = logcatProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_processLogLine); + + logcatProcess.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => _logger.err('Logcat error: $line')); + } +} diff --git a/packages/patrol_cli/lib/src/ios/ios_test_backend.dart b/packages/patrol_cli/lib/src/ios/ios_test_backend.dart index a1080b6ae4..be00ce3ef9 100644 --- a/packages/patrol_cli/lib/src/ios/ios_test_backend.dart +++ b/packages/patrol_cli/lib/src/ios/ios_test_backend.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io' show Process; +import 'dart:io' as io; import 'package:dispose_scope/dispose_scope.dart'; import 'package:file/file.dart'; @@ -10,10 +11,14 @@ import 'package:patrol_cli/src/base/exceptions.dart'; import 'package:patrol_cli/src/base/logger.dart'; import 'package:patrol_cli/src/base/process.dart'; import 'package:patrol_cli/src/crossplatform/app_options.dart'; +import 'package:patrol_cli/src/crossplatform/coverage_collector.dart'; +import 'package:patrol_cli/src/crossplatform/coverage_options.dart'; import 'package:patrol_cli/src/devices.dart'; import 'package:platform/platform.dart'; import 'package:process/process.dart'; +import '../crossplatform/log_processor.dart'; + enum BuildMode { debug, profile, @@ -71,6 +76,8 @@ class IOSTestBackend { final FileSystem _fs; final DisposeScope _disposeScope; final Logger _logger; + final CoverageCollector _coverageCollector = CoverageCollector(); + late CoverageOptions _coverageOptions; Future build(IOSAppOptions options) async { await _disposeScope.run((scope) async { @@ -149,7 +156,37 @@ class IOSTestBackend { IOSAppOptions options, Device device, { bool interruptible = false, + CoverageOptions coverageOptions = const CoverageOptions(), }) async { + + // String logFilePath; + // LogProcessor? logProcessor; + // _coverageOptions = coverageOptions; + + // if (_coverageOptions.coverage) { + // logFilePath = join( + // io.Directory.systemTemp.path, + // 'patrol_${device.id}_${DateTime.now().millisecondsSinceEpoch}.log', + // ); + + // logProcessor = LogProcessor( + // device, + // logFilePath, + // (uri) => _handleStartTest(uri, device), + // _logger, + // ); + + // await _coverageCollector.initialize( + // logger: _logger, + // processManager: _processManager, + // options: coverageOptions, + // ); + + // await logProcessor.start(); + // } + + // exit program + await _disposeScope.run((scope) async { final subject = '${options.description} on ${device.description}'; final task = _logger.task('Running $subject'); @@ -182,6 +219,10 @@ class IOSTestBackend { process.listenStdErr((l) => _logger.err('\t$l')).disposedBy(scope); final exitCode = await process.exitCode; + // if (coverageOptions.coverage) { + // await logProcessor!.stop(); + // await _coverageCollector.stop(); + // } if (exitCode == 0) { task.complete('Completed executing $subject'); @@ -368,4 +409,12 @@ class IOSTestBackend { return jsonEncode(ids); } + + Future _handleStartTest(String uri, Device device) async { + _logger.detail('observatory uri: $uri'); + if(_coverageOptions.coverage) { + _logger.detail('Starting coverage collection'); + await _coverageCollector.start(uri); + } + } } diff --git a/packages/patrol_cli/lib/src/runner/patrol_command.dart b/packages/patrol_cli/lib/src/runner/patrol_command.dart index c03b6808c7..7b2e76d5b8 100644 --- a/packages/patrol_cli/lib/src/runner/patrol_command.dart +++ b/packages/patrol_cli/lib/src/runner/patrol_command.dart @@ -155,6 +155,44 @@ abstract class PatrolCommand extends Command { ); } + void useCoverageOption() { + argParser.addFlag( + 'coverage', + help: 'Whether to collect coverage information.', + negatable: false, + ); + } + + void useFunctionCoverageOption() { + argParser.addFlag( + 'function-coverage', + help: 'Collect function coverage info', + negatable: false, + ); + } + + void useMergeCoverageOption() { + argParser.addFlag( + 'merge-coverage', + help: 'Whether to merge coverage data with "coverage/lcov.base.info', + negatable: false, + ); + } + + void useCoveragePathOption() { + argParser.addOption( + 'coverage-path', + help: 'Where to store coverage information (if coverage is enabled).', + ); + } + + void useCoveragePackageOption() { + argParser.addMultiOption( + 'coverage-package', + help: 'A regular expression matching packages names to include in the coverage report (if coverage is enabled). If unset, matches the current package name', + ); + } + // Runtime-only options void usesUninstallOption() { diff --git a/packages/patrol_cli/pubspec.yaml b/packages/patrol_cli/pubspec.yaml index f8ced06ddb..8a1a383603 100644 --- a/packages/patrol_cli/pubspec.yaml +++ b/packages/patrol_cli/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: ci: ^0.1.0 cli_completion: ^0.4.0 collection: ^1.18.0 + coverage: ^1.8.0 dispose_scope: ^2.1.0 equatable: ^2.0.5 file: ^7.0.0 @@ -34,6 +35,7 @@ dependencies: pub_updater: ^0.4.0 uuid: ^4.2.1 version: ^3.0.2 + vm_service: ^14.2.4 yaml: ^3.1.2 dev_dependencies: build_runner: ^2.4.6