diff --git a/packages/shorebird_cli/bin/shorebird.dart b/packages/shorebird_cli/bin/shorebird.dart index 16e4a675..b6e4e092 100644 --- a/packages/shorebird_cli/bin/shorebird.dart +++ b/packages/shorebird_cli/bin/shorebird.dart @@ -88,6 +88,7 @@ Command: shorebird ${args.join(' ')} shorebirdToolsRef, shorebirdValidatorRef, shorebirdVersionRef, + windowsRef, xcodeBuildRef, }, ), diff --git a/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart index cd9a4fa8..88016927 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart @@ -19,6 +19,7 @@ import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -144,9 +145,10 @@ class WindowsPatcher extends Patcher { zipFile: artifact, outputDirectory: outputDirectory, ); - final exeFile = outputDirectory.listSync().whereType().firstWhere( - (file) => p.extension(file.path) == '.exe', + final executable = windows.findExecutable( + releaseDirectory: outputDirectory, + projectName: shorebirdEnv.getPubspecYaml()!.name, ); - return powershell.getExeVersionString(exeFile); + return powershell.getProductVersion(executable); } } diff --git a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart index 85447769..46ce53a3 100644 --- a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; -import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:shorebird_cli/src/archive/archive.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; @@ -79,14 +78,11 @@ To change the version of this release, change your app's version in your pubspec Future getReleaseVersion({ required FileSystemEntity releaseArtifactRoot, }) { - final exe = (releaseArtifactRoot as Directory) - .listSync() - .whereType() - .firstWhere( - (entity) => p.extension(entity.path) == '.exe', - orElse: () => throw Exception('No .exe found in release artifact'), - ); - return powershell.getExeVersionString(exe); + final executable = windows.findExecutable( + releaseDirectory: releaseArtifactRoot as Directory, + projectName: shorebirdEnv.getPubspecYaml()!.name, + ); + return powershell.getProductVersion(executable); } @override diff --git a/packages/shorebird_cli/lib/src/executables/powershell.dart b/packages/shorebird_cli/lib/src/executables/powershell.dart index 08f52042..adf5aced 100644 --- a/packages/shorebird_cli/lib/src/executables/powershell.dart +++ b/packages/shorebird_cli/lib/src/executables/powershell.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; /// A reference to a [Powershell] instance. @@ -20,6 +21,7 @@ class Powershell { List arguments, { String? workingDirectory, }) async { + logger.detail('[powershell] Command: $executable ${arguments.join(' ')}'); final result = await process.run(executable, arguments); if (result.exitCode != ExitCode.success.code) { throw ProcessException( @@ -33,12 +35,11 @@ class Powershell { } /// Returns the version string of the given executable file. - Future getExeVersionString(File exeFile) async { - final exePath = exeFile.path; - final pwshCommand = - "(Get-Item -Path '$exePath').VersionInfo.ProductVersion"; - - final result = await pwsh(['-Command', pwshCommand]); + Future getProductVersion(File executable) async { + final result = await pwsh([ + '-Command', + "(Get-Item -Path '${executable.path}').VersionInfo.ProductVersion", + ]); return (result.stdout as String).trim(); } diff --git a/packages/shorebird_cli/lib/src/platform/windows.dart b/packages/shorebird_cli/lib/src/platform/windows.dart index c8ff09fe..916e7853 100644 --- a/packages/shorebird_cli/lib/src/platform/windows.dart +++ b/packages/shorebird_cli/lib/src/platform/windows.dart @@ -1,4 +1,9 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; +import 'package:scoped_deps/scoped_deps.dart'; /// The primary release artifact architecture for Windows releases. /// This is a zipped copy of `build/windows/x64/runner/Release`, which is @@ -11,3 +16,35 @@ const primaryWindowsReleaseArtifactArch = 'win_archive'; /// The minimum allowed Flutter version for creating Windows releases. final minimumSupportedWindowsFlutterVersion = Version(3, 32, 6); + +/// A reference to a [Windows] instance. +final windowsRef = create(Windows.new); + +/// The [Windows] instance available in the current zone. +Windows get windows => read(windowsRef); + +/// A class that provides Windows-specific functionality. +class Windows { + /// Returns the selected application `.exe` from [releaseDirectory]. + /// Searches for an exact match for [projectName] and if none is found, + /// falls back to returning the most recently modified executable. + File findExecutable({ + required Directory releaseDirectory, + required String projectName, + }) { + final executables = releaseDirectory + .listSync() + .whereType() + .where((f) => p.extension(f.path).toLowerCase() == '.exe') + .sorted((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync())); + + if (executables.isEmpty) { + throw Exception('No executables found in ${releaseDirectory.path}'); + } + + return executables.firstWhere( + (e) => p.basenameWithoutExtension(e.path) == projectName, + orElse: () => executables.first, + ); + } +} diff --git a/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart index a78a2dd1..39b5e697 100644 --- a/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart @@ -6,6 +6,7 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; @@ -55,6 +56,7 @@ void main() { late ShorebirdEnv shorebirdEnv; late ShorebirdFlutter shorebirdFlutter; late ShorebirdValidator shorebirdValidator; + late Windows windows; late WindowsPatcher patcher; R runWithOverrides(R Function() body) { @@ -75,6 +77,7 @@ void main() { shorebirdEnvRef.overrideWith(() => shorebirdEnv), shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + windowsRef.overrideWith(() => windows), }, ); } @@ -108,6 +111,7 @@ void main() { shorebirdEnv = MockShorebirdEnv(); shorebirdFlutter = MockShorebirdFlutter(); shorebirdValidator = MockShorebirdValidator(); + windows = MockWindows(); when(() => argParser.options).thenReturn({}); @@ -511,7 +515,18 @@ void main() { }); group('extractReleaseVersionFromArtifact', () { + const projectName = 'my_app'; + const productVersion = '1.2.3'; + + late File executable; + late Pubspec pubspec; + setUp(() async { + executable = File(p.join(projectRoot.path, 'my_app.exe')); + pubspec = MockPubspec(); + + when(() => shorebirdEnv.getPubspecYaml()).thenReturn(pubspec); + when(() => pubspec.name).thenReturn(projectName); when( () => artifactManager.extractZip( zipFile: any(named: 'zipFile'), @@ -525,16 +540,29 @@ void main() { ).createSync(recursive: true); }); when( - () => powershell.getExeVersionString(any()), - ).thenAnswer((_) async => '1.2.3'); + () => windows.findExecutable( + releaseDirectory: any(named: 'releaseDirectory'), + projectName: any(named: 'projectName'), + ), + ).thenReturn(executable); + when( + () => powershell.getProductVersion(any()), + ).thenAnswer((_) async => productVersion); }); - test('returns version from archived exe', () async { + test('returns correct version from archived executable', () async { final version = await runWithOverrides( - () => patcher.extractReleaseVersionFromArtifact(File('')), + () => patcher.extractReleaseVersionFromArtifact(executable), ); - expect(version, '1.2.3'); + expect(version, equals(productVersion)); + verify( + () => windows.findExecutable( + releaseDirectory: any(named: 'releaseDirectory'), + projectName: projectName, + ), + ).called(1); + verify(() => powershell.getProductVersion(executable)).called(1); }); }); }); diff --git a/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart index ad557eaa..db33dc98 100644 --- a/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart @@ -5,6 +5,7 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; @@ -15,6 +16,7 @@ import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_flutter.dart'; @@ -45,6 +47,7 @@ void main() { late ShorebirdEnv shorebirdEnv; late ShorebirdFlutter shorebirdFlutter; late ShorebirdValidator shorebirdValidator; + late Windows windows; late WindowsReleaser releaser; R runWithOverrides(R Function() body) { @@ -62,6 +65,7 @@ void main() { shorebirdEnvRef.overrideWith(() => shorebirdEnv), shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + windowsRef.overrideWith(() => windows), }, ); } @@ -88,6 +92,7 @@ void main() { shorebirdEnv = MockShorebirdEnv(); shorebirdFlutter = MockShorebirdFlutter(); shorebirdValidator = MockShorebirdValidator(); + windows = MockWindows(); when(() => argResults.rest).thenReturn([]); when(() => argResults.wasParsed(any())).thenReturn(false); @@ -324,7 +329,24 @@ To change the version of this release, change your app's version in your pubspec }); group('getReleaseVersion', () { - group('when exe does not exist', () { + const projectName = 'my_app'; + + late Pubspec pubspec; + + setUp(() { + pubspec = MockPubspec(); + + when( + () => windows.findExecutable( + releaseDirectory: any(named: 'releaseDirectory'), + projectName: any(named: 'projectName'), + ), + ).thenThrow(Exception('No .exe found in release artifact')); + when(() => shorebirdEnv.getPubspecYaml()).thenReturn(pubspec); + when(() => pubspec.name).thenReturn(projectName); + }); + + group('when an executable does not exist', () { test('throws exception', () { expect( () => runWithOverrides( @@ -336,22 +358,39 @@ To change the version of this release, change your app's version in your pubspec }); }); - group('when exe exists', () { + group('when an executable exists', () { + const productVersion = '1.2.3'; + late File executable; + setUp(() { - File(p.join(projectRoot.path, 'app.exe')).createSync(); + executable = File(p.join(projectRoot.path, 'app.exe')); when( - () => powershell.getExeVersionString(any()), - ).thenAnswer((_) async => '1.2.3'); + () => windows.findExecutable( + releaseDirectory: any(named: 'releaseDirectory'), + projectName: any(named: 'projectName'), + ), + ).thenReturn(executable); + when( + () => powershell.getProductVersion(any()), + ).thenAnswer((_) async => productVersion); }); - test('returns result of getExeVersionString', () async { + test('returns result of powershell.getProductVersion', () async { await expectLater( runWithOverrides( - () => - releaser.getReleaseVersion(releaseArtifactRoot: projectRoot), + () => releaser.getReleaseVersion( + releaseArtifactRoot: projectRoot, + ), ), - completion(equals('1.2.3')), + completion(equals(productVersion)), ); + verify( + () => windows.findExecutable( + releaseDirectory: projectRoot, + projectName: projectName, + ), + ).called(1); + verify(() => powershell.getProductVersion(executable)).called(1); }); }); }); diff --git a/packages/shorebird_cli/test/src/executables/powershell_test.dart b/packages/shorebird_cli/test/src/executables/powershell_test.dart index 3a591b54..cfe68c14 100644 --- a/packages/shorebird_cli/test/src/executables/powershell_test.dart +++ b/packages/shorebird_cli/test/src/executables/powershell_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:mocktail/mocktail.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:test/test.dart'; @@ -10,6 +11,7 @@ import '../mocks.dart'; void main() { group(Powershell, () { + late ShorebirdLogger logger; late ShorebirdProcessResult processResult; late ShorebirdProcess process; late Powershell powershell; @@ -17,11 +19,15 @@ void main() { R runWithOverrides(R Function() body) { return runScoped( () => body(), - values: {processRef.overrideWith(() => process)}, + values: { + loggerRef.overrideWith(() => logger), + processRef.overrideWith(() => process), + }, ); } setUp(() { + logger = MockShorebirdLogger(); processResult = MockShorebirdProcessResult(); process = MockShorebirdProcess(); @@ -36,7 +42,7 @@ void main() { powershell = runWithOverrides(Powershell.new); }); - group('getExeVersionString', () { + group('getProductVersion', () { group('when exit code is not success', () { setUp(() { when(() => processResult.exitCode).thenReturn(1); @@ -44,7 +50,7 @@ void main() { test('throws an exception', () async { await expectLater( - runWithOverrides(() => powershell.getExeVersionString(File(''))), + runWithOverrides(() => powershell.getProductVersion(File(''))), throwsA(isA()), ); }); @@ -59,7 +65,7 @@ void main() { test('returns unaltered version string', () async { final version = await runWithOverrides( - () => powershell.getExeVersionString(File('')), + () => powershell.getProductVersion(File('')), ); expect(version, '1.0.0+1'); }); @@ -76,7 +82,7 @@ void main() { 'directory with spaces', ); final file = File('${directory.path}/file.exe'); - await runWithOverrides(() => powershell.getExeVersionString(file)); + await runWithOverrides(() => powershell.getProductVersion(file)); verify( () => process.run('powershell.exe', [ '-Command', @@ -96,7 +102,7 @@ void main() { 'returns the version string without a build number', () async { final version = await runWithOverrides( - () => powershell.getExeVersionString(File('')), + () => powershell.getProductVersion(File('')), ); expect(version, '1.0.0'); }, diff --git a/packages/shorebird_cli/test/src/mocks.dart b/packages/shorebird_cli/test/src/mocks.dart index 9521a199..eb1a952b 100644 --- a/packages/shorebird_cli/test/src/mocks.dart +++ b/packages/shorebird_cli/test/src/mocks.dart @@ -7,6 +7,7 @@ import 'package:jwt/jwt.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:platform/platform.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:shorebird_cli/src/android_sdk.dart'; import 'package:shorebird_cli/src/android_studio.dart'; import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; @@ -148,6 +149,8 @@ class MockProcess extends Mock implements Process {} class MockProgress extends Mock implements Progress {} +class MockPubspec extends Mock implements Pubspec {} + class MockPubspecEditor extends Mock implements PubspecEditor {} class MockRelease extends Mock implements Release {} @@ -187,4 +190,6 @@ class MockStdout extends Mock implements Stdout {} class MockValidator extends Mock implements Validator {} +class MockWindows extends Mock implements Windows {} + class MockXcodeBuild extends Mock implements XcodeBuild {} diff --git a/packages/shorebird_cli/test/src/platform/windows_test.dart b/packages/shorebird_cli/test/src/platform/windows_test.dart new file mode 100644 index 00000000..eb845d07 --- /dev/null +++ b/packages/shorebird_cli/test/src/platform/windows_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/platform/windows.dart'; +import 'package:test/test.dart'; + +void main() { + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: {windowsRef.overrideWith(Windows.new)}, + ); + } + + group(Windows, () { + group('findExecutable', () { + const projectName = 'my_app'; + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync(); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + group('when no executables exist', () { + test('throws an exception', () { + expect( + () => runWithOverrides( + () => windows.findExecutable( + releaseDirectory: tempDir, + projectName: projectName, + ), + ), + throwsA(isA()), + ); + }); + }); + + group('when an exact match exists', () { + late File app; + + setUp(() { + app = File(p.join(tempDir.path, '$projectName.exe'))..createSync(); + File(p.join(tempDir.path, 'other.exe')).createSync(); + }); + + test('returns it', () { + final executable = runWithOverrides( + () => windows.findExecutable( + releaseDirectory: tempDir, + projectName: projectName, + ), + ); + expect(executable.path, equals(app.path)); + }); + }); + + group('when an exact match does not exist', () { + late File app; + + setUp(() async { + File(p.join(tempDir.path, 'other.exe')).createSync(); + // Ensure my_app is created after other. + await Future.delayed(const Duration(seconds: 1)); + app = File(p.join(tempDir.path, '$projectName.exe'))..createSync(); + }); + + test('returns most recently modified executable', () { + final selected = runWithOverrides( + () => windows.findExecutable( + releaseDirectory: tempDir, + projectName: 'runner', + ), + ); + expect(selected.path, equals(app.path)); + }); + }); + }); + }); +}