fix(windows): select correct executable when multiple executables are generated (#3319)

Co-authored-by: Felix Angelov <felix@shorebird.dev>
Co-authored-by: Bryan Oltman <bryan@shorebird.dev>
This commit is contained in:
Jason Holt
2025-10-01 02:55:50 +08:00
committed by GitHub
parent 50f46aaa67
commit ec8fc9441b
10 changed files with 237 additions and 38 deletions
@@ -88,6 +88,7 @@ Command: shorebird ${args.join(' ')}
shorebirdToolsRef,
shorebirdValidatorRef,
shorebirdVersionRef,
windowsRef,
xcodeBuildRef,
},
),
@@ -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<File>().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);
}
}
@@ -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<String> getReleaseVersion({
required FileSystemEntity releaseArtifactRoot,
}) {
final exe = (releaseArtifactRoot as Directory)
.listSync()
.whereType<File>()
.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
@@ -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<String> 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<String> getExeVersionString(File exeFile) async {
final exePath = exeFile.path;
final pwshCommand =
"(Get-Item -Path '$exePath').VersionInfo.ProductVersion";
final result = await pwsh(['-Command', pwshCommand]);
Future<String> getProductVersion(File executable) async {
final result = await pwsh([
'-Command',
"(Get-Item -Path '${executable.path}').VersionInfo.ProductVersion",
]);
return (result.stdout as String).trim();
}
@@ -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<File>()
.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,
);
}
}
@@ -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>(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);
});
});
});
@@ -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>(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);
});
});
});
@@ -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>(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<Exception>()),
);
});
@@ -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');
},
@@ -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 {}
@@ -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>(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<Exception>()),
);
});
});
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<void>.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));
});
});
});
});
}