feat(shorebird_cli): add channel support to patch and preview commands (#1340)

This commit is contained in:
Felix Angelov
2023-10-02 13:59:50 -05:00
committed by GitHub
parent 41b07539fe
commit aefecff218
8 changed files with 318 additions and 47 deletions
@@ -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?;
@@ -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?;
@@ -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<int> 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<int> 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<void> 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<void> 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);
});
}
}
@@ -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);
@@ -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);
@@ -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<void> 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<int>();
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<int>();
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<AppMetadata>(
@@ -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<String>(
@@ -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<Release>(
@@ -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
'''),
);
});
});
});
@@ -11,7 +11,7 @@ void main() {
displayName: 'display-name',
createdAt: DateTime(2022),
updatedAt: DateTime(2023),
)
),
],
);
expect(
@@ -18,7 +18,7 @@ void main() {
},
createdAt: DateTime(2022),
updatedAt: DateTime(2023),
)
),
],
);
expect(