feat(shorebird_cli): shorebird patch android supports dynamic Flutter revision (#1023)

This commit is contained in:
Felix Angelov
2023-08-04 11:17:34 -05:00
committed by GitHub
parent 3efa2b0535
commit 4ea780f35c
2 changed files with 305 additions and 212 deletions
@@ -1,9 +1,11 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
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/cache.dart';
@@ -15,6 +17,7 @@ import 'package:shorebird_cli/src/formatters/formatters.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter_manager.dart';
import 'package:shorebird_cli/src/shorebird_release_version_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
@@ -99,45 +102,22 @@ class PatchAndroidCommand extends ShorebirdCommand
const channelName = 'stable';
final flavor = results['flavor'] as String?;
final target = results['target'] as String?;
final buildProgress = logger.progress('Building patch');
try {
await buildAppBundle(flavor: flavor, target: target);
buildProgress.complete();
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId(flavor: flavor);
final app = await codePushClientWrapper.getApp(appId: appId);
final releases = await codePushClientWrapper.getReleases(appId: appId);
final releaseVersion = results['release-version'] as String? ??
await promptForReleaseVersion(releases);
final bundlePath = flavor != null
? './build/app/outputs/bundle/${flavor}Release/app-$flavor-release.aab'
: './build/app/outputs/bundle/release/app-release.aab';
final releaseVersionArg = results['release-version'] as String?;
final String releaseVersion;
final detectReleaseVersionProgress = logger.progress(
'Detecting release version',
final release = releases.firstWhereOrNull(
(r) => r.version == releaseVersion,
);
try {
releaseVersion = releaseVersionArg ??
await extractReleaseVersionFromAppBundle(bundlePath);
detectReleaseVersionProgress.complete(
'Detected release version $releaseVersion',
);
} catch (error) {
detectReleaseVersionProgress.fail('$error');
return ExitCode.software.code;
if (releaseVersion == null || release == null) {
logger.info('No releases found');
return ExitCode.success.code;
}
final release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
if (release.platformStatuses[ReleasePlatform.android] ==
ReleaseStatus.draft) {
logger.err('''
@@ -147,33 +127,6 @@ Please re-run the release command for this version or create a new release.''');
return ExitCode.software.code;
}
final shorebirdFlutterRevision = shorebirdEnv.flutterRevision;
if (release.flutterRevision != shorebirdFlutterRevision) {
logger
..err('''
Flutter revision mismatch.
The release you are trying to patch was built with a different version of Flutter.
Release Flutter Revision: ${release.flutterRevision}
Current Flutter Revision: $shorebirdFlutterRevision
''')
..info(
'''
Either create a new release using:
${lightCyan.wrap('shorebird release')}
Or downgrade your Flutter version and try again using:
${lightCyan.wrap('cd ${shorebirdEnv.flutterDirectory.path}')}
${lightCyan.wrap('git checkout ${release.flutterRevision}')}
Shorebird plans to support this automatically, let us know if it's important to you:
https://github.com/shorebirdtech/shorebird/issues/472
''',
);
return ExitCode.software.code;
}
final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts(
appId: app.appId,
releaseId: release.id,
@@ -211,12 +164,49 @@ https://github.com/shorebirdtech/shorebird/issues/472
Uri.parse(releaseAabArtifact.url),
httpClient: _httpClient,
);
downloadReleaseArtifactProgress.complete();
} catch (error) {
downloadReleaseArtifactProgress.fail('$error');
return ExitCode.software.code;
}
downloadReleaseArtifactProgress.complete();
final shorebirdFlutterRevision = shorebirdEnv.flutterRevision;
if (release.flutterRevision != shorebirdFlutterRevision) {
final installFlutterRevisionProgress = logger.progress(
'Switching to Flutter revision ${release.flutterRevision}',
);
try {
await shorebirdFlutterManager.installRevision(
revision: release.flutterRevision,
);
installFlutterRevisionProgress.complete();
} catch (error) {
installFlutterRevisionProgress.fail('$error');
return ExitCode.software.code;
}
}
final buildProgress = logger.progress('Building patch');
try {
await runScoped(
() => buildAppBundle(flavor: flavor, target: target),
values: {
shorebirdEnvRef.overrideWith(
() => ShorebirdEnv(
flutterRevisionOverride: release.flutterRevision,
),
)
},
);
buildProgress.complete();
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
final bundlePath = flavor != null
? './build/app/outputs/bundle/${flavor}Release/app-$flavor-release.aab'
: './build/app/outputs/bundle/release/app-release.aab';
FileSetDiff contentDiffs;
try {
@@ -358,6 +348,16 @@ ${summary.join('\n')}
return ExitCode.success.code;
}
Future<String?> promptForReleaseVersion(List<Release> releases) async {
if (releases.isEmpty) return null;
final release = logger.chooseOne(
'Which release would you like to patch?',
choices: releases,
display: (release) => release.version,
);
return release.version;
}
Future<String> downloadReleaseArtifact(
Uri uri, {
required http.Client httpClient,
@@ -22,6 +22,7 @@ import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/process.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter_manager.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';
@@ -54,6 +55,8 @@ class _MockProgress extends Mock implements Progress {}
class _MockProcessResult extends Mock implements ShorebirdProcessResult {}
class _MockProcessWrapper extends Mock implements ProcessWrapper {}
class _MockHttpClient extends Mock implements http.Client {}
class _MockShorebirdEnv extends Mock implements ShorebirdEnv {}
@@ -61,6 +64,9 @@ class _MockShorebirdEnv extends Mock implements ShorebirdEnv {}
class _MockShorebirdFlutterValidator extends Mock
implements ShorebirdFlutterValidator {}
class _MockShorebirdFlutterManager extends Mock
implements ShorebirdFlutterManager {}
class _MockShorebirdProcess extends Mock implements ShorebirdProcess {}
class _MockShorebirdValidator extends Mock implements ShorebirdValidator {}
@@ -133,6 +139,7 @@ flutter:
late ShorebirdProcessResult patchProcessResult;
late http.Client httpClient;
late Cache cache;
late ShorebirdFlutterManager shorebirdFlutterManager;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
late ShorebirdValidator shorebirdValidator;
@@ -153,6 +160,9 @@ flutter:
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
platformRef.overrideWith(() => platform),
processRef.overrideWith(() => shorebirdProcess),
shorebirdFlutterManagerRef.overrideWith(
() => shorebirdFlutterManager,
),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
},
);
@@ -217,6 +227,7 @@ flutter:
cache = _MockCache();
shorebirdEnv = _MockShorebirdEnv();
shorebirdProcess = _MockShorebirdProcess();
shorebirdFlutterManager = _MockShorebirdFlutterManager();
shorebirdValidator = _MockShorebirdValidator();
command = runWithOverrides(
() => PatchAndroidCommand(
@@ -272,6 +283,7 @@ flutter:
when(() => argResults['channel']).thenReturn(channelName);
when(() => argResults['dry-run']).thenReturn(false);
when(() => argResults['force']).thenReturn(false);
when(() => argResults['release-version']).thenReturn(release.version);
when(() => auth.isAuthenticated).thenReturn(true);
when(() => auth.client).thenReturn(httpClient);
when(() => logger.progress(any())).thenReturn(progress);
@@ -292,11 +304,8 @@ flutter:
),
).thenAnswer((_) async => app);
when(
() => codePushClientWrapper.getRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer((_) async => release);
() => codePushClientWrapper.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => [release]);
when(
() => codePushClientWrapper.getReleaseArtifacts(
appId: any(named: 'appId'),
@@ -341,6 +350,11 @@ flutter:
when(() => bundletool.getVersionCode(any())).thenAnswer(
(_) async => versionCode,
);
when(
() => shorebirdFlutterManager.installRevision(
revision: any(named: 'revision'),
),
).thenAnswer((_) async {});
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
@@ -376,19 +390,6 @@ flutter:
).called(1);
});
test('exits with code 70 when building fails', () async {
when(() => flutterBuildProcessResult.exitCode).thenReturn(1);
when(() => flutterBuildProcessResult.stderr).thenReturn('oops');
final tempDir = setUpTempDir();
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
});
test(
'exits with usage code when '
'both --dry-run and --force are specified', () async {
@@ -402,6 +403,232 @@ flutter:
expect(exitCode, equals(ExitCode.usage.code));
});
test('prompts for release when release-version is not specified', () async {
when(() => argResults['release-version']).thenReturn(null);
when(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenReturn(release);
try {
await runWithOverrides(command.run);
} catch (_) {}
await untilCalled(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
);
final display = verify(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: captureAny(named: 'display'),
),
).captured.single as String Function(Release);
expect(display(release), equals(release.version));
});
test('exits early when no releases are found', () async {
when(() => argResults['release-version']).thenReturn(null);
when(
() => codePushClientWrapper.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
try {
await runWithOverrides(command.run);
} catch (_) {}
verifyNever(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: captureAny(named: 'display'),
),
);
verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1);
verify(() => logger.info('No releases found')).called(1);
});
test(
'''exits with code 70 if release is in draft state for the android platform''',
() async {
when(
() => codePushClientWrapper.getReleases(
appId: any(named: 'appId'),
),
).thenAnswer(
(_) async => const [
Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {releasePlatform: ReleaseStatus.draft},
),
],
);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, ExitCode.software.code);
verify(
() => logger.err('''
Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.'''),
).called(1);
});
test('proceeds if release is in draft state for non-android platform',
() async {
when(
() => codePushClientWrapper.getReleases(
appId: any(named: 'appId'),
),
).thenAnswer(
(_) async => const [
Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft},
)
],
);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, ExitCode.success.code);
});
test(
'installs correct flutter revision '
'when release flutter revision differs', () async {
const otherRevision = 'other-revision';
when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => logger.progress(
'Switching to Flutter revision ${release.flutterRevision}',
),
).called(1);
verify(
() => shorebirdFlutterManager.installRevision(
revision: release.flutterRevision,
),
).called(1);
});
test(
'builds using correct flutter revision '
'when release flutter revision differs', () async {
when(
() => platform.script,
).thenReturn(Uri.parse('file:///bin/cache/shorebird.snapshot'));
const otherRevision = 'other-revision';
when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision);
final processWrapper = _MockProcessWrapper();
when(
() => processWrapper.run(
any(),
any(),
runInShell: any(named: 'runInShell'),
workingDirectory: any(named: 'workingDirectory'),
environment: any(named: 'environment'),
),
).thenAnswer((_) async => flutterBuildProcessResult);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
await IOOverrides.runZoned(
() => runWithOverrides(
() => runScoped(
() => command.run(),
values: {
processRef.overrideWith(
() => ShorebirdProcess(
logger: logger,
processWrapper: processWrapper,
),
),
},
),
),
getCurrentDirectory: () => tempDir,
);
verify(
() => processWrapper.run(
'/bin/cache/flutter/${release.flutterRevision}/bin/flutter',
any(),
runInShell: true,
workingDirectory: any(named: 'workingDirectory'),
environment: any(named: 'environment'),
),
).called(1);
});
test(
'exits with code 70 when '
'unable to install correct flutter revision', () async {
final exception = Exception('oops');
const otherRevision = 'other-revision';
when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision);
when(
() => shorebirdFlutterManager.installRevision(
revision: any(named: 'revision'),
),
).thenThrow(exception);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => logger.progress(
'Switching to Flutter revision ${release.flutterRevision}',
),
).called(1);
verify(
() => shorebirdFlutterManager.installRevision(
revision: release.flutterRevision,
),
).called(1);
verify(() => progress.fail('$exception')).called(1);
});
test('exits with code 70 when building fails', () async {
when(() => flutterBuildProcessResult.exitCode).thenReturn(1);
when(() => flutterBuildProcessResult.stderr).thenReturn('oops');
final tempDir = setUpTempDir();
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
});
test('aborts when user opts out', () async {
when(() => logger.confirm(any())).thenReturn(false);
final tempDir = setUpTempDir();
@@ -414,140 +641,6 @@ flutter:
verify(() => logger.info('Aborting.')).called(1);
});
test(
'''exits with code 70 if release is in draft state for the android platform''',
() async {
when(
() => codePushClientWrapper.getRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer(
(_) async => const Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {releasePlatform: ReleaseStatus.draft},
),
);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, ExitCode.software.code);
verify(
() => logger.err('''
Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.'''),
).called(1);
},
);
test(
'proceeds if release is in draft state for non-android platform',
() async {
when(
() => codePushClientWrapper.getRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer(
(_) async => const Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft},
),
);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, ExitCode.success.code);
},
);
test(
'errors when shorebird flutter revision '
'does not match release revision', () async {
const otherRevision = 'other-revision';
when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, ExitCode.software.code);
verify(
() => logger.err('''
Either create a new release using:
${lightCyan.wrap('shorebird release aar')}
Or downgrade your Flutter version and try again using:
${lightCyan.wrap('cd ${shorebirdEnv.flutterDirectory.path}')}
${lightCyan.wrap('git checkout ${release.flutterRevision}')}
Shorebird plans to support this automatically, let us know if it's important to you:
https://github.com/shorebirdtech/shorebird/issues/472
'''),
).called(1);
});
test('errors when detecting release version name fails', () async {
final exception = Exception(
'Failed to extract version name from app bundle: oops',
);
when(() => bundletool.getVersionName(any())).thenThrow(exception);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, ExitCode.software.code);
verify(() => progress.fail('$exception')).called(1);
});
test('errors when detecting release version code fails', () async {
final exception = Exception(
'Failed to extract version code from app bundle: oops',
);
when(() => bundletool.getVersionCode(any())).thenThrow(exception);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, ExitCode.software.code);
verify(() => progress.fail('$exception')).called(1);
});
test('prints release version when detected', () async {
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.success.code));
verify(() => progress.complete('Detected release version 1.2.3+1'))
.called(1);
});
test('throws error when release artifact does not exist.', () async {
when(() => httpClient.send(any())).thenAnswer(
(_) async => http.StreamedResponse(