diff --git a/packages/shorebird_cli/lib/src/artifact_manager.dart b/packages/shorebird_cli/lib/src/artifact_manager.dart index 55a0271a..89dad782 100644 --- a/packages/shorebird_cli/lib/src/artifact_manager.dart +++ b/packages/shorebird_cli/lib/src/artifact_manager.dart @@ -2,11 +2,14 @@ import 'dart:io'; import 'dart:isolate'; import 'package:archive/archive_io.dart'; +import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; /// A reference to a [ArtifactManager] instance. @@ -173,4 +176,88 @@ Failed to create diff (exit code ${result.exitCode}). return archsDirectory.existsSync() ? archsDirectory : null; } + + /// Returns the .xcarchive directory generated by `flutter build ipa`. This + /// was traditionally named `Runner.xcarchive`, but can now be renamed. + Directory? getXcarchiveDirectory() { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; + final archiveDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + ), + ); + + if (!archiveDirectory.existsSync()) return null; + + return archiveDirectory + .listSync() + .whereType() + .firstWhereOrNull((directory) => directory.path.endsWith('.xcarchive')); + } + + /// Returns the .app directory generated by `flutter build ipa`. This was + /// traditionally named `Runner.app`, but can now be renamed. + Directory? getIosAppDirectory({required Directory xcarchiveDirectory}) { + final applicationsDirectory = Directory( + p.join( + xcarchiveDirectory.path, + 'Products', + 'Applications', + ), + ); + + if (!applicationsDirectory.existsSync()) { + return null; + } + + return applicationsDirectory + .listSync() + .whereType() + .firstWhereOrNull((directory) => directory.path.endsWith('.app')); + } + + /// Returns the path to the .ipa file generated by `flutter build ipa`. + /// + /// Returns null if: + /// - there is no ipa build directory (build/ios/ipa) + /// - there is no .ipa file in the ipa build directory + /// - there is more than one .ipa file in the ipa build directory + File? getIpa() { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; + final ipaBuildDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'ipa', + ), + ); + + if (!ipaBuildDirectory.existsSync()) { + logger.detail('No directory found at ${ipaBuildDirectory.path}'); + return null; + } + + final ipaFiles = ipaBuildDirectory + .listSync(recursive: true) + .whereType() + .where((f) => p.extension(f.path) == '.ipa'); + + if (ipaFiles.isEmpty) { + logger.detail('No .ipa files found in ${ipaBuildDirectory.path}'); + return null; + } + + if (ipaFiles.length > 1) { + logger.detail( + 'More than one .ipa file found in ${ipaBuildDirectory.path}', + ); + return null; + } + + return ipaFiles.single; + } } diff --git a/packages/shorebird_cli/test/src/artifact_builder_test.dart b/packages/shorebird_cli/test/src/artifact_builder_test.dart index 85052010..5473ae30 100644 --- a/packages/shorebird_cli/test/src/artifact_builder_test.dart +++ b/packages/shorebird_cli/test/src/artifact_builder_test.dart @@ -512,9 +512,7 @@ Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.pa final tempDir = Directory.systemTemp.createTempSync(); exportOptionsPlist = File(p.join(tempDir.path, 'exportoptions.plist')); - when(ios.createExportOptionsPlist).thenReturn( - exportOptionsPlist, - ); + when(ios.createExportOptionsPlist).thenReturn(exportOptionsPlist); }); group('with default arguments', () { diff --git a/packages/shorebird_cli/test/src/artifact_manager_test.dart b/packages/shorebird_cli/test/src/artifact_manager_test.dart index 96714fd1..7ed2c753 100644 --- a/packages/shorebird_cli/test/src/artifact_manager_test.dart +++ b/packages/shorebird_cli/test/src/artifact_manager_test.dart @@ -8,6 +8,8 @@ import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:test/test.dart'; @@ -19,6 +21,9 @@ void main() { late Cache cache; late Directory cacheArtifactDirectory; late http.Client httpClient; + late Directory projectRoot; + late Logger logger; + late ShorebirdEnv shorebirdEnv; late ShorebirdProcessResult patchProcessResult; late ShorebirdProcess shorebirdProcess; late ArtifactManager artifactManager; @@ -29,7 +34,9 @@ void main() { values: { cacheRef.overrideWith(() => cache), httpClientRef.overrideWith(() => httpClient), + loggerRef.overrideWith(() => logger), processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), }, ); } @@ -42,9 +49,11 @@ void main() { cacheArtifactDirectory = Directory.systemTemp.createTempSync(); cache = MockCache(); httpClient = MockHttpClient(); + logger = MockLogger(); patchProcessResult = MockProcessResult(); + projectRoot = Directory.systemTemp.createTempSync(); shorebirdProcess = MockShorebirdProcess(); - artifactManager = ArtifactManager(); + shorebirdEnv = MockShorebirdEnv(); when(() => cache.getArtifactDirectory(any())) .thenReturn(cacheArtifactDirectory); @@ -53,6 +62,10 @@ void main() { when(() => httpClient.send(any())).thenAnswer( (_) async => http.StreamedResponse(const Stream.empty(), HttpStatus.ok), ); + + when(() => shorebirdEnv.getShorebirdProjectRoot()) + .thenReturn(projectRoot); + when( () => shorebirdProcess.run( any(that: endsWith('patch')), @@ -68,6 +81,8 @@ void main() { return patchProcessResult; }); when(() => patchProcessResult.exitCode).thenReturn(ExitCode.success.code); + + artifactManager = ArtifactManager(); }); group('createDiff', () { @@ -399,5 +414,150 @@ void main() { }); }); }); + + group('getXcarchiveDirectory', () { + group('when archive directory exists', () { + late Directory archiveDirectory; + + setUp(() { + archiveDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + ), + )..createSync(recursive: true); + }); + + test('returns path to archive directory', () async { + final result = runWithOverrides( + () => artifactManager.getXcarchiveDirectory(), + ); + + expect(result, isNotNull); + expect(result!.path, equals(archiveDirectory.path)); + }); + }); + + group('when archive directory does not exist', () { + test('returns null', () { + expect( + runWithOverrides(artifactManager.getXcarchiveDirectory), + isNull, + ); + }); + }); + }); + + group('getIosAppDirectory', () { + group('when applications directory does not exist', () { + test('returns null', () { + final xcarchiveDirectory = Directory.systemTemp.createTempSync(); + expect( + runWithOverrides( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: xcarchiveDirectory, + ), + ), + isNull, + ); + }); + }); + + group('when applications directory exists', () { + late Directory applicationsDirectory; + late Directory xcarchiveDirectory; + + setUp(() { + xcarchiveDirectory = Directory.systemTemp.createTempSync(); + applicationsDirectory = Directory( + p.join( + xcarchiveDirectory.path, + 'Products', + 'Applications', + 'Runner.app', + ), + )..createSync(recursive: true); + }); + + test('returns path to applications directory', () { + final result = runWithOverrides( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: xcarchiveDirectory, + ), + ); + + expect(result, isNotNull); + expect(result!.path, equals(applicationsDirectory.path)); + }); + }); + }); + + group('getIpa', () { + group('when ipa build directory does not exist', () { + test('returns null', () { + expect( + runWithOverrides(artifactManager.getIpa), + isNull, + ); + }); + }); + + group('when ipa build directory exists', () { + late Directory ipaBuildDirectory; + late File ipaFile; + + setUp(() { + ipaBuildDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'ipa', + ), + )..createSync(recursive: true); + ipaFile = File(p.join(ipaBuildDirectory.path, 'Runner.ipa')) + ..createSync(); + }); + + test('returns path to ipa file', () { + final result = runWithOverrides(artifactManager.getIpa); + + expect(result, isNotNull); + expect(result!.path, equals(ipaFile.path)); + }); + + test('returns null when multiple ipa files exist', () { + File(p.join(ipaBuildDirectory.path, 'Runner2.ipa')).createSync(); + + expect( + runWithOverrides(artifactManager.getIpa), + isNull, + ); + verify( + () => logger.detail( + 'More than one .ipa file found in ${ipaBuildDirectory.path}', + ), + ); + }); + + test('returns null when no ipa files exist', () { + ipaFile.deleteSync(); + + expect( + runWithOverrides(artifactManager.getIpa), + isNull, + ); + + verify( + () => logger.detail( + 'No .ipa files found in ${ipaBuildDirectory.path}', + ), + ); + }); + }); + }); }); }