diff --git a/packages/dart_test_adapter/lib/src/sdk_lookup.dart b/packages/dart_test_adapter/lib/src/sdk_lookup.dart new file mode 100644 index 0000000..72de744 --- /dev/null +++ b/packages/dart_test_adapter/lib/src/sdk_lookup.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:path/path.dart' as p; +import 'package:universal_io/io.dart'; + +/// An SDK. +enum Sdk { + /// The Dart SDK. + dart, + + /// The Flutter SDK. + flutter, +} + +/// {@template sdk_not_found_exception} +/// The exception thrown when and [Sdk] is not found. +/// {@endtemplate} +class SdkNotFoundException implements Exception { + /// {@macro sdk_not_found_exception} + const SdkNotFoundException({ + required this.sdk, + }); + + /// The [Sdk] that was not found. + final Sdk sdk; + + @override + String toString() => ''' +${sdk.capitalizedName} SDK not found. +Verify that the SDK path has been added to your PATH environment variable.'''; +} + +/// A set of utilities for working with an [Sdk]. +extension ExtendedSdk on Sdk { + /// The capitalized name of the [Sdk]. + String get capitalizedName => [ + name[0].toUpperCase(), + name.substring(1), + ].join(); + + /// The collection of valid extensions for the [Sdk] according to the current + /// platform. + /// + /// The [Sdk]s installation always includes executables for all the supported + /// platforms. This set of extensions is used to determine which of those are + /// valid for the current platform. + Iterable get _extensions => + Platform.isWindows ? ['.bat', '.exe'] : ['.sh', '']; + + /// The lookup command according to the current platform. + static final _lookupCommand = Platform.isWindows ? 'where.exe' : 'which'; + + static const _lineSplitter = LineSplitter(); + + /// The collection of valid executable paths for the [Sdk]. + /// + /// Might be empty if the [Sdk] is not found. + Future> getExecutablePaths({ + Map? env, + }) async { + final commandLookupResult = await Process.run( + _lookupCommand, + [name], + environment: env, + ); + final commandPaths = _lineSplitter + .convert(commandLookupResult.stdout as String) + .where((s) => _extensions.any((ext) => p.extension(s) == ext)); + return commandPaths; + } + + /// The default [Sdk] executable path. + /// + /// Throws [SdkNotFoundException] if the [Sdk] is not found. + Future getDefaultExecutablePath({ + Map? env, + }) async { + final paths = await getExecutablePaths(env: env); + if (paths.isEmpty) throw SdkNotFoundException(sdk: this); + return paths.first; + } +} diff --git a/packages/dart_test_adapter/lib/src/test_runner.dart b/packages/dart_test_adapter/lib/src/test_runner.dart index c077222..26449df 100644 --- a/packages/dart_test_adapter/lib/src/test_runner.dart +++ b/packages/dart_test_adapter/lib/src/test_runner.dart @@ -2,44 +2,49 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'sdk_lookup.dart'; import 'test_protocol.dart'; -/// Executes `flutter test` and decode the output +/// Executes `flutter test` and decode the output. +/// +/// Throws a [SdkNotFoundException] if the Flutter SDK is not found. Stream flutterTest({ Map? environment, List? arguments, List? tests, + // TODO: Typo String? workdingDirectory, // TODO(rrousselGit) expose a typed interface for CLI parameters -}) { - return _parseTestJsonOutput( - () => Process.start( - 'flutter', - [ - 'test', - ...?arguments, - '--reporter=json', - '--no-pub', - ...?tests, - ], - environment: environment, - workingDirectory: workdingDirectory, - ), - ); -} +}) => + _parseTestJsonOutput( + () async => Process.start( + await Sdk.flutter.getDefaultExecutablePath(env: environment), + [ + 'test', + ...?arguments, + '--reporter=json', + '--no-pub', + ...?tests, + ], + environment: environment, + workingDirectory: workdingDirectory, + ), + ); -/// Executes `dart test` and decode the output +/// Executes `dart test` and decode the output. +/// +/// Throws a [SdkNotFoundException] if the Dart SDK is not found. Stream dartTest({ Map? environment, List? arguments, List? tests, + // TODO: Typo String? workdingDirectory, // TODO(rrousselGit) expose a typed interface for CLI parameters -}) { - return _parseTestJsonOutput( - () { - return Process.start( - 'dart', +}) => + _parseTestJsonOutput( + () async => Process.start( + await Sdk.dart.getDefaultExecutablePath(env: environment), [ // '--packages=${await Isolate.packageConfig}', 'test', @@ -50,10 +55,8 @@ Stream dartTest({ ], environment: environment, workingDirectory: workdingDirectory, - ); - }, - ); -} + ), + ); Stream _parseTestJsonOutput( Future Function() processCb, diff --git a/packages/dart_test_adapter/pubspec.yaml b/packages/dart_test_adapter/pubspec.yaml index bc8f1c4..167ecf2 100644 --- a/packages/dart_test_adapter/pubspec.yaml +++ b/packages/dart_test_adapter/pubspec.yaml @@ -9,7 +9,9 @@ environment: dependencies: freezed_annotation: ^1.1.0 json_annotation: ^4.4.0 + path: ^1.8.1 test: ^1.17.8 + universal_io: ^2.0.4 dev_dependencies: build_runner: ^2.1.7 diff --git a/packages/dart_test_adapter/test/src/helpers.dart b/packages/dart_test_adapter/test/src/helpers.dart new file mode 100644 index 0000000..2b95e7b --- /dev/null +++ b/packages/dart_test_adapter/test/src/helpers.dart @@ -0,0 +1,29 @@ +import 'package:dart_test_adapter/src/sdk_lookup.dart'; +import 'package:path/path.dart' as p; +import 'package:universal_io/io.dart'; + +/// Creates an **empty** temp dir. +/// +/// Returns the underlying folder for the created temp dir. +Directory setupTmpDir({required String dirName}) { + final tmpDir = Directory(p.join(Directory.systemTemp.path, dirName)); + if (tmpDir.existsSync()) tmpDir.deleteSync(recursive: true); + return tmpDir..createSync(recursive: true); +} + +extension FakeSdk on Sdk { + /// Creates a fake [Sdk] executable within the given [dir] and with the given + /// [extension]. + /// + /// Returns the underlying file linked to the created fake executable. + File setupFakeExecutable({ + required Directory dir, + required String extension, + }) { + final executableName = p.setExtension(name, extension); + final executablePath = p.join(dir.path, executableName); + final executable = File(executablePath); + if (executable.existsSync()) executable.delete(recursive: true); + return executable..createSync(recursive: true); + } +} diff --git a/packages/dart_test_adapter/test/src/sdk_lookup_test.dart b/packages/dart_test_adapter/test/src/sdk_lookup_test.dart new file mode 100644 index 0000000..568fcb5 --- /dev/null +++ b/packages/dart_test_adapter/test/src/sdk_lookup_test.dart @@ -0,0 +1,99 @@ +import 'package:dart_test_adapter/src/sdk_lookup.dart'; +import 'package:test/test.dart'; +import 'package:universal_io/io.dart'; + +import 'helpers.dart'; + +void main() { + for (final sdk in Sdk.values) { + group('${sdk.capitalizedName} SDK executables lookup', () { + final extensionCases = Platform.isWindows + ? [ + ['.exe', '.bat'], + ['.bat', '.exe'], + ] + : [ + ['.sh', ''], + ['', '.sh'], + ]; + + for (var caseIdx = 0; caseIdx < extensionCases.length; caseIdx++) { + final extensions = extensionCases[caseIdx]; + test( + 'returns a set of executable paths when found, keeping their ' + 'priority order', () async { + final executableDirPaths = []; + final executablePaths = []; + for (var extIdx = 0; extIdx < extensions.length; extIdx++) { + final executableDirName = '${sdk.name}-all-$caseIdx-$extIdx'; + final executableDir = setupTmpDir(dirName: executableDirName); + final extension = extensions[extIdx]; + final executable = sdk.setupFakeExecutable( + dir: executableDir, + extension: extension, + ); + executableDirPaths.add(executableDir.path); + executablePaths.add(executable.path); + } + final env = { + 'PATH': executableDirPaths.join(Platform.isWindows ? ';' : ':'), + }; + final foundExecutablePaths = await sdk.getExecutablePaths(env: env); + expect(foundExecutablePaths, executablePaths); + }); + } + + test('returns an empty collection of executable paths when not found', + () async { + final executablesDir = setupTmpDir(dirName: '${sdk.name}-none'); + final env = {'PATH': executablesDir.path}; + final executablePaths = await sdk.getExecutablePaths(env: env); + expect(executablePaths, isEmpty); + }); + }); + + group('${sdk.capitalizedName} SDK default executable lookup', () { + test('returns the prime valid executable path when found', () async { + final extensions = Platform.isWindows ? ['.bat', '.exe'] : ['.sh', '']; + final executableDirPaths = []; + final executablePaths = []; + for (var extIdx = 0; extIdx < extensions.length; extIdx++) { + final executableDirName = '${sdk.name}-default-$extIdx'; + final executableDir = setupTmpDir(dirName: executableDirName); + final extension = extensions[extIdx]; + final executable = sdk.setupFakeExecutable( + dir: executableDir, + extension: extension, + ); + executableDirPaths.add(executableDir.path); + executablePaths.add(executable.path); + } + final env = { + 'PATH': executableDirPaths.join(Platform.isWindows ? ';' : ':'), + }; + final foundExecutablePath = + await sdk.getDefaultExecutablePath(env: env); + expect(foundExecutablePath, executablePaths.first); + }); + + test('throws an [SdkNotFoundException] when no prime executable is found', + () async { + final executablesDir = setupTmpDir(dirName: '${sdk.name}-throw'); + final env = {'PATH': executablesDir.path}; + Future defaultExecutableLookup() => + sdk.getDefaultExecutablePath(env: env); + final errorMsg = ''' +${sdk.capitalizedName} SDK not found. +Verify that the SDK path has been added to your PATH environment variable.'''; + expect( + defaultExecutableLookup, + throwsA( + isA() + .having((e) => e.sdk, 'sdk', sdk) + .having((e) => e.toString(), 'message', errorMsg), + ), + ); + }); + }); + } +}