diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart index ca20509f..75315b6d 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart @@ -49,6 +49,11 @@ class PatchAndroidCommand extends ShorebirdCommand 'flavor', help: 'The product flavor to use when building the app.', ) + ..addOption( + 'channel', + help: 'The channel to publish the patch to.', + defaultsTo: 'stable', + ) ..addFlag( 'force', abbr: 'f', @@ -97,7 +102,7 @@ class PatchAndroidCommand extends ShorebirdCommand await cache.updateAll(); const platform = ReleasePlatform.android; - const channelName = 'stable'; + final channelName = results['channel'] as String; final flavor = results['flavor'] as String?; final target = results['target'] as String?; diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart index 7031ab64..85efac10 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart @@ -42,6 +42,11 @@ class PatchIosCommand extends ShorebirdCommand 'flavor', help: 'The product flavor to use when building the app.', ) + ..addOption( + 'channel', + help: 'The channel to publish the patch to.', + defaultsTo: 'stable', + ) ..addFlag( 'codesign', help: 'Codesign the application bundle.', @@ -95,7 +100,7 @@ class PatchIosCommand extends ShorebirdCommand } const arch = 'aarch64'; - const channelName = 'stable'; + final channelName = results['channel'] as String; const releasePlatform = ReleasePlatform.ios; final flavor = results['flavor'] as String?; diff --git a/packages/shorebird_cli/lib/src/commands/preview_command.dart b/packages/shorebird_cli/lib/src/commands/preview_command.dart index c4d67b54..f2bedb78 100644 --- a/packages/shorebird_cli/lib/src/commands/preview_command.dart +++ b/packages/shorebird_cli/lib/src/commands/preview_command.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:isolate'; +import 'package:archive/archive_io.dart'; import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; @@ -17,6 +19,7 @@ import 'package:shorebird_cli/src/logger.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'; +import 'package:yaml_edit/yaml_edit.dart'; /// {@template preview_command} /// `shorebird preview` command. @@ -49,6 +52,11 @@ class PreviewCommand extends ShorebirdCommand { ReleasePlatform.ios.name: 'iOS', }, help: 'The platform of the release.', + ) + ..addOption( + 'channel', + defaultsTo: 'stable', + help: 'The channel to preview the release for.', ); } @@ -103,17 +111,20 @@ class PreviewCommand extends ShorebirdCommand { ); final deviceId = results['device-id'] as String?; + final channel = results['channel'] as String; return switch (platform) { ReleasePlatform.android => installAndLaunchAndroid( appId: appId, release: release, deviceId: deviceId, + channel: channel, ), ReleasePlatform.ios => installAndLaunchIos( appId: appId, release: release, deviceId: deviceId, + channel: channel, ), }; } @@ -151,6 +162,7 @@ class PreviewCommand extends ShorebirdCommand { Future installAndLaunchAndroid({ required String appId, required Release release, + required String channel, String? deviceId, }) async { const platform = ReleasePlatform.android; @@ -187,6 +199,23 @@ class PreviewCommand extends ShorebirdCommand { } } + final apksPath = getArtifactPath( + appId: appId, + release: release, + platform: platform, + extension: 'apks', + ); + + if (File(apksPath).existsSync()) File(apksPath).deleteSync(); + final progress = logger.progress('Using channel $channel'); + try { + await setChannelOnAab(aabFile: aabFile, channel: channel); + progress.complete(); + } catch (error) { + progress.fail('$error'); + return ExitCode.software.code; + } + final extractMetadataProgress = logger.progress('Extracting metadata'); late String package; try { @@ -197,22 +226,13 @@ class PreviewCommand extends ShorebirdCommand { return ExitCode.software.code; } - final apksPath = getArtifactPath( - appId: appId, - release: release, - platform: platform, - extension: 'apks', - ); - - if (!File(apksPath).existsSync()) { - final buildApksProgress = logger.progress('Building apks'); - try { - await bundletool.buildApks(bundle: aabFile.path, output: apksPath); - buildApksProgress.complete(); - } catch (error) { - buildApksProgress.fail('$error'); - return ExitCode.software.code; - } + final buildApksProgress = logger.progress('Building apks'); + try { + await bundletool.buildApks(bundle: aabFile.path, output: apksPath); + buildApksProgress.complete(); + } catch (error) { + buildApksProgress.fail('$error'); + return ExitCode.software.code; } final installApksProgress = logger.progress('Installing apks'); @@ -247,6 +267,7 @@ class PreviewCommand extends ShorebirdCommand { Future installAndLaunchIos({ required String appId, required Release release, + required String channel, String? deviceId, }) async { const platform = ReleasePlatform.ios; @@ -285,6 +306,18 @@ class PreviewCommand extends ShorebirdCommand { } } + final progress = logger.progress('Using channel $channel'); + try { + await setChannelOnRunner( + runnerDirectory: runnerDirectory, + channel: channel, + ); + progress.complete(); + } catch (error) { + progress.fail('$error'); + return ExitCode.software.code; + } + try { final exitCode = await iosDeploy.installAndLaunchApp( bundlePath: runnerDirectory.path, @@ -308,4 +341,87 @@ class PreviewCommand extends ShorebirdCommand { '${platform.name}_${release.version}.$extension', ); } + + /// Sets the channel property in the shorebird.yaml file inside the Runner.app + Future setChannelOnRunner({ + required Directory runnerDirectory, + required String channel, + }) async { + await Isolate.run(() async { + final shorebirdYaml = File( + p.join( + runnerDirectory.path, + 'Frameworks', + 'App.framework', + 'flutter_assets', + 'shorebird.yaml', + ), + ); + + if (!shorebirdYaml.existsSync()) { + throw Exception('Unable to find shorebird.yaml'); + } + + final yaml = YamlEditor(shorebirdYaml.readAsStringSync()) + ..update(['channel'], channel); + + shorebirdYaml.writeAsStringSync(yaml.toString(), flush: true); + }); + } + + /// Unzips the `.aab` and sets the channel property in the shorebird.yaml + /// file inside the base module and then re-zips the `.aab`. + Future setChannelOnAab({ + required File aabFile, + required String channel, + }) async { + // Getting the reference here since we cannot inside the isolate. + final extractZip = artifactManager.extractZip; + + await Isolate.run(() async { + final tempDir = Directory.systemTemp.createTempSync(); + final basename = p.basenameWithoutExtension(aabFile.path); + final outputPath = p.join(tempDir.path, basename); + + await extractZip( + zipFile: aabFile, + outputDirectory: Directory(outputPath), + ); + + final shorebirdYaml = File( + p.join( + outputPath, + 'base', + 'assets', + 'flutter_assets', + 'shorebird.yaml', + ), + ); + + if (!shorebirdYaml.existsSync()) { + throw Exception('Unable to find shorebird.yaml'); + } + + final yaml = YamlEditor(shorebirdYaml.readAsStringSync()) + ..update(['channel'], channel); + + shorebirdYaml.writeAsStringSync(yaml.toString(), flush: true); + + // This is equivalent to `zip --no-dir-entries` + // Which does NOT create entries in the zip archive for directories. + // It's important to do this because bundletool expects the + // .aab not to contain any directories. + final encoder = ZipFileEncoder()..create(aabFile.path); + for (final file in Directory(outputPath).listSync(recursive: true)) { + if (file is File) { + await encoder.addFile( + file, + file.path.replaceFirst('$outputPath/', ''), + ); + } + } + encoder.close(); + tempDir.deleteSync(recursive: true); + }); + } } diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart index f702009d..9f5f323a 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart @@ -42,7 +42,7 @@ void main() { const version = '$versionName+$versionCode'; const arch = 'aarch64'; const releasePlatform = ReleasePlatform.android; - const channelName = 'stable'; + const channelName = 'test-channel'; const appDisplayName = 'Test App'; final appMetadata = AppMetadata( appId: appId, @@ -786,10 +786,10 @@ Please re-run the release command for this version or create a new release.'''), verifyNever(() => logger.confirm(any())); verify( () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - channelName: any(named: 'channelName'), + appId: appId, + releaseId: release.id, + platform: releasePlatform, + channelName: channelName, patchArtifactBundles: any(named: 'patchArtifactBundles'), ), ).called(1); @@ -813,10 +813,10 @@ Please re-run the release command for this version or create a new release.'''), ).called(1); verify( () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - channelName: any(named: 'channelName'), + appId: appId, + releaseId: release.id, + platform: releasePlatform, + channelName: channelName, patchArtifactBundles: any(named: 'patchArtifactBundles'), ), ).called(1); @@ -863,10 +863,10 @@ flavors: ); verify( () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - channelName: any(named: 'channelName'), + appId: appId, + releaseId: release.id, + platform: releasePlatform, + channelName: channelName, patchArtifactBundles: any(named: 'patchArtifactBundles'), ), ).called(1); diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart index 9b9a87be..c5d38d52 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart @@ -38,7 +38,9 @@ void main() { const versionCode = '1'; const version = '$versionName+$versionCode'; const arch = 'aarch64'; + const channelName = 'test-channel'; const appDisplayName = 'Test App'; + const releasePlatform = ReleasePlatform.ios; const platformName = 'ios'; const elfAotSnapshotFileName = 'out.aot'; const ipaPath = 'build/ios/ipa/Runner.ipa'; @@ -252,6 +254,7 @@ flutter: when(() => argResults['force']).thenReturn(false); when(() => argResults['release-version']).thenReturn(release.version); when(() => argResults['codesign']).thenReturn(true); + when(() => argResults['channel']).thenReturn(channelName); when(() => argResults.rest).thenReturn([]); when(() => auth.isAuthenticated).thenReturn(true); when(() => auth.client).thenReturn(httpClient); @@ -845,10 +848,10 @@ Please re-run the release command for this version or create a new release.'''), verifyNever(() => logger.confirm(any())); verify( () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - channelName: any(named: 'channelName'), + appId: appId, + releaseId: release.id, + platform: releasePlatform, + channelName: channelName, patchArtifactBundles: any(named: 'patchArtifactBundles'), ), ).called(1); @@ -872,10 +875,10 @@ Please re-run the release command for this version or create a new release.'''), ).called(1); verify( () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - channelName: any(named: 'channelName'), + appId: appId, + releaseId: release.id, + platform: releasePlatform, + channelName: channelName, patchArtifactBundles: any(named: 'patchArtifactBundles'), ), ).called(1); @@ -976,10 +979,10 @@ flavors: expect(exitCode, ExitCode.success.code); verify( () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - channelName: any(named: 'channelName'), + appId: appId, + releaseId: release.id, + platform: releasePlatform, + channelName: channelName, patchArtifactBundles: any(named: 'patchArtifactBundles'), ), ).called(1); 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 9fda827c..81a912c3 100644 --- a/packages/shorebird_cli/test/src/commands/preview_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/preview_command_test.dart @@ -27,6 +27,7 @@ void main() { const appId = 'test-app-id'; const appDisplayName = 'Test App'; const releaseVersion = '1.2.3'; + const channel = 'stable'; const releaseId = 42; late AppMetadata app; @@ -85,6 +86,7 @@ void main() { when(() => argResults['app-id']).thenReturn(appId); when(() => argResults['release-version']).thenReturn(releaseVersion); + when(() => argResults['channel']).thenReturn(channel); when(() => auth.isAuthenticated).thenReturn(true); when(() => cache.getPreviewDirectory(any())).thenReturn(previewDirectory); when( @@ -188,8 +190,9 @@ void main() { authRef.overrideWith(() => auth), bundletoolRef.overrideWith(() => bundletool), cacheRef.overrideWith(() => cache), - codePushClientWrapperRef - .overrideWith(() => codePushClientWrapper), + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), loggerRef.overrideWith(() => logger), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), }, @@ -197,6 +200,20 @@ void main() { ); } + Future createShorebirdYaml(Invocation invocation) async { + File( + p.join( + (invocation.namedArguments[#outputDirectory] as Directory).path, + 'base', + 'assets', + 'flutter_assets', + 'shorebird.yaml', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('app_id: $appId', flush: true); + } + setUp(() { adb = MockAdb(); bundletool = MockBundleTool(); @@ -289,7 +306,22 @@ void main() { verify(() => progress.fail('$exception')).called(1); }); + test('exits with code 70 when unable to find shorebird.yaml', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify( + () => progress.fail('Exception: Unable to find shorebird.yaml'), + ).called(1); + }); + test('exits with code 70 when extracting metadata fails', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + final exception = Exception('oops'); when(() => bundletool.getPackageName(any())).thenThrow(exception); final result = await runWithOverrides(command.run); @@ -298,6 +330,13 @@ void main() { }); test('exits with code 70 when building apks fails', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + final exception = Exception('oops'); when( () => bundletool.buildApks( @@ -313,6 +352,13 @@ void main() { }); test('exits with code 70 when installing apks fails', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + final exception = Exception('oops'); when( () => bundletool.installApks(apks: any(named: 'apks')), @@ -323,6 +369,13 @@ void main() { }); test('exits with code 70 when starting app fails', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + final exception = Exception('oops'); when(() => adb.startApp(package: any(named: 'package'))) .thenThrow(exception); @@ -332,6 +385,13 @@ void main() { }); test('exits with non-zero exit code when logcat process fails', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + when(() => process.exitCode).thenAnswer((_) async => 1); final result = await runWithOverrides(command.run); expect(result, equals(1)); @@ -339,6 +399,13 @@ void main() { }); test('pipes stdout output to logger', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + final completer = Completer(); when(() => process.exitCode).thenAnswer((_) => completer.future); const output = 'hello world'; @@ -352,6 +419,13 @@ void main() { }); test('pipes stderr output to logger', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + final completer = Completer(); when(() => process.exitCode).thenAnswer((_) => completer.future); const output = 'hello world'; @@ -365,6 +439,13 @@ void main() { }); test('queries for apps when app-id is not specified', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + when(() => argResults['app-id']).thenReturn(null); when( () => logger.chooseOne( @@ -387,6 +468,13 @@ void main() { }); test('prompts for platforms when platform is not specified', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + when(() => argResults['platform']).thenReturn(null); when( () => logger.chooseOne( @@ -458,6 +546,13 @@ void main() { test( 'queries for releases when ' 'release-version is not specified', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + when(() => argResults['release-version']).thenReturn(null); when( () => logger.chooseOne( @@ -485,6 +580,13 @@ void main() { }); test('forwards deviceId to adb and bundletool', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(createShorebirdYaml); + const deviceId = '1234'; when(() => argResults['device-id']).thenReturn(deviceId); await runWithOverrides(command.run); @@ -613,7 +715,29 @@ void main() { verify(() => progress.fail('$exception')).called(1); }); + test('exits with code 70 when unable to find shorebird.yaml', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify( + () => progress.fail('Exception: Unable to find shorebird.yaml'), + ).called(1); + verifyNever( + () => iosDeploy.installAndLaunchApp(bundlePath: runnerPath()), + ); + }); + test('exits with code 70 when install/launch throws', () async { + File( + p.join( + runnerPath(), + 'Frameworks', + 'App.framework', + 'flutter_assets', + 'shorebird.yaml', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('app_id: $appId', flush: true); final exception = Exception('oops'); when( () => iosDeploy.installAndLaunchApp( @@ -629,6 +753,17 @@ void main() { }); test('exits with code 0 when install/launch succeeds', () async { + final shorebirdYaml = File( + p.join( + runnerPath(), + 'Frameworks', + 'App.framework', + 'flutter_assets', + 'shorebird.yaml', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('app_id: $appId', flush: true); when( () => iosDeploy.installAndLaunchApp( bundlePath: any(named: 'bundlePath'), @@ -640,6 +775,13 @@ void main() { verify( () => iosDeploy.installAndLaunchApp(bundlePath: runnerPath()), ).called(1); + expect( + shorebirdYaml.readAsStringSync(), + equals(''' +app_id: $appId +channel: $channel +'''), + ); }); }); }); diff --git a/packages/shorebird_code_push_protocol/test/src/messages/get_apps/get_apps_response_test.dart b/packages/shorebird_code_push_protocol/test/src/messages/get_apps/get_apps_response_test.dart index 81d5c8f8..ccb3346f 100644 --- a/packages/shorebird_code_push_protocol/test/src/messages/get_apps/get_apps_response_test.dart +++ b/packages/shorebird_code_push_protocol/test/src/messages/get_apps/get_apps_response_test.dart @@ -11,7 +11,7 @@ void main() { displayName: 'display-name', createdAt: DateTime(2022), updatedAt: DateTime(2023), - ) + ), ], ); expect( diff --git a/packages/shorebird_code_push_protocol/test/src/messages/get_releases/get_releases_response_test.dart b/packages/shorebird_code_push_protocol/test/src/messages/get_releases/get_releases_response_test.dart index 78883d91..b1650e64 100644 --- a/packages/shorebird_code_push_protocol/test/src/messages/get_releases/get_releases_response_test.dart +++ b/packages/shorebird_code_push_protocol/test/src/messages/get_releases/get_releases_response_test.dart @@ -18,7 +18,7 @@ void main() { }, createdAt: DateTime(2022), updatedAt: DateTime(2023), - ) + ), ], ); expect(