diff --git a/bin/internal/flutter.version b/bin/internal/flutter.version index f58ec2159..7452e0090 100644 --- a/bin/internal/flutter.version +++ b/bin/internal/flutter.version @@ -1 +1 @@ -77b6dc95d4e1f2de2af3c9241b874f0ede96e1ed +56228c343d6c7fd3e1e548dbb290f9713bb22aa9 \ No newline at end of file diff --git a/packages/shorebird_cli/bin/shorebird.dart b/packages/shorebird_cli/bin/shorebird.dart index 1021d8acc..e6ebb65f1 100644 --- a/packages/shorebird_cli/bin/shorebird.dart +++ b/packages/shorebird_cli/bin/shorebird.dart @@ -80,6 +80,7 @@ Command: shorebird ${args.join(' ')} patchExecutableRef, patchDiffCheckerRef, platformRef, + powershellRef, processRef, pubspecEditorRef, shorebirdAndroidArtifactsRef, diff --git a/packages/shorebird_cli/lib/src/archive_analysis/archive_analysis.dart b/packages/shorebird_cli/lib/src/archive_analysis/archive_analysis.dart index 1d7ef655d..b1d9c4a3c 100644 --- a/packages/shorebird_cli/lib/src/archive_analysis/archive_analysis.dart +++ b/packages/shorebird_cli/lib/src/archive_analysis/archive_analysis.dart @@ -2,3 +2,4 @@ export 'android_archive_differ.dart'; export 'apple_archive_differ.dart'; export 'file_set_diff.dart'; export 'plist.dart'; +export 'windows_archive_differ.dart'; diff --git a/packages/shorebird_cli/lib/src/archive_analysis/file_set_diff.dart b/packages/shorebird_cli/lib/src/archive_analysis/file_set_diff.dart index fd4d7c822..4621aa0ac 100644 --- a/packages/shorebird_cli/lib/src/archive_analysis/file_set_diff.dart +++ b/packages/shorebird_cli/lib/src/archive_analysis/file_set_diff.dart @@ -1,12 +1,13 @@ import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; /// Maps file paths to SHA-256 hash digests. typedef PathHashes = Map; /// Sets of [PathHashes] that represent changes between two sets of files. -class FileSetDiff { +class FileSetDiff extends Equatable { /// Creates a [FileSetDiff] showing added, changed, and removed file sets. - FileSetDiff({ + const FileSetDiff({ required this.addedPaths, required this.removedPaths, required this.changedPaths, @@ -72,4 +73,7 @@ class FileSetDiff { $padding$title: ${paths.sorted().map((p) => '${padding * 2}$p').join('\n')}'''; } + + @override + List get props => [addedPaths, removedPaths, changedPaths]; } diff --git a/packages/shorebird_cli/lib/src/archive_analysis/portable_executable.dart b/packages/shorebird_cli/lib/src/archive_analysis/portable_executable.dart new file mode 100644 index 000000000..72cc029cf --- /dev/null +++ b/packages/shorebird_cli/lib/src/archive_analysis/portable_executable.dart @@ -0,0 +1,24 @@ +import 'dart:io'; +import 'dart:typed_data'; + +/// Utilities for interacting with Windows Portable Executable files. +class PortableExecutable { + /// Zeroes out the timestamps in the provided PE file to enable comparison of + /// binaries with different build times. + /// + /// Timestamps are DWORD (4-byte) values at: + /// 1. offset 0x110 in the PE header. + /// 2. offset 0x6e14 (seems to be in section 1, need to figure out a robust + /// way to find this). + /// + /// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#coff-file-header-object-and-image + static Uint8List bytesWithZeroedTimestamps(File file) { + final bytes = file.readAsBytesSync(); + final timestampLocations = [0x110, 0x6e14]; + for (final location in timestampLocations) { + bytes.setRange(location, location + 4, List.filled(4, 0)); + } + + return bytes; + } +} diff --git a/packages/shorebird_cli/lib/src/archive_analysis/windows_archive_differ.dart b/packages/shorebird_cli/lib/src/archive_analysis/windows_archive_differ.dart new file mode 100644 index 000000000..0c0dd7aa1 --- /dev/null +++ b/packages/shorebird_cli/lib/src/archive_analysis/windows_archive_differ.dart @@ -0,0 +1,105 @@ +import 'dart:io'; +import 'dart:isolate'; + +import 'package:archive/archive_io.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; +import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; +import 'package:shorebird_cli/src/archive_analysis/portable_executable.dart'; + +/// {@template windows_archive_differ} +/// Finds differences between two Windows app packages. +/// {@endtemplate} +class WindowsArchiveDiffer extends ArchiveDiffer { + /// {@macro windows_archive_differ} + const WindowsArchiveDiffer(); + + String _hash(List bytes) => sha256.convert(bytes).toString(); + + bool _isDirectoryPath(String path) { + return path.endsWith('/'); + } + + @override + bool isAssetFilePath(String filePath) { + // We don't care if an empty directory is added or removed, so ignore paths + // that end with a '/'. + return !_isDirectoryPath(filePath) && + p.split(filePath).contains('flutter_assets'); + } + + @override + bool isDartFilePath(String filePath) { + return p.basename(filePath) == 'app.so'; + } + + @override + bool isNativeFilePath(String filePath) { + // TODO(bryanoltman): reenable this check once we can reliably report + // native diffs + // const nativeFileExtensions = ['.dll', '.exe']; + // return nativeFileExtensions.contains(p.extension(filePath)); + return false; + } + + @override + Future changedFiles( + String oldArchivePath, + String newArchivePath, + ) async { + var oldPathHashes = await fileHashes(File(oldArchivePath)); + var newPathHashes = await fileHashes(File(newArchivePath)); + + oldPathHashes = await _updateHashes( + archivePath: oldArchivePath, + pathHashes: oldPathHashes, + ); + newPathHashes = await _updateHashes( + archivePath: newArchivePath, + pathHashes: newPathHashes, + ); + + return FileSetDiff.fromPathHashes( + oldPathHashes: oldPathHashes, + newPathHashes: newPathHashes, + ); + } + + /// Removes the timestamps from exe headers + Future _updateHashes({ + required String archivePath, + required PathHashes pathHashes, + }) async { + return Isolate.run(() async { + for (final file in _exeFiles(archivePath)) { + pathHashes[file.name] = await _sanitizedFileHash(file); + } + + return pathHashes; + }); + } + + Future _sanitizedFileHash(ArchiveFile file) async { + final tempDir = Directory.systemTemp.createTempSync(); + final outPath = p.join(tempDir.path, file.name); + final outputStream = OutputFileStream(outPath); + file.writeContent(outputStream); + await outputStream.close(); + + final outFile = File(outPath); + final bytes = PortableExecutable.bytesWithZeroedTimestamps(outFile); + return _hash(bytes); + } + + List _exeFiles(String archivePath) { + return ZipDecoder() + .decodeStream(InputFileStream(archivePath)) + .files + .where((file) => file.isFile) + .where( + (file) => p.extension(file.name) == '.exe', + ) + .toList(); + } +} diff --git a/packages/shorebird_cli/lib/src/artifact_builder.dart b/packages/shorebird_cli/lib/src/artifact_builder.dart index 6b96732de..84540d3f6 100644 --- a/packages/shorebird_cli/lib/src/artifact_builder.dart +++ b/packages/shorebird_cli/lib/src/artifact_builder.dart @@ -8,6 +8,7 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/os/operating_system_interface.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; @@ -547,6 +548,53 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod return File(outFilePath); } + /// Builds a windows app and returns the x64 Release directory + Future buildWindowsApp({ + String? flavor, + String? target, + List args = const [], + String? base64PublicKey, + DetailProgress? buildProgress, + }) async { + await _runShorebirdBuildCommand(() async { + const executable = 'flutter'; + final arguments = [ + 'build', + 'windows', + '--release', + ...args, + ]; + + final buildProcess = await process.start( + executable, + arguments, + runInShell: true, + // TODO(bryanoltman): support this + // environment: base64PublicKey?.toPublicKeyEnv(), + ); + + buildProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + logger.detail(line); + // TODO(bryanoltman): update build progress + }); + + final stderrLines = await buildProcess.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .toList(); + final stdErr = stderrLines.join('\n'); + final exitCode = await buildProcess.exitCode; + if (exitCode != ExitCode.success.code) { + throw ArtifactBuildException('Failed to build: $stdErr'); + } + }); + + return artifactManager.getWindowsReleaseDirectory(); + } + /// Given a log of verbose output from `flutter build ipa`, returns a /// progress update message to display to the user if the line contains /// a known progress update step. Returns null (no update) otherwise. diff --git a/packages/shorebird_cli/lib/src/artifact_manager.dart b/packages/shorebird_cli/lib/src/artifact_manager.dart index 213109a0a..5b2e8a01e 100644 --- a/packages/shorebird_cli/lib/src/artifact_manager.dart +++ b/packages/shorebird_cli/lib/src/artifact_manager.dart @@ -347,6 +347,21 @@ class ArtifactManager { .firstWhereOrNull((directory) => directory.path.endsWith('.app')); } + /// Returns the build/ subdirectory containing the compiled Windows exe. + Directory getWindowsReleaseDirectory() { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; + return Directory( + p.join( + projectRoot.path, + 'build', + 'windows', + 'x64', + 'runner', + 'Release', + ), + ); + } + /// Returns the path to the .ipa file generated by `flutter build ipa`. /// /// Returns null if: diff --git a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart index 28906614d..74d984d63 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -9,6 +9,7 @@ import 'dart:isolate'; import 'package:archive/archive_io.dart'; import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; +import 'package:equatable/equatable.dart'; import 'package:io/io.dart' as io; import 'package:mason_logger/mason_logger.dart'; import 'package:meta/meta.dart'; @@ -31,7 +32,7 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; /// {@template patch_artifact_bundle} /// Metadata about a patch artifact that we are about to upload. /// {@endtemplate} -class PatchArtifactBundle { +class PatchArtifactBundle extends Equatable { /// {@macro patch_artifact_bundle} const PatchArtifactBundle({ required this.arch, @@ -55,6 +56,9 @@ class PatchArtifactBundle { /// The signature of the artifact hash. final String? hashSignature; + + @override + List get props => [arch, path, hash, size, hashSignature]; } // A reference to a [CodePushClientWrapper] instance. @@ -520,6 +524,44 @@ aab artifact already exists, continuing...''', createArtifactProgress.complete(); } + Future createWindowsReleaseArtifacts({ + required String appId, + required int releaseId, + required String projectRoot, + required String releaseZipPath, + }) async { + final createArtifactProgress = logger.progress('Uploading artifacts'); + + try { + // logger.detail('Uploading artifact for $aabPath'); + await codePushClient.createReleaseArtifact( + appId: appId, + releaseId: releaseId, + artifactPath: releaseZipPath, + arch: primaryWindowsReleaseArtifactArch, + platform: ReleasePlatform.windows, + hash: + sha256.convert(await File(releaseZipPath).readAsBytes()).toString(), + canSideload: true, + podfileLockHash: null, + ); + } on CodePushConflictException catch (_) { + // Newlines are due to how logger.info interacts with logger.progress. + logger.info( + ''' + +Windows release (exe) artifact already exists, continuing...''', + ); + } catch (error) { + _handleErrorAndExit( + error, + progress: createArtifactProgress, + message: 'Error uploading: $error', + ); + } + createArtifactProgress.complete(); + } + Future createAndroidArchiveReleaseArtifacts({ required String appId, required int releaseId, diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch.dart b/packages/shorebird_cli/lib/src/commands/patch/patch.dart index d4d864698..2a0d45903 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch.dart @@ -5,3 +5,4 @@ export 'ios_patcher.dart'; export 'macos_patcher.dart'; export 'patch_command.dart'; export 'patcher.dart'; +export 'windows_patcher.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index 00dcd9f5c..e2244a60e 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -205,6 +205,10 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl logger.warn(macosBetaWarning); } + if (results.releaseTypes.contains(ReleaseType.windows)) { + logger.warn(windowsBetaWarning); + } + final patcherFutures = results.releaseTypes.map(_resolvePatcher).map(createPatch); @@ -254,6 +258,13 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl flavor: flavor, target: target, ); + case ReleaseType.windows: + return WindowsPatcher( + argResults: results, + argParser: argParser, + flavor: flavor, + target: target, + ); } } diff --git a/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart new file mode 100644 index 000000000..6e6498bee --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patch/windows_patcher.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +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/archive_analysis/windows_archive_differ.dart'; +import 'package:shorebird_cli/src/artifact_builder.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/patch/patcher.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/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_flutter.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'; + +/// {@template windows_patcher} +/// Functions to create a Windows patch. +/// {@endtemplate} +class WindowsPatcher extends Patcher { + /// {@macro windows_patcher} + WindowsPatcher({ + required super.argParser, + required super.argResults, + required super.flavor, + required super.target, + }); + + @override + String get primaryReleaseArtifactArch => primaryWindowsReleaseArtifactArch; + + @override + ReleaseType get releaseType => ReleaseType.windows; + + @override + Future assertPreconditions() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: doctor.windowsCommandValidators, + supportedOperatingSystems: {Platform.windows}, + ); + } on PreconditionFailedException catch (e) { + throw ProcessExit(e.exitCode.code); + } + } + + @override + Future assertUnpatchableDiffs({ + required ReleaseArtifact releaseArtifact, + required File releaseArchive, + required File patchArchive, + }) { + return patchDiffChecker.confirmUnpatchableDiffsIfNecessary( + localArchive: patchArchive, + releaseArchive: releaseArchive, + archiveDiffer: const WindowsArchiveDiffer(), + allowAssetChanges: allowAssetDiffs, + allowNativeChanges: allowNativeDiffs, + ); + } + + @override + Future buildPatchArtifact({ + String? releaseVersion, + }) async { + final flutterVersionString = await shorebirdFlutter.getVersionAndRevision(); + + final buildAppBundleProgress = logger.detailProgress( + 'Building Windows app with Flutter $flutterVersionString', + ); + + final Directory releaseDir; + try { + releaseDir = await artifactBuilder.buildWindowsApp(); + buildAppBundleProgress.complete(); + } on Exception catch (e) { + buildAppBundleProgress.fail(e.toString()); + throw ProcessExit(ExitCode.software.code); + } + + return releaseDir.zipToTempFile(); + } + + @override + Future> createPatchArtifacts({ + required String appId, + required int releaseId, + required File releaseArtifact, + File? supplementArtifact, + }) async { + final createDiffProgress = logger.progress('Creating patch artifacts'); + final patchArtifactPath = p.join( + artifactManager.getWindowsReleaseDirectory().path, + 'data', + 'app.so', + ); + final patchArtifact = File(patchArtifactPath); + final hash = sha256.convert(await patchArtifact.readAsBytes()).toString(); + + final tempDir = Directory.systemTemp.createTempSync(); + final zipPath = p.join(tempDir.path, 'patch.zip'); + final zipFile = releaseArtifact.copySync(zipPath); + await artifactManager.extractZip( + zipFile: zipFile, + outputDirectory: tempDir, + ); + + // The release artifact is the zipped directory at + // build/windows/x64/runner/Release + final appSoPath = p.join(tempDir.path, 'data', 'app.so'); + + final String diffPath; + try { + diffPath = await artifactManager.createDiff( + releaseArtifactPath: appSoPath, + patchArtifactPath: patchArtifactPath, + ); + } on Exception catch (error) { + createDiffProgress.fail('$error'); + throw ProcessExit(ExitCode.software.code); + } + + createDiffProgress.complete(); + + return { + Arch.x86_64: PatchArtifactBundle( + arch: Arch.x86_64.arch, + path: diffPath, + hash: hash, + size: File(diffPath).lengthSync(), + ), + }; + } + + @override + Future extractReleaseVersionFromArtifact(File artifact) async { + final outputDirectory = Directory.systemTemp.createTempSync(); + await artifactManager.extractZip( + zipFile: artifact, + outputDirectory: outputDirectory, + ); + final exeFile = outputDirectory + .listSync() + .whereType() + .firstWhere((file) => p.extension(file.path) == '.exe'); + return powershell.getExeVersionString(exeFile); + } +} diff --git a/packages/shorebird_cli/lib/src/commands/preview_command.dart b/packages/shorebird_cli/lib/src/commands/preview_command.dart index ebd227f37..4660242a0 100644 --- a/packages/shorebird_cli/lib/src/commands/preview_command.dart +++ b/packages/shorebird_cli/lib/src/commands/preview_command.dart @@ -18,8 +18,10 @@ import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/platform.dart'; +import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_process.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'; @@ -277,7 +279,11 @@ This is only applicable when previewing Android releases.''', deviceId: deviceId, track: track, ), - ReleasePlatform.windows => throw UnimplementedError(), + ReleasePlatform.windows => installAndLaunchWindows( + appId: appId, + release: release, + track: track, + ), }; } @@ -316,6 +322,72 @@ This is only applicable when previewing Android releases.''', return ReleasePlatform.values.firstWhere((p) => p.displayName == platform); } + /// Downloads and runs the given [release] of the given [appId] on Windows. + Future installAndLaunchWindows({ + required String appId, + required Release release, + required DeploymentTrack track, + }) async { + const platform = ReleasePlatform.windows; + late Directory appDirectory; + late ReleaseArtifact releaseExeArtifact; + + try { + releaseExeArtifact = await codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: primaryWindowsReleaseArtifactArch, + platform: platform, + ); + } on Exception catch (e, s) { + logger + ..err('Error getting release artifact: $e') + ..detail('Stack trace: $s'); + return ExitCode.software.code; + } + + appDirectory = Directory( + getArtifactPath( + appId: appId, + release: release, + artifact: releaseExeArtifact, + platform: platform, + extension: 'exe', + ), + ); + + if (!appDirectory.existsSync()) { + final downloadArtifactProgress = logger.progress('Downloading release'); + try { + if (!appDirectory.existsSync()) { + appDirectory.createSync(recursive: true); + } + + final archiveFile = await artifactManager.downloadFile( + Uri.parse(releaseExeArtifact.url), + ); + await artifactManager.extractZip( + zipFile: archiveFile, + outputDirectory: appDirectory, + ); + downloadArtifactProgress.complete(); + } on Exception catch (error) { + downloadArtifactProgress.fail('$error'); + return ExitCode.software.code; + } + } + + final exeFile = appDirectory + .listSync() + .whereType() + .firstWhere((file) => file.path.endsWith('.exe')); + + final proc = await process.start(exeFile.path, []); + proc.stdout.listen((log) => logger.info(utf8.decode(log))); + proc.stderr.listen((log) => logger.err(utf8.decode(log))); + return proc.exitCode; + } + /// Installs and launches the release on macOS. Future installAndLaunchMacos({ required String appId, diff --git a/packages/shorebird_cli/lib/src/commands/release/release.dart b/packages/shorebird_cli/lib/src/commands/release/release.dart index d26b2cf06..5f30db813 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release.dart @@ -5,3 +5,4 @@ export 'ios_releaser.dart'; export 'macos_releaser.dart'; export 'release_command.dart'; export 'releaser.dart'; +export 'windows_releaser.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/release/release_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_command.dart index dc15c6260..1bb8851ff 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -165,6 +165,10 @@ of the iOS app that is using this module. (aar and ios-framework only)''', logger.warn(macosBetaWarning); } + if (results.releaseTypes.contains(ReleaseType.windows)) { + logger.warn(windowsBetaWarning); + } + final releaserFutures = results.releaseTypes.map(_resolveReleaser).map(createRelease); @@ -209,6 +213,12 @@ of the iOS app that is using this module. (aar and ios-framework only)''', flavor: flavor, target: target, ); + case ReleaseType.windows: + return WindowsReleaser( + argResults: results, + flavor: flavor, + target: target, + ); } } diff --git a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart new file mode 100644 index 000000000..062b3eeaf --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart @@ -0,0 +1,146 @@ +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.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/release/releaser.dart'; +import 'package:shorebird_cli/src/doctor.dart'; +import 'package:shorebird_cli/src/executables/executables.dart'; +import 'package:shorebird_cli/src/extensions/arg_results.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_documentation.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.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'; + +/// {@template windows_releaser} +/// Functions to create a Windows release. +/// {@endtemplate} +class WindowsReleaser extends Releaser { + /// {@macro windows_releaser} + WindowsReleaser({ + required super.argResults, + required super.flavor, + required super.target, + }); + + @override + ReleaseType get releaseType => ReleaseType.windows; + + @override + Future assertArgsAreValid() async { + if (argResults.wasParsed('release-version')) { + logger.err( + ''' +The "--release-version" flag is only supported for aar and ios-framework releases. + +To change the version of this release, change your app's version in your pubspec.yaml.''', + ); + throw ProcessExit(ExitCode.usage.code); + } + } + + @override + Future assertPreconditions() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: doctor.windowsCommandValidators, + supportedOperatingSystems: {Platform.windows}, + ); + } on PreconditionFailedException catch (e) { + throw ProcessExit(e.exitCode.code); + } + + final flutterVersionArg = argResults['flutter-version'] as String?; + if (flutterVersionArg != null) { + final version = + await shorebirdFlutter.resolveFlutterVersion(flutterVersionArg); + if (version != null && version < minimumSupportedWindowsFlutterVersion) { + logger.err( + ''' +Windows releases are not supported with Flutter versions older than $minimumSupportedWindowsFlutterVersion. +For more information see: ${supportedFlutterVersionsUrl.toLink()}''', + ); + throw ProcessExit(ExitCode.usage.code); + } + } + } + + @override + Future buildReleaseArtifacts() async { + final flutterVersionString = await shorebirdFlutter.getVersionAndRevision(); + + final buildAppBundleProgress = logger.detailProgress( + 'Building Windows app with Flutter $flutterVersionString', + ); + + final Directory releaseDir; + try { + releaseDir = await artifactBuilder.buildWindowsApp( + flavor: flavor, + target: target, + args: argResults.forwardedArgs, + buildProgress: buildAppBundleProgress, + ); + buildAppBundleProgress.complete(); + } on Exception catch (e) { + buildAppBundleProgress.fail(e.toString()); + throw ProcessExit(ExitCode.software.code); + } + + return releaseDir; + } + + @override + 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); + } + + @override + Future uploadReleaseArtifacts({ + required Release release, + required String appId, + }) async { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; + final releaseDir = artifactManager.getWindowsReleaseDirectory(); + + if (!releaseDir.existsSync()) { + logger.err('No release directory found at ${releaseDir.path}'); + throw ProcessExit(ExitCode.software.code); + } + + final zippedRelease = await releaseDir.zipToTempFile(); + + await codePushClientWrapper.createWindowsReleaseArtifacts( + appId: appId, + releaseId: release.id, + projectRoot: projectRoot.path, + releaseZipPath: zippedRelease.path, + ); + } + + @override + String get postReleaseInstructions => ''' + +Windows executable created at ${artifactManager.getWindowsReleaseDirectory().path}. +'''; +} diff --git a/packages/shorebird_cli/lib/src/doctor.dart b/packages/shorebird_cli/lib/src/doctor.dart index ec4ffc2ea..989f6c426 100644 --- a/packages/shorebird_cli/lib/src/doctor.dart +++ b/packages/shorebird_cli/lib/src/doctor.dart @@ -29,6 +29,11 @@ class Doctor { MacosNetworkEntitlementValidator(), ]; + /// Validators that verify shorebird will work on Windows. + final List windowsCommandValidators = [ + // Check whether powershell is installed? + ]; + /// Validators that should run on all commands. List generalValidators = [ ShorebirdVersionValidator(), diff --git a/packages/shorebird_cli/lib/src/executables/executables.dart b/packages/shorebird_cli/lib/src/executables/executables.dart index edde68bae..58e9d7e25 100644 --- a/packages/shorebird_cli/lib/src/executables/executables.dart +++ b/packages/shorebird_cli/lib/src/executables/executables.dart @@ -12,5 +12,6 @@ export 'ios_deploy.dart'; export 'java.dart'; export 'open.dart'; export 'patch_executable.dart'; +export 'powershell.dart'; export 'shorebird_tools.dart'; export 'xcodebuild.dart'; diff --git a/packages/shorebird_cli/lib/src/executables/open.dart b/packages/shorebird_cli/lib/src/executables/open.dart index 4e8c82f8a..8e5c91f6d 100644 --- a/packages/shorebird_cli/lib/src/executables/open.dart +++ b/packages/shorebird_cli/lib/src/executables/open.dart @@ -11,7 +11,7 @@ final openRef = create(Open.new); /// The [Open] instance available in the current zone. Open get open => read(openRef); -/// A wrapper around the `open` command. +/// A wrapper around the macOS `open` command. /// https://ss64.com/mac/open.html class Open { /// Opens a new application at the provided [path] and streams the stdout and diff --git a/packages/shorebird_cli/lib/src/executables/powershell.dart b/packages/shorebird_cli/lib/src/executables/powershell.dart new file mode 100644 index 000000000..76d5e54f4 --- /dev/null +++ b/packages/shorebird_cli/lib/src/executables/powershell.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; + +/// A reference to a [Powershell] instance. +final powershellRef = create(Powershell.new); + +/// The [Powershell] instance available in the current zone. +Powershell get powershell => read(powershellRef); + +/// A wrapper around all powershell related functionality. +class Powershell { + /// Name of the powershell executable. + static const executable = 'powershell.exe'; + + /// Execute a powershell command with the provided [arguments]. + Future pwsh( + List arguments, { + String? workingDirectory, + bool runInShell = false, + }) async { + final result = await process.run( + executable, + arguments, + runInShell: true, + ); + if (result.exitCode != ExitCode.success.code) { + throw ProcessException( + executable, + arguments, + '${result.stderr}', + result.exitCode, + ); + } + return result; + } + + /// 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], + runInShell: true, + ); + + return (result.stdout as String).trim(); + } +} diff --git a/packages/shorebird_cli/lib/src/platform/platform.dart b/packages/shorebird_cli/lib/src/platform/platform.dart index 3ab7020e4..c229a9e88 100644 --- a/packages/shorebird_cli/lib/src/platform/platform.dart +++ b/packages/shorebird_cli/lib/src/platform/platform.dart @@ -1,6 +1,7 @@ export 'android.dart'; export 'ios.dart'; export 'macos.dart'; +export 'windows.dart'; /// {@template arch} /// Build architectures supported by Shorebird. diff --git a/packages/shorebird_cli/lib/src/platform/windows.dart b/packages/shorebird_cli/lib/src/platform/windows.dart new file mode 100644 index 000000000..2cc1753ea --- /dev/null +++ b/packages/shorebird_cli/lib/src/platform/windows.dart @@ -0,0 +1,20 @@ +import 'package:pub_semver/pub_semver.dart'; + +/// The primary release artifact architecture for Windows releases. +/// This is a zipped copy of `build/windows/x64/runner/Release`, which is +/// produced by the `flutter build windows --release` command. It contains, at +/// its top level: +/// - flutter.dll +/// - app.exe (where `app` is the name of the Flutter app) +/// - data/, which contains flutter assets and the app.so file +const primaryWindowsReleaseArtifactArch = 'win_archive'; + +/// The minimum allowed Flutter version for creating Windows releases. +final minimumSupportedWindowsFlutterVersion = Version(3, 27, 1); + +/// A warning message printed at the start of `shorebird release windows` and +/// `shorebird patch windows` commands. +const windowsBetaWarning = ''' +Windows support is currently in beta. +Please report issues at https://github.com/shorebirdtech/shorebird/issues/new +'''; diff --git a/packages/shorebird_cli/lib/src/release_type.dart b/packages/shorebird_cli/lib/src/release_type.dart index d5028244c..60d616b5f 100644 --- a/packages/shorebird_cli/lib/src/release_type.dart +++ b/packages/shorebird_cli/lib/src/release_type.dart @@ -17,7 +17,10 @@ enum ReleaseType { macos, /// An iOS framework used in a hybrid app. - iosFramework; + iosFramework, + + /// A full Flutter Windows app. + windows; /// The CLI argument used to specify the release type(s). String get cliName { @@ -32,6 +35,8 @@ enum ReleaseType { return 'macos'; case ReleaseType.aar: return 'aar'; + case ReleaseType.windows: + return 'windows'; } } @@ -48,6 +53,8 @@ enum ReleaseType { return ReleasePlatform.macos; case ReleaseType.iosFramework: return ReleasePlatform.ios; + case ReleaseType.windows: + return ReleasePlatform.windows; } } } diff --git a/packages/shorebird_cli/test/fixtures/win_archives/README.md b/packages/shorebird_cli/test/fixtures/win_archives/README.md new file mode 100644 index 000000000..d6eaf6041 --- /dev/null +++ b/packages/shorebird_cli/test/fixtures/win_archives/README.md @@ -0,0 +1,3 @@ +release.zip and patch.zip were build from identical code bases at different times. + +They have been thinned (removed flutter.dll) to help with size. diff --git a/packages/shorebird_cli/test/fixtures/win_archives/patch.zip b/packages/shorebird_cli/test/fixtures/win_archives/patch.zip new file mode 100644 index 000000000..05e25cedf Binary files /dev/null and b/packages/shorebird_cli/test/fixtures/win_archives/patch.zip differ diff --git a/packages/shorebird_cli/test/fixtures/win_archives/release.zip b/packages/shorebird_cli/test/fixtures/win_archives/release.zip new file mode 100644 index 000000000..52a036639 Binary files /dev/null and b/packages/shorebird_cli/test/fixtures/win_archives/release.zip differ diff --git a/packages/shorebird_cli/test/src/archive_analysis/android_archive_differ_test.dart b/packages/shorebird_cli/test/src/archive_analysis/android_archive_differ_test.dart index 37aa3c441..fa316cf0c 100644 --- a/packages/shorebird_cli/test/src/archive_analysis/android_archive_differ_test.dart +++ b/packages/shorebird_cli/test/src/archive_analysis/android_archive_differ_test.dart @@ -141,7 +141,7 @@ void main() { group('containsPotentiallyBreakingAssetDiffs', () { test('returns true if assets were added', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {'base/assets/flutter_assets/file.json'}, removedPaths: {}, changedPaths: {}, @@ -153,7 +153,7 @@ void main() { }); test('returns true if changed assets are not in the ignore list', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {}, removedPaths: {}, changedPaths: { @@ -169,7 +169,7 @@ void main() { }); test('returns false if changed assets are in the ignore list', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {}, removedPaths: {}, changedPaths: { @@ -187,7 +187,7 @@ void main() { group('containsPotentiallyBreakingNativeDiffs', () { test('returns true if any native files have been added', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {'base/lib/arm64-v8a/test.dex'}, removedPaths: {}, changedPaths: {}, @@ -199,7 +199,7 @@ void main() { }); test('returns true if any native files have been removed', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {}, removedPaths: {'base/lib/arm64-v8a/test.dex'}, changedPaths: {}, @@ -211,7 +211,7 @@ void main() { }); test('returns true if any native files have been changed', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {}, removedPaths: {}, changedPaths: {'base/lib/arm64-v8a/test.dex'}, diff --git a/packages/shorebird_cli/test/src/archive_analysis/archive_differ_test.dart b/packages/shorebird_cli/test/src/archive_analysis/archive_differ_test.dart index be1867fbb..ccdcf6eb0 100644 --- a/packages/shorebird_cli/test/src/archive_analysis/archive_differ_test.dart +++ b/packages/shorebird_cli/test/src/archive_analysis/archive_differ_test.dart @@ -14,7 +14,7 @@ void main() { group('containsPotentiallyBreakingAssetDiffs', () { test('returns true if any assets were added', () { - archiveDiffer.changedFileSetDiff = FileSetDiff( + archiveDiffer.changedFileSetDiff = const FileSetDiff( addedPaths: {assetFilePath}, removedPaths: {}, changedPaths: {}, @@ -28,7 +28,7 @@ void main() { }); test('returns false if changed assets are all in the ignore list', () { - archiveDiffer.changedFileSetDiff = FileSetDiff( + archiveDiffer.changedFileSetDiff = const FileSetDiff( addedPaths: {}, removedPaths: {}, changedPaths: {'AssetManifest.bin'}, @@ -42,7 +42,7 @@ void main() { }); test('returns true if changed assets are not all in the ignore list', () { - archiveDiffer.changedFileSetDiff = FileSetDiff( + archiveDiffer.changedFileSetDiff = const FileSetDiff( addedPaths: {}, removedPaths: {}, changedPaths: {assetFilePath}, diff --git a/packages/shorebird_cli/test/src/archive_analysis/file_set_diff_test.dart b/packages/shorebird_cli/test/src/archive_analysis/file_set_diff_test.dart index 476b8cd8a..cbf075c8e 100644 --- a/packages/shorebird_cli/test/src/archive_analysis/file_set_diff_test.dart +++ b/packages/shorebird_cli/test/src/archive_analysis/file_set_diff_test.dart @@ -27,7 +27,7 @@ void main() { group('prettyString', () { test('returns a string with added, changed, and removed files', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {'a', 'b'}, changedPaths: {'c'}, removedPaths: {'d'}, @@ -46,7 +46,7 @@ void main() { }); test('does not include empty path sets', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {'a', 'b'}, changedPaths: {}, removedPaths: {}, @@ -68,7 +68,7 @@ void main() { }); test('isEmpty is false if any path sets are not empty', () { - final fileSetDiff = FileSetDiff( + const fileSetDiff = FileSetDiff( addedPaths: {'a'}, changedPaths: {}, removedPaths: {}, diff --git a/packages/shorebird_cli/test/src/archive_analysis/windows_archive_differ_test.dart b/packages/shorebird_cli/test/src/archive_analysis/windows_archive_differ_test.dart new file mode 100644 index 000000000..0ce3a878e --- /dev/null +++ b/packages/shorebird_cli/test/src/archive_analysis/windows_archive_differ_test.dart @@ -0,0 +1,90 @@ +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; +import 'package:test/test.dart'; + +void main() { + group(WindowsArchiveDiffer, () { + late WindowsArchiveDiffer differ; + + setUp(() { + differ = const WindowsArchiveDiffer(); + }); + + group('isAssetFilePath', () { + group('when path contains "flutter_assets"', () { + test('returns true', () { + final result = differ.isAssetFilePath('flutter_assets/foo/bar'); + expect(result, isTrue); + }); + }); + + group('when path does not contain "flutter_assets"', () { + test('returns false', () { + final result = differ.isAssetFilePath('foo/bar'); + expect(result, isFalse); + }); + }); + }); + + group('isDartFilePath', () { + group('when file is app.so', () { + test('returns true', () { + final result = differ.isDartFilePath('app.so'); + expect(result, isTrue); + }); + }); + + group('when file is not app.so', () { + test('returns false', () { + final result = differ.isDartFilePath('foo.so'); + expect(result, isFalse); + }); + }); + }); + + group( + 'isNativeFilePath', + () { + group('when file extension is .dll', () { + test('returns true', () { + final result = differ.isNativeFilePath('foo.dll'); + expect(result, isTrue); + }); + }); + + group('when file extension is .exe', () { + test('returns true', () { + final result = differ.isNativeFilePath('foo.exe'); + expect(result, isTrue); + }); + }); + + group('when file extension is not .dll or .exe', () { + test('returns false', () { + final result = differ.isNativeFilePath('foo.so'); + expect(result, isFalse); + }); + }); + }, + skip: 'Disabled until we can reliably report native diffs', + ); + + group('changedFiles', () { + final winArchivesFixturesBasePath = + p.join('test', 'fixtures', 'win_archives'); + final releasePath = p.join( + winArchivesFixturesBasePath, + 'release.zip', + ); + final patchPath = p.join( + winArchivesFixturesBasePath, + 'patch.zip', + ); + + test('returns an empty FileSetDiff', () async { + final result = await differ.changedFiles(releasePath, patchPath); + expect(result, equals(FileSetDiff.empty())); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/artifact_builder_test.dart b/packages/shorebird_cli/test/src/artifact_builder_test.dart index 819011586..e28c057fc 100644 --- a/packages/shorebird_cli/test/src/artifact_builder_test.dart +++ b/packages/shorebird_cli/test/src/artifact_builder_test.dart @@ -6,6 +6,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/os/operating_system_interface.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; @@ -31,6 +32,7 @@ void main() { group(ArtifactBuilder, () { final projectRoot = Directory.systemTemp.createTempSync(); + late ArtifactManager artifactManager; late Ios ios; late ShorebirdLogger logger; late OperatingSystemInterface operatingSystemInterface; @@ -47,6 +49,7 @@ void main() { return runScoped( body, values: { + artifactManagerRef.overrideWith(() => artifactManager), iosRef.overrideWith(() => ios), loggerRef.overrideWith(() => logger), osInterfaceRef.overrideWith(() => operatingSystemInterface), @@ -65,6 +68,7 @@ void main() { }); setUp(() { + artifactManager = MockArtifactManager(); buildProcessResult = MockProcessResult(); ios = MockIos(); logger = MockShorebirdLogger(); @@ -1457,6 +1461,84 @@ Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with testOn: 'mac-os', ); + group('buildWindowsApp', () { + late Directory windowsReleaseDirectory; + + setUp(() { + windowsReleaseDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'windows', + 'x64', + 'runner', + 'Release', + ), + ); + when( + () => artifactManager.getWindowsReleaseDirectory(), + ).thenReturn(windowsReleaseDirectory); + when( + () => shorebirdProcess.start( + 'flutter', + [ + 'build', + 'windows', + '--release', + ], + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => buildProcess); + }); + + group('when flutter build fails', () { + setUp(() { + when( + () => buildProcess.exitCode, + ).thenAnswer((_) async => ExitCode.software.code); + when(() => buildProcess.stderr).thenAnswer( + (_) => Stream.fromIterable( + [ + 'stderr contents', + ].map(utf8.encode), + ), + ); + }); + + test('throws ArtifactBuildException', () async { + expect( + () => runWithOverrides(() => builder.buildWindowsApp()), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('Failed to build: stderr contents'), + ), + ), + ); + }); + }); + + group('when flutter build succeeds', () { + setUp(() { + when( + () => buildProcess.exitCode, + ).thenAnswer((_) async => ExitCode.success.code); + }); + + test('returns path to Release directory', () async { + final result = await runWithOverrides( + () => builder.buildWindowsApp(), + ); + + expect( + result.path, + endsWith(p.join('build', 'windows', 'x64', 'runner', 'Release')), + ); + }); + }); + }); + group('findAppDill', () { group('when gen_snapshot is invoked with app.dill', () { test('returns the path to app.dill', () { diff --git a/packages/shorebird_cli/test/src/artifact_manager_test.dart b/packages/shorebird_cli/test/src/artifact_manager_test.dart index 28014203b..39e5b16dc 100644 --- a/packages/shorebird_cli/test/src/artifact_manager_test.dart +++ b/packages/shorebird_cli/test/src/artifact_manager_test.dart @@ -803,6 +803,24 @@ void main() { }); }); + group('getWindowsReleaseDirectory', () { + test('returns correct path', () { + expect( + runWithOverrides(artifactManager.getWindowsReleaseDirectory).path, + equals( + p.join( + projectRoot.path, + 'build', + 'windows', + 'x64', + 'runner', + 'Release', + ), + ), + ); + }); + }); + group('getIpa', () { group('when ipa build directory does not exist', () { test('returns null', () { diff --git a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart index a5361eef6..4022ee8ba 100644 --- a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart +++ b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart @@ -1450,6 +1450,128 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' }); }); + group('createWindowsReleaseArtifacts', () { + late File releaseZip; + setUp(() { + releaseZip = + File(p.join(projectRoot.path, 'path', 'to', 'release.zip')) + ..createSync(recursive: true); + }); + + group('when release artifact already exists', () { + setUp(() { + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath'), + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ).thenThrow( + const CodePushConflictException(message: 'already exists'), + ); + }); + + test('logs message and continues', () async { + await runWithOverrides( + () => codePushClientWrapper.createWindowsReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + projectRoot: projectRoot.path, + releaseZipPath: releaseZip.path, + ), + ); + + verify( + () => logger.info( + any(that: contains('already exists, continuing...')), + ), + ).called(1); + verifyNever(() => progress.fail(any())); + }); + }); + + group('when createReleaseArtifact fails', () { + setUp(() { + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath'), + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ).thenThrow(Exception('something went wrong')); + }); + + test('exits with code 70', () async { + await expectLater( + () async => runWithOverrides( + () => codePushClientWrapper.createWindowsReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + projectRoot: projectRoot.path, + releaseZipPath: releaseZip.path, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail(any())).called(1); + }); + }); + + group('when createReleaseArtifact succeeds', () { + setUp(() { + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath'), + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ).thenAnswer((_) async {}); + }); + + test('completes successfully', () async { + await runWithOverrides( + () async => codePushClientWrapper.createWindowsReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + projectRoot: projectRoot.path, + releaseZipPath: releaseZip.path, + ), + ); + + verify(() => progress.complete()).called(1); + verifyNever(() => progress.fail(any())); + verify( + () => codePushClient.createReleaseArtifact( + artifactPath: releaseZip.path, + appId: appId, + releaseId: releaseId, + arch: primaryWindowsReleaseArtifactArch, + platform: ReleasePlatform.windows, + hash: any(named: 'hash'), + canSideload: true, + podfileLockHash: null, + ), + ).called(1); + }); + }); + }); + group('createAndroidArchiveReleaseArtifacts', () { const buildNumber = '1.0'; diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index 719bd1e22..f3f877168 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart @@ -343,10 +343,26 @@ void main() { }); }); - test('prints beta warning when macos platform is selected', () async { - when(() => argResults['platforms']).thenReturn(['macos']); - await runWithOverrides(command.run); - verify(() => logger.warn(macosBetaWarning)).called(1); + group('when patching a macos release', () { + setUp(() { + when(() => argResults['platforms']).thenReturn(['macos']); + }); + + test('prints beta warning', () async { + await runWithOverrides(command.run); + verify(() => logger.warn(macosBetaWarning)).called(1); + }); + }); + + group('when patching a windows release', () { + setUp(() { + when(() => argResults['platforms']).thenReturn(['windows']); + }); + + test('prints beta warning', () async { + await runWithOverrides(command.run); + verify(() => logger.warn(windowsBetaWarning)).called(1); + }); }); }); @@ -555,6 +571,10 @@ void main() { command.getPatcher(ReleaseType.macos), isA(), ); + expect( + command.getPatcher(ReleaseType.windows), + isA(), + ); }); }); 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 new file mode 100644 index 000000000..100839903 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patch/windows_patcher_test.dart @@ -0,0 +1,482 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:crypto/crypto.dart'; +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:pub_semver/pub_semver.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.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/doctor.dart'; +import 'package:shorebird_cli/src/engine_config.dart'; +import 'package:shorebird_cli/src/executables/executables.dart'; +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_artifacts.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; +import 'package:shorebird_cli/src/shorebird_process.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_cli/src/validators/flavor_validator.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../matchers.dart'; +import '../../mocks.dart'; + +void main() { + group(WindowsPatcher, () { + late ArgParser argParser; + late ArgResults argResults; + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + late Doctor doctor; + late EngineConfig engineConfig; + late Directory projectRoot; + late FlavorValidator flavorValidator; + late ShorebirdLogger logger; + late PatchDiffChecker patchDiffChecker; + late Powershell powershell; + late Progress progress; + late ShorebirdArtifacts shorebirdArtifacts; + late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late WindowsPatcher patcher; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + doctorRef.overrideWith(() => doctor), + engineConfigRef.overrideWith(() => engineConfig), + loggerRef.overrideWith(() => logger), + patchDiffCheckerRef.overrideWith(() => patchDiffChecker), + powershellRef.overrideWith(() => powershell), + processRef.overrideWith(() => shorebirdProcess), + shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUpAll(() { + registerFallbackValue(Directory('')); + registerFallbackValue(File('')); + registerFallbackValue(ReleasePlatform.windows); + registerFallbackValue(ShorebirdArtifact.genSnapshotMacOS); + registerFallbackValue(Uri.parse('https://example.com')); + registerFallbackValue(const WindowsArchiveDiffer()); + }); + + setUp(() { + argParser = MockArgParser(); + argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + engineConfig = MockEngineConfig(); + flavorValidator = MockFlavorValidator(); + patchDiffChecker = MockPatchDiffChecker(); + powershell = MockPowershell(); + progress = MockProgress(); + projectRoot = Directory.systemTemp.createTempSync(); + logger = MockShorebirdLogger(); + shorebirdArtifacts = MockShorebirdArtifacts(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + + when(() => argParser.options).thenReturn({}); + + when(() => argResults.options).thenReturn([]); + when(() => argResults.rest).thenReturn([]); + when(() => argResults.wasParsed(any())).thenReturn(false); + + when(() => logger.progress(any())).thenReturn(progress); + + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + + patcher = WindowsPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + }); + + group('releaseType', () { + test('is windows', () { + expect(patcher.releaseType, ReleaseType.windows); + }); + }); + + group('primaryReleaseArtifactArch', () { + test('is win_archive', () { + expect( + patcher.primaryReleaseArtifactArch, + primaryWindowsReleaseArtifactArch, + ); + }); + }); + + group('assertPreconditions', () { + setUp(() { + when( + () => doctor.windowsCommandValidators, + ).thenReturn([flavorValidator]); + }); + + group('when validation succeeds', () { + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any( + named: 'checkUserIsAuthenticated', + ), + checkShorebirdInitialized: any( + named: 'checkShorebirdInitialized', + ), + validators: any(named: 'validators'), + supportedOperatingSystems: any( + named: 'supportedOperatingSystems', + ), + ), + ).thenAnswer((_) async {}); + }); + + test('returns normally', () async { + await expectLater( + () => runWithOverrides(patcher.assertPreconditions), + returnsNormally, + ); + }); + }); + + group('when validation fails', () { + setUp(() { + final exception = ValidationFailedException(); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any( + named: 'checkUserIsAuthenticated', + ), + checkShorebirdInitialized: any( + named: 'checkShorebirdInitialized', + ), + validators: any( + named: 'validators', + ), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + final exception = ValidationFailedException(); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any( + named: 'checkUserIsAuthenticated', + ), + checkShorebirdInitialized: any( + named: 'checkShorebirdInitialized', + ), + validators: any(named: 'validators'), + supportedOperatingSystems: any( + named: 'supportedOperatingSystems', + ), + ), + ).thenThrow(exception); + await expectLater( + () => runWithOverrides(patcher.assertPreconditions), + exitsWithCode(exception.exitCode), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: [flavorValidator], + supportedOperatingSystems: {Platform.windows}, + ), + ).called(1); + }); + }); + }); + + group('assertUnpatchableDiffs', () { + late ReleaseArtifact releaseArtifact; + late File releaseArchive; + late File patchArchive; + late DiffStatus diffStatus; + + setUp(() { + diffStatus = const DiffStatus( + hasAssetChanges: false, + hasNativeChanges: false, + ); + releaseArtifact = MockReleaseArtifact(); + final tempDir = Directory.systemTemp.createTempSync(); + releaseArchive = File(p.join(tempDir.path, 'release.zip')); + patchArchive = File(p.join(tempDir.path, 'patch.zip')); + + when( + () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( + localArchive: any(named: 'localArchive'), + releaseArchive: any(named: 'releaseArchive'), + archiveDiffer: any(named: 'archiveDiffer'), + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + ), + ).thenAnswer((_) async => diffStatus); + }); + + test('returns result from patchDiffChecker', () async { + final diffStatus = await runWithOverrides( + () => patcher.assertUnpatchableDiffs( + releaseArtifact: releaseArtifact, + releaseArchive: releaseArchive, + patchArchive: patchArchive, + ), + ); + + expect( + diffStatus, + const DiffStatus(hasAssetChanges: false, hasNativeChanges: false), + ); + verify( + () => patchDiffChecker.confirmUnpatchableDiffsIfNecessary( + localArchive: patchArchive, + releaseArchive: releaseArchive, + archiveDiffer: const WindowsArchiveDiffer(), + allowAssetChanges: false, + allowNativeChanges: false, + ), + ).called(1); + }); + }); + + group('buildPatchArtifact', () { + const flutterVersionAndRevision = '3.27.1 (8495dee1fd)'; + + setUp(() { + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + when( + () => shorebirdFlutter.getVersion(), + ).thenAnswer((_) async => Version(3, 27, 1)); + }); + + group('when build fails', () { + setUp(() { + when( + () => artifactBuilder.buildWindowsApp(), + ).thenThrow(Exception('Failed to build Windows app')); + }); + + test('exits with software error code', () async { + expect( + () => runWithOverrides( + () => patcher.buildPatchArtifact(), + ), + throwsA( + isA().having((e) => e.exitCode, 'exitCode', 70), + ), + ); + }); + }); + + group('when build succeeds', () { + setUp(() { + final releaseDir = Directory( + p.join( + projectRoot.path, + 'build', + 'windows', + 'x64', + 'runner', + 'Release', + ), + )..createSync(recursive: true); + when( + () => artifactBuilder.buildWindowsApp(), + ).thenAnswer((_) async => releaseDir); + }); + + test('returns a zipped exe file', () async { + await expectLater( + runWithOverrides( + () => patcher.buildPatchArtifact(), + ), + completion( + isA().having((f) => f.path, 'path', endsWith('.zip')), + ), + ); + }); + }); + }); + + group('createPatchArtifacts', () { + const appId = 'app-id'; + const releaseId = 42; + + late Directory releaseDirectory; + late File releaseArtifact; + late File patchArtifact; + late File diffFile; + + setUp(() { + final tempDir = Directory.systemTemp.createTempSync(); + releaseArtifact = File(p.join(tempDir.path, 'release.zip')) + ..createSync(recursive: true); + + diffFile = File(p.join(tempDir.path, 'diff.so')) + ..createSync(recursive: true); + + releaseDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'windows', + 'x64', + 'runner', + 'Release', + ), + )..createSync(recursive: true); + + patchArtifact = File( + p.join( + releaseDirectory.path, + 'data', + 'app.so', + ), + )..createSync(recursive: true); + + when( + () => artifactManager.getWindowsReleaseDirectory(), + ).thenReturn(releaseDirectory); + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + (invocation.namedArguments[#outputDirectory] as Directory) + .createSync(recursive: true); + }); + }); + + group('when creating diff fails', () { + setUp(() { + when( + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + ), + ).thenThrow(Exception('Failed to create diff')); + }); + + test('exits with software error code', () async { + expect( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifact, + ), + ), + throwsA( + isA().having((e) => e.exitCode, 'exitCode', 70), + ), + ); + }); + }); + + group('when creating artifacts succeeds', () { + setUp(() { + when( + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + ), + ).thenAnswer((_) async => diffFile.path); + }); + + test('returns patch artifacts', () async { + final patchArtifacts = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: 'com.example.app', + releaseId: 1, + releaseArtifact: releaseArtifact, + supplementArtifact: File('supplement.zip'), + ), + ); + + final expectedHash = + sha256.convert(await patchArtifact.readAsBytes()).toString(); + + expect( + patchArtifacts, + equals({ + Arch.x86_64: PatchArtifactBundle( + arch: Arch.x86_64.arch, + path: diffFile.path, + hash: expectedHash, + size: diffFile.lengthSync(), + ), + }), + ); + }); + }); + }); + + group('extractReleaseVersionFromArtifact', () { + setUp(() async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outPath = + (invocation.namedArguments[#outputDirectory] as Directory).path; + File(p.join(outPath, 'hello_windows.exe')) + .createSync(recursive: true); + }); + when( + () => powershell.getExeVersionString(any()), + ).thenAnswer((_) async => '1.2.3'); + }); + + test('returns version from archived exe', () async { + final version = await runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact( + File(''), + ), + ); + + expect(version, '1.2.3'); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/preview_command_test.dart b/packages/shorebird_cli/test/src/commands/preview_command_test.dart index 2684dc5cb..3ccc92a95 100644 --- a/packages/shorebird_cli/test/src/commands/preview_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/preview_command_test.dart @@ -21,7 +21,9 @@ import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/platform.dart'; +import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; @@ -242,6 +244,7 @@ void main() { ReleasePlatform.android: ReleaseStatus.active, ReleasePlatform.ios: ReleaseStatus.active, ReleasePlatform.macos: ReleaseStatus.active, + ReleasePlatform.windows: ReleaseStatus.active, }); when(() => logger.progress(any())).thenReturn(progress); when(() => progress.fail(any())).thenReturn(null); @@ -327,24 +330,6 @@ void main() { }); }); - group('when releasePlatform is not supported', () { - setUp(() { - when(() => release.platformStatuses).thenReturn({ - ReleasePlatform.windows: ReleaseStatus.active, - }); - when( - () => argResults['platform'], - ).thenReturn(ReleasePlatform.windows.name); - }); - - test('throws an UnimplementedError', () async { - await expectLater( - () => runWithOverrides(command.run), - throwsA(isA()), - ); - }); - }); - group('android', () { const releasePlatform = ReleasePlatform.android; const releaseArtifactUrl = 'https://example.com/release.aab'; @@ -1806,6 +1791,173 @@ channel: ${DeploymentTrack.staging.channel} }); }); + group('windows', () { + const releaseArtifactUrl = 'https://example.com/Release.zip'; + const releaseArtifactId = 42; + + late File releaseArtifactFile; + late ReleaseArtifact windowsReleaseArtifact; + late ShorebirdProcess shorebirdProcess; + late Process process; + + R runWithOverrides(R Function() body) { + return HttpOverrides.runZoned( + () => runScoped( + body, + values: { + artifactManagerRef.overrideWith(() => artifactManager), + authRef.overrideWith(() => auth), + cacheRef.overrideWith(() => cache), + codePushClientWrapperRef + .overrideWith(() => codePushClientWrapper), + httpClientRef.overrideWith(() => httpClient), + loggerRef.overrideWith(() => logger), + platformRef.overrideWith(() => platform), + processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + ); + } + + setUp(() { + shorebirdProcess = MockShorebirdProcess(); + process = MockProcess(); + windowsReleaseArtifact = MockReleaseArtifact(); + + final tempDir = Directory.systemTemp.createTempSync(); + releaseArtifactFile = File(p.join(tempDir.path, 'Release.zip')); + + when(() => process.stdout) + .thenAnswer((_) => Stream.value(utf8.encode('hello world'))); + when(() => process.stderr) + .thenAnswer((_) => Stream.value(utf8.encode('hello error'))); + when(() => process.exitCode) + .thenAnswer((_) async => ExitCode.success.code); + + when( + () => shorebirdProcess.start(any(), any()), + ).thenAnswer((_) async => process); + + when(() => windowsReleaseArtifact.id).thenReturn(releaseArtifactId); + when(() => windowsReleaseArtifact.url).thenReturn(releaseArtifactUrl); + + when(() => release.platformStatuses).thenReturn({ + ReleasePlatform.windows: ReleaseStatus.active, + }); + + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => windowsReleaseArtifact); + }); + + group('when getting release artifact fails', () { + setUp(() { + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + ), + ).thenThrow(Exception('oops')); + }); + + test('returns code 70', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify( + () => codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: releaseId, + arch: primaryWindowsReleaseArtifactArch, + platform: ReleasePlatform.windows, + ), + ).called(1); + }); + }); + + group('when downloading release artifact fails', () { + setUp(() { + when( + () => artifactManager.downloadFile(any()), + ).thenThrow(Exception('oops')); + }); + + test('returns code 70', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify( + () => artifactManager.downloadFile( + Uri.parse(releaseArtifactUrl), + ), + ).called(1); + }); + }); + + group('when preview artifact is not cached', () { + setUp(() { + when(() => artifactManager.downloadFile(any())) + .thenAnswer((_) async => releaseArtifactFile); + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDirectory = + invocation.namedArguments[#outputDirectory] as Directory; + File(p.join(outDirectory.path, 'runner.exe')) + .createSync(recursive: true); + }); + }); + + test('downloads and launches artifact', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + + verify( + () => artifactManager.downloadFile(Uri.parse(releaseArtifactUrl)), + ).called(1); + verify( + () => shorebirdProcess.start(any(that: endsWith('runner.exe')), []), + ).called(1); + verify(() => logger.info('hello world')).called(1); + verify(() => logger.err('hello error')).called(1); + }); + }); + + group('when preview artifact is cached', () { + setUp(() { + File( + p.join( + previewDirectory.path, + 'windows_${releaseVersion}_$releaseArtifactId.exe', + 'runner.exe', + ), + ).createSync(recursive: true); + }); + + test('launches cached artifact', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + + verifyNever(() => artifactManager.downloadFile(any())); + verify( + () => shorebirdProcess.start(any(that: endsWith('runner.exe')), []), + ).called(1); + verify(() => logger.info('hello world')).called(1); + verify(() => logger.err('hello error')).called(1); + }); + }); + }); + group('when no platform is specified', () { const iosReleaseArtifactUrl = 'https://example.com/runner.app'; const macosReleaseArtifactUrl = 'https://example.com/sample.app'; diff --git a/packages/shorebird_cli/test/src/commands/release/release_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_command_test.dart index 039db00d6..2e7db01a2 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_command_test.dart @@ -13,6 +13,7 @@ import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/platform/macos.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'; @@ -223,13 +224,33 @@ void main() { command.getReleaser(ReleaseType.macos), isA(), ); + expect( + command.getReleaser(ReleaseType.windows), + isA(), + ); + }); + }); + + group('when releasing to macos', () { + setUp(() { + when(() => argResults['platforms']).thenReturn(['macos']); + }); + + test('prints beta warning', () async { + await runWithOverrides(command.run); + verify(() => logger.warn(macosBetaWarning)).called(1); }); }); - test('prints beta warning when macos platform is selected', () async { - when(() => argResults['platforms']).thenReturn(['macos']); - await runWithOverrides(command.run); - verify(() => logger.warn(macosBetaWarning)).called(1); + group('when releasing to windows', () { + setUp(() { + when(() => argResults['platforms']).thenReturn(['windows']); + }); + + test('prints beta warning', () async { + await runWithOverrides(command.run); + verify(() => logger.warn(windowsBetaWarning)).called(1); + }); }); test('executes commands in order, completes successfully', () async { 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 new file mode 100644 index 000000000..12b551713 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart @@ -0,0 +1,411 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +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:pub_semver/pub_semver.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/artifact_builder.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/commands.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_documentation.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/validators/validators.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../matchers.dart'; +import '../../mocks.dart'; + +void main() { + group(WindowsReleaser, () { + late ArgResults argResults; + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + late Directory releaseDirectory; + late Doctor doctor; + late ShorebirdLogger logger; + late FlavorValidator flavorValidator; + late Directory projectRoot; + late Powershell powershell; + late Progress progress; + late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late WindowsReleaser releaser; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + doctorRef.overrideWith(() => doctor), + loggerRef.overrideWith(() => logger), + powershellRef.overrideWith(() => powershell), + processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUpAll(() { + registerFallbackValue(Directory('')); + registerFallbackValue(File('')); + registerFallbackValue(ReleasePlatform.windows); + }); + + setUp(() { + argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + flavorValidator = MockFlavorValidator(); + powershell = MockPowershell(); + progress = MockProgress(); + projectRoot = Directory.systemTemp.createTempSync(); + logger = MockShorebirdLogger(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + + when(() => argResults.rest).thenReturn([]); + when(() => argResults.wasParsed(any())).thenReturn(false); + + releaseDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'windows', + 'x63', + 'runner', + 'Release', + ), + )..createSync(recursive: true); + + when( + () => artifactManager.getWindowsReleaseDirectory(), + ).thenReturn(releaseDirectory); + + when(() => logger.progress(any())).thenReturn(progress); + + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + + releaser = WindowsReleaser( + argResults: argResults, + flavor: null, + target: null, + ); + }); + + group('releaseType', () { + test('is windows', () { + expect(releaser.releaseType, ReleaseType.windows); + }); + }); + + group('assertArgsAreValid', () { + group('when release-version is passed', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(true); + }); + + test('logs error and exits with usage err', () async { + await expectLater( + () => runWithOverrides(releaser.assertArgsAreValid), + exitsWithCode(ExitCode.usage), + ); + + verify( + () => logger.err( + ''' +The "--release-version" flag is only supported for aar and ios-framework releases. + +To change the version of this release, change your app's version in your pubspec.yaml.''', + ), + ).called(1); + }); + }); + }); + + group('assertPreconditions', () { + setUp(() { + when(() => doctor.windowsCommandValidators) + .thenReturn([flavorValidator]); + when(flavorValidator.validate).thenAnswer((_) async => []); + }); + + group('when validation succeeds', () { + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: + any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: + any(named: 'supportedOperatingSystems'), + ), + ).thenAnswer((_) async {}); + }); + + test('returns normally', () async { + await expectLater( + () => runWithOverrides(releaser.assertPreconditions), + returnsNormally, + ); + }); + }); + + group('when validation fails', () { + final exception = ValidationFailedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: + any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: + any(named: 'supportedOperatingSystems'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(releaser.assertPreconditions), + exitsWithCode(exception.exitCode), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: [flavorValidator], + supportedOperatingSystems: {Platform.windows}, + ), + ).called(1); + }); + }); + + group('when flutter version is too old', () { + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: + any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: + any(named: 'supportedOperatingSystems'), + ), + ).thenAnswer((_) async {}); + when(() => argResults['flutter-version'] as String?) + .thenReturn('1.2.3'); + when(() => shorebirdFlutter.resolveFlutterVersion('1.2.3')) + .thenAnswer((_) async => Version(1, 2, 3)); + when(() => shorebirdFlutter.getVersionAndRevision()) + .thenAnswer((_) async => '3.27.1'); + }); + + test('logs error and exits with usage err', () async { + await expectLater( + () => runWithOverrides(releaser.assertPreconditions), + exitsWithCode(ExitCode.usage), + ); + + verify( + () => logger.err( + ''' +Windows releases are not supported with Flutter versions older than $minimumSupportedWindowsFlutterVersion. +For more information see: ${supportedFlutterVersionsUrl.toLink()}''', + ), + ).called(1); + }); + }); + }); + + group('buildReleaseArtifacts', () { + setUp(() { + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => '3.27.1'); + }); + + group('when builder throws exception', () { + setUp(() { + when( + () => artifactBuilder.buildWindowsApp( + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenThrow(Exception('oh no')); + }); + + test('fails progress, exits', () async { + await expectLater( + () => runWithOverrides(releaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + verify(() => progress.fail('Exception: oh no')).called(1); + }); + }); + + group('when build succeeds', () { + setUp(() { + when( + () => artifactBuilder.buildWindowsApp( + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenAnswer((_) async => projectRoot); + }); + + test('returns path to release directory', () async { + final releaseDir = + await runWithOverrides(releaser.buildReleaseArtifacts); + expect(releaseDir, projectRoot); + }); + }); + }); + + group('getReleaseVersion', () { + group('when exe does not exist', () { + test('throws exception', () { + expect( + () => runWithOverrides( + () => releaser.getReleaseVersion( + releaseArtifactRoot: projectRoot, + ), + ), + throwsA(isA()), + ); + }); + }); + + group('when exe exists', () { + setUp(() { + File(p.join(projectRoot.path, 'app.exe')).createSync(); + when(() => powershell.getExeVersionString(any())).thenAnswer( + (_) async => '1.2.3', + ); + }); + + test('returns result of getExeVersionString', () async { + await expectLater( + runWithOverrides( + () => releaser.getReleaseVersion( + releaseArtifactRoot: projectRoot, + ), + ), + completion(equals('1.2.3')), + ); + }); + }); + }); + + group('uploadReleaseArtifacts', () { + const appId = 'my-app'; + const releaseId = 123; + late Release release; + + setUp(() { + release = MockRelease(); + when(() => release.id).thenReturn(releaseId); + }); + + group('when release directory does not exist', () { + setUp(() { + releaseDirectory.deleteSync(); + }); + + test('fails progress, exits', () async { + await expectLater( + () => runWithOverrides( + () => releaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ), + exitsWithCode(ExitCode.software), + ); + verify( + () => logger.err( + any(that: startsWith('No release directory found at')), + ), + ).called(1); + }); + }); + + group('when release directory exists', () { + setUp(() { + when( + () => codePushClientWrapper.createWindowsReleaseArtifacts( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + projectRoot: any(named: 'projectRoot'), + releaseZipPath: any(named: 'releaseZipPath'), + ), + ).thenAnswer((_) async {}); + }); + + test('zips and uploads release directory', () async { + await runWithOverrides( + () => releaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ); + verify( + () => codePushClientWrapper.createWindowsReleaseArtifacts( + appId: appId, + releaseId: releaseId, + projectRoot: projectRoot.path, + releaseZipPath: any(named: 'releaseZipPath'), + ), + ).called(1); + }); + }); + }); + + group('postReleaseInstructions', () { + test('returns nonempty instructions', () { + final instructions = runWithOverrides( + () => releaser.postReleaseInstructions, + ); + expect( + instructions, + equals(''' + +Windows executable created at ${artifactManager.getWindowsReleaseDirectory().path}. +'''), + ); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/executables/powershell_test.dart b/packages/shorebird_cli/test/src/executables/powershell_test.dart new file mode 100644 index 000000000..8f339a171 --- /dev/null +++ b/packages/shorebird_cli/test/src/executables/powershell_test.dart @@ -0,0 +1,73 @@ +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/shorebird_process.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + group(Powershell, () { + late ShorebirdProcessResult processResult; + late ShorebirdProcess process; + late Powershell powershell; + + R runWithOverrides(R Function() body) { + return runScoped( + () => body(), + values: { + processRef.overrideWith(() => process), + }, + ); + } + + setUp(() { + processResult = MockShorebirdProcessResult(); + process = MockShorebirdProcess(); + + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + + powershell = runWithOverrides(Powershell.new); + }); + + group('getExeVersionString', () { + group('when exit code is not success', () { + setUp(() { + when(() => processResult.exitCode).thenReturn(1); + }); + + test('throws an exception', () async { + await expectLater( + runWithOverrides( + () => powershell.getExeVersionString(File('')), + ), + throwsA(isA()), + ); + }); + }); + + group('when exit code is success', () { + setUp(() { + when(() => processResult.exitCode).thenReturn(0); + when(() => processResult.stdout).thenReturn('1.0.0'); + }); + + test('returns the version string', () async { + final version = await runWithOverrides( + () => powershell.getExeVersionString(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 87b61b20a..c03501c92 100644 --- a/packages/shorebird_cli/test/src/mocks.dart +++ b/packages/shorebird_cli/test/src/mocks.dart @@ -132,6 +132,8 @@ class MockPatcher extends Mock implements Patcher {} class MockPlatform extends Mock implements Platform {} +class MockPowershell extends Mock implements Powershell {} + class MockProcessResult extends Mock implements ShorebirdProcessResult {} class MockProcessSignal extends Mock implements ProcessSignal {} diff --git a/packages/shorebird_cli/test/src/release_type_test.dart b/packages/shorebird_cli/test/src/release_type_test.dart index bc0598b8b..a6cd63591 100644 --- a/packages/shorebird_cli/test/src/release_type_test.dart +++ b/packages/shorebird_cli/test/src/release_type_test.dart @@ -10,6 +10,7 @@ void main() { expect(ReleaseType.ios.cliName, 'ios'); expect(ReleaseType.iosFramework.cliName, 'ios-framework'); expect(ReleaseType.aar.cliName, 'aar'); + expect(ReleaseType.windows.cliName, 'windows'); }); test('releasePlatform', () { @@ -17,6 +18,7 @@ void main() { expect(ReleaseType.ios.releasePlatform, ReleasePlatform.ios); expect(ReleaseType.iosFramework.releasePlatform, ReleasePlatform.ios); expect(ReleaseType.aar.releasePlatform, ReleasePlatform.android); + expect(ReleaseType.windows.releasePlatform, ReleasePlatform.windows); }); group('releaseTypes', () { diff --git a/packages/shorebird_cli/test/src/validators/tracked_lock_files_validator_test.dart b/packages/shorebird_cli/test/src/validators/tracked_lock_files_validator_test.dart index dc87d889c..d1f7c09c9 100644 --- a/packages/shorebird_cli/test/src/validators/tracked_lock_files_validator_test.dart +++ b/packages/shorebird_cli/test/src/validators/tracked_lock_files_validator_test.dart @@ -5,7 +5,6 @@ import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/executables/git.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/validators/tracked_lock_files_validator.dart'; import 'package:shorebird_cli/src/validators/validators.dart'; import 'package:test/test.dart';