diff --git a/packages/artifact_proxy/pubspec.lock b/packages/artifact_proxy/pubspec.lock index 5cd938929..f35f2cb75 100644 --- a/packages/artifact_proxy/pubspec.lock +++ b/packages/artifact_proxy/pubspec.lock @@ -26,26 +26,26 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: @@ -122,10 +122,10 @@ packages: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.10.1" collection: dependency: "direct main" description: @@ -138,50 +138,50 @@ packages: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" coverage: dependency: transitive description: name: coverage - sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 + sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 url: "https://pub.dev" source: hosted - version: "1.9.2" + version: "1.11.1" crypto: dependency: transitive description: name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" dart_style: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.3.7" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" frontend_server_client: dependency: transitive description: @@ -234,18 +234,18 @@ packages: dependency: transitive description: name: http_parser - sha256: "40f592dd352890c3b60fec1b68e786cefb9603e05ff303dbc4dda49b304ecdf4" + sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.1.1" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" js: dependency: transitive description: @@ -274,10 +274,10 @@ packages: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" macros: dependency: transitive description: @@ -298,18 +298,18 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: transitive description: name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "2.0.0" mocktail: dependency: "direct dev" description: @@ -330,18 +330,18 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" pool: dependency: transitive description: @@ -354,10 +354,10 @@ packages: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" pubspec_parse: dependency: transitive description: @@ -410,10 +410,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" source_gen: dependency: transitive description: @@ -442,26 +442,26 @@ packages: dependency: transitive description: name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted - version: "0.10.12" + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" stream_channel: dependency: transitive description: @@ -482,10 +482,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" term_glyph: dependency: transitive description: @@ -530,10 +530,10 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" very_good_analysis: dependency: "direct dev" description: @@ -546,10 +546,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.1" watcher: dependency: transitive description: @@ -562,10 +562,10 @@ packages: dependency: transitive description: name: web - sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" web_socket: dependency: transitive description: diff --git a/packages/discord_gcp_alerts/pubspec.lock b/packages/discord_gcp_alerts/pubspec.lock index 4aa7d4e8f..26e11c615 100644 --- a/packages/discord_gcp_alerts/pubspec.lock +++ b/packages/discord_gcp_alerts/pubspec.lock @@ -26,26 +26,26 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: @@ -122,66 +122,66 @@ packages: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.10.1" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" convert: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" coverage: dependency: transitive description: name: coverage - sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 + sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 url: "https://pub.dev" source: hosted - version: "1.9.2" + version: "1.11.1" crypto: dependency: transitive description: name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" dart_style: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.3.7" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" frontend_server_client: dependency: transitive description: @@ -234,18 +234,18 @@ packages: dependency: transitive description: name: http_parser - sha256: "40f592dd352890c3b60fec1b68e786cefb9603e05ff303dbc4dda49b304ecdf4" + sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.1.1" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" js: dependency: transitive description: @@ -274,10 +274,10 @@ packages: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" macros: dependency: transitive description: @@ -298,18 +298,18 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: transitive description: name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "2.0.0" mocktail: dependency: "direct dev" description: @@ -330,18 +330,18 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" pool: dependency: transitive description: @@ -354,10 +354,10 @@ packages: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" pubspec_parse: dependency: transitive description: @@ -402,10 +402,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" source_gen: dependency: transitive description: @@ -434,26 +434,26 @@ packages: dependency: transitive description: name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted - version: "0.10.12" + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" stream_channel: dependency: transitive description: @@ -474,10 +474,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" term_glyph: dependency: transitive description: @@ -522,10 +522,10 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" very_good_analysis: dependency: "direct dev" description: @@ -538,10 +538,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "15.0.0" watcher: dependency: transitive description: @@ -554,10 +554,10 @@ packages: dependency: transitive description: name: web - sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" web_socket: dependency: transitive description: diff --git a/packages/shorebird_cli/bin/shorebird.dart b/packages/shorebird_cli/bin/shorebird.dart index 6f23209ee..d67949a22 100644 --- a/packages/shorebird_cli/bin/shorebird.dart +++ b/packages/shorebird_cli/bin/shorebird.dart @@ -63,6 +63,7 @@ Command: shorebird ${args.join(' ')} codePushClientWrapperRef, codeSignerRef, devicectlRef, + dittoRef, doctorRef, engineConfigRef, gitRef, diff --git a/packages/shorebird_cli/lib/src/archive_analysis/plist.dart b/packages/shorebird_cli/lib/src/archive_analysis/plist.dart index e62437eb7..4bac17a02 100644 --- a/packages/shorebird_cli/lib/src/archive_analysis/plist.dart +++ b/packages/shorebird_cli/lib/src/archive_analysis/plist.dart @@ -31,6 +31,7 @@ class Plist { /// CFBundleShortVersionString: "1.0.0", /// CFBundleVersion: "1", /// }, + /// This nesting is not present in Info.plist files in app bundles. static const applicationPropertiesKey = 'ApplicationProperties'; /// The properties contained in the Info.plist file. @@ -39,7 +40,9 @@ class Plist { /// The version number of the application. String get versionNumber { final applicationProperties = - properties[applicationPropertiesKey]! as Map; + properties.containsKey(applicationPropertiesKey) + ? properties[applicationPropertiesKey]! as Map + : properties; final releaseVersion = applicationProperties[releaseVersionKey] as String?; final buildNumber = applicationProperties[buildNumberKey] as String?; if (releaseVersion == null) { diff --git a/packages/shorebird_cli/lib/src/artifact_builder.dart b/packages/shorebird_cli/lib/src/artifact_builder.dart index 297d43c96..21349254c 100644 --- a/packages/shorebird_cli/lib/src/artifact_builder.dart +++ b/packages/shorebird_cli/lib/src/artifact_builder.dart @@ -6,6 +6,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; 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/logging/logging.dart'; import 'package:shorebird_cli/src/os/operating_system_interface.dart'; @@ -20,6 +21,11 @@ import 'package:shorebird_cli/src/shorebird_process.dart'; /// Flutter. typedef ShorebirdBuildCommand = Future Function(); +// FIXME: The following three apple BuildResult classes are identical and +// should be merged. They are all capturing the idea that we want to get the +// kernel (app.dill) file generated during a build so we can use it to link +// when patching. + /// {@template ipa_build_result} /// Metadata about the result of a `flutter build ipa` invocation. /// {@endtemplate} @@ -44,6 +50,17 @@ class IosFrameworkBuildResult { final File kernelFile; } +/// {@template macos_build_result} +/// Metadata about the result of a `flutter build macos` invocation. +/// {@endtemplate} +class MacosBuildResult { + /// {@macro macos_build_result} + MacosBuildResult({required this.kernelFile}); + + /// The app.dill file produced by this invocation of `flutter build ipa`. + final File kernelFile; +} + /// {@template artifact_build_exception} /// Thrown when a build fails. /// {@endtemplate} @@ -256,6 +273,86 @@ class ArtifactBuilder { }); } + /// Builds a macOS app using `flutter build macos`. Runs `flutter pub get` + /// with the system installation of Flutter to reset + /// `.dart_tool/package_config.json` after the build completes or fails. + Future buildMacos({ + bool codesign = true, + String? flavor, + String? target, + List args = const [], + String? base64PublicKey, + DetailProgress? buildProgress, + }) async { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; + // Delete the .dart_tool directory to ensure that the app is rebuilt. + // Without this, the build command will not print the app.dill path + final dartToolDir = Directory(p.join(projectRoot.path, '.dart_tool')); + if (dartToolDir.existsSync()) { + dartToolDir.deleteSync(recursive: true); + } + + String? appDillPath; + await _runShorebirdBuildCommand(() async { + const executable = 'flutter'; + final arguments = [ + 'build', + 'macos', + '--release', + if (flavor != null) '--flavor=$flavor', + if (target != null) '--target=$target', + if (!codesign) '--no-codesign', + ...args, + ]; + + final buildProcess = await process.start( + executable, + arguments, + runInShell: true, + environment: base64PublicKey?.toPublicKeyEnv(), + ); + + final stdoutLines = []; + buildProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + stdoutLines.add(line); + if (buildProgress == null) { + return; + } + + // TODO(bryanoltman): update the progress message for macOS builds. + // final update = _progressUpdateFromMacosBuildLog(line); + // if (update != null) { + // buildProgress.updateDetailMessage(update); + // } + }); + + final stderrLines = await buildProcess.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .toList(); + final stderr = stderrLines.join('\n'); + final stdout = stdoutLines.join('\n'); + final exitCode = await buildProcess.exitCode; + if (exitCode != ExitCode.success.code) { + throw ArtifactBuildException('Failed to build: $stderr'); + } + + appDillPath = findAppDill(stdout: stdout); + }); + + if (appDillPath == null) { + throw ArtifactBuildException(''' +Unable to find app.dill file. +Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with the logs for this command. +'''); + } + + return MacosBuildResult(kernelFile: File(appDillPath!)); + } + /// Calls `flutter build ipa`. If [codesign] is false, this will only build /// an .xcarchive and _not_ an .ipa. Future buildIpa({ @@ -455,6 +552,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod Future buildElfAotSnapshot({ required String appDillPath, required String outFilePath, + required ShorebirdArtifact genSnapshotArtifact, List additionalArgs = const [], }) async { final arguments = [ @@ -466,9 +564,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod ]; final result = await process.run( - shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, - ), + shorebirdArtifacts.getArtifactPath(artifact: genSnapshotArtifact), arguments, ); @@ -522,8 +618,8 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod return null; } - /// Given the full stdout from a `flutter build ipa` command, finds the path - /// to the app.dill file that was built. + /// Given the full stdout from a `flutter build ipa` or `flutter build macos` + /// command, finds the path to the app.dill file that was built. @visibleForTesting String? findAppDill({required String stdout}) { final appDillLine = stdout.split('\n').firstWhereOrNull( diff --git a/packages/shorebird_cli/lib/src/artifact_manager.dart b/packages/shorebird_cli/lib/src/artifact_manager.dart index f68e47eb2..213109a0a 100644 --- a/packages/shorebird_cli/lib/src/artifact_manager.dart +++ b/packages/shorebird_cli/lib/src/artifact_manager.dart @@ -264,6 +264,38 @@ class ArtifactManager { return archsDirectory.existsSync() ? archsDirectory : null; } + /// The directory containing the compiled macOS .app file, if it exists. + Directory? getMacOSAppDirectory() { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; + + final appDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + ), + ); + + if (!appDirectory.existsSync()) return null; + + return appDirectory + .listSync() + .whereType() + // Get the most recently modified app to handle cases where an app + // may produce multiple apps with different names. + // This still could grab the wrong app, if multiple `flutter` commands + // are running in parallel or the clock is/was broken on the machine. + // If either of those occurs in the wild we can check the contents of + // the apps, but this should be good enough for now. + .sorted( + (a, b) => b.statSync().modified.compareTo(a.statSync().modified), + ) + .firstWhereOrNull((directory) => directory.path.endsWith('.app')); + } + /// Returns the .xcarchive directory generated by `flutter build ipa`. This /// was traditionally named `Runner.xcarchive`, but can now be renamed. Directory? getXcarchiveDirectory() { @@ -357,7 +389,7 @@ class ArtifactManager { return ipaFiles.single; } - /// Returns the path to the shorebird release supplement directory on iOS. + /// Returns the path to the shorebird release supplement directory for iOS. /// /// Returns null if there is no supplement directory /// (e.g. when using older Flutter revisions). @@ -377,6 +409,26 @@ class ArtifactManager { return releaseSupplementDir; } + /// Returns the path to the shorebird release supplement directory for macOS. + /// + /// Returns null if there is no supplement directory + /// (e.g. when using older Flutter revisions). + Directory? getMacosReleaseSupplementDirectory() { + final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; + final releaseSupplementDir = Directory( + p.join(projectRoot.path, 'build', 'macos', 'shorebird'), + ); + + if (!releaseSupplementDir.existsSync()) { + logger.detail( + 'No macOS release supplements found at ${releaseSupplementDir.path}', + ); + return null; + } + + return releaseSupplementDir; + } + /// Name of the App.xcframework generated by `shorebird release ios-framework` static const String appXcframeworkName = 'App.xcframework'; 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 03d31032a..a24208bf5 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -17,6 +17,7 @@ import 'package:shorebird_cli/src/archive/directory_archive.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/deployment_track.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/shorebird_env.dart'; @@ -616,6 +617,64 @@ aar artifact already exists, continuing...''', return thinnedArchiveDirectory; } + /// Registers and uploads macOS release artifacts to the Shorebird server. + Future createMacosReleaseArtifacts({ + required String appId, + required int releaseId, + required String appPath, + required bool isCodesigned, + required String? podfileLockHash, + required String supplementPath, + }) async { + final createArtifactProgress = logger.progress('Uploading artifacts'); + final tempDir = await Directory.systemTemp.createTemp(); + final zippedApp = File(p.join(tempDir.path, '${p.basename(appPath)}.zip')); + await ditto.archive(source: appPath, destination: zippedApp.path); + + try { + await codePushClient.createReleaseArtifact( + appId: appId, + releaseId: releaseId, + artifactPath: zippedApp.path, + arch: 'app', + platform: ReleasePlatform.macos, + hash: sha256.convert(await zippedApp.readAsBytes()).toString(), + canSideload: true, + podfileLockHash: podfileLockHash, + ); + } catch (error) { + _handleErrorAndExit( + error, + progress: createArtifactProgress, + message: 'Error uploading app: $error', + ); + } + + final zippedSupplement = await Directory(supplementPath).zipToTempFile( + name: 'macos_supplement', + ); + try { + await codePushClient.createReleaseArtifact( + appId: appId, + releaseId: releaseId, + artifactPath: zippedSupplement.path, + arch: 'macos_supplement', + platform: ReleasePlatform.macos, + hash: sha256.convert(await zippedSupplement.readAsBytes()).toString(), + canSideload: false, + podfileLockHash: podfileLockHash, + ); + } catch (error) { + _handleErrorAndExit( + error, + progress: createArtifactProgress, + message: 'Error uploading release supplements: $error', + ); + } + + createArtifactProgress.complete(); + } + /// Uploads a release .xcarchive, .app, and supplementary files to the /// Shorebird server. Future createIosReleaseArtifacts({ @@ -628,8 +687,9 @@ aar artifact already exists, continuing...''', required String? supplementPath, }) async { final createArtifactProgress = logger.progress('Uploading artifacts'); - final thinnedArchiveDirectory = - await _thinXcarchive(xcarchivePath: xcarchivePath); + final thinnedArchiveDirectory = await _thinXcarchive( + xcarchivePath: xcarchivePath, + ); final zippedArchive = await thinnedArchiveDirectory.zipToTempFile(); try { await codePushClient.createReleaseArtifact( @@ -652,6 +712,7 @@ aar artifact already exists, continuing...''', final zippedRunner = await Directory(runnerPath).zipToTempFile(); try { + logger.detail('[archive] zipped runner.app to ${zippedRunner.path}'); await codePushClient.createReleaseArtifact( appId: appId, releaseId: releaseId, diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart index 80c751647..84922ab7c 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_framework_patcher.dart @@ -128,6 +128,7 @@ class IosFrameworkPatcher extends Patcher { 'build', 'out.aot', ), + genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos, additionalArgs: IosPatcher.splitDebugInfoArgs(splitDebugInfoPath), ); } catch (error) { @@ -246,7 +247,7 @@ class IosFrameworkPatcher extends Patcher { if (await aotTools.isGeneratePatchDiffBaseSupported()) { final patchBaseProgress = logger.progress('Generating patch diff base'); final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ); final File patchBaseFile; @@ -313,7 +314,7 @@ class IosFrameworkPatcher extends Patcher { final analyzeSnapshot = File( shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ), ); @@ -323,7 +324,7 @@ class IosFrameworkPatcher extends Patcher { } final genSnapshot = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, + artifact: ShorebirdArtifact.genSnapshotIos, ); final linkProgress = logger.progress('Linking AOT files'); diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart index 8033246b8..d926b9431 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart @@ -133,9 +133,9 @@ class IosPatcher extends Patcher { } final String? podfileLockHash; - if (shorebirdEnv.podfileLockFile.existsSync()) { + if (shorebirdEnv.iosPodfileLockFile.existsSync()) { podfileLockHash = sha256 - .convert(shorebirdEnv.podfileLockFile.readAsBytesSync()) + .convert(shorebirdEnv.iosPodfileLockFile.readAsBytesSync()) .toString(); } else { podfileLockHash = null; @@ -214,6 +214,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', await artifactBuilder.buildElfAotSnapshot( appDillPath: ipaBuildResult.kernelFile.path, outFilePath: _aotOutputPath, + genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos, additionalArgs: splitDebugInfoArgs(splitDebugInfoPath), ); } catch (error) { @@ -341,7 +342,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', if (useLinker && await aotTools.isGeneratePatchDiffBaseSupported()) { final patchBaseProgress = logger.progress('Generating patch diff base'); final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ); final File patchBaseFile; @@ -438,7 +439,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', final analyzeSnapshot = File( shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ), ); @@ -448,7 +449,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', } final genSnapshot = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, + artifact: ShorebirdArtifact.genSnapshotIos, ); final linkProgress = logger.progress('Linking AOT files'); diff --git a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart new file mode 100644 index 000000000..2f1f974e7 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart @@ -0,0 +1,428 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; +import 'package:shorebird_cli/src/archive/directory_archive.dart'; +import 'package:shorebird_cli/src/archive_analysis/plist.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/code_signer.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; +import 'package:shorebird_cli/src/common_arguments.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/detail_progress.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/metadata/metadata.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_documentation.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'; + +// TODO(bryanoltman): consolidate this - this was copied from [IosPatcher] +typedef _LinkResult = ({int exitCode, double? linkPercentage}); + +/// {@template macos_patcher} +/// Functions to create and apply patches to a macOS release. +/// {@endtemplate} +class MacosPatcher extends Patcher { + /// {@macro macos_patcher} + MacosPatcher({ + required super.argParser, + required super.argResults, + required super.flavor, + required super.target, + }); + + String get _patchClassTableLinkInfoPath => + p.join(buildDirectory.path, 'macos', 'shorebird', 'App.ct.link'); + + String get _patchClassTableLinkDebugInfoPath => + p.join(buildDirectory.path, 'macos', 'shorebird', 'App.class_table.json'); + + String get _aotOutputPath => p.join(buildDirectory.path, 'out.aot'); + + String get _appDillCopyPath => p.join(buildDirectory.path, 'app.dill'); + + String get _vmcodeOutputPath => p.join(buildDirectory.path, 'out.vmcode'); + + /// The name of the split debug info file when the target is macOS. + // FIXME: this is only the arm symbols, x64 symbols are at + // app.darwin-x86_64.symbols + static const splitDebugInfoFileName = 'app.darwin-arm64.symbols'; + + /// The additional gen_snapshot arguments to use when building the patch with + /// `--split-debug-info`. + static List splitDebugInfoArgs(String? splitDebugInfoPath) { + return splitDebugInfoPath != null + ? [ + '--dwarf-stack-traces', + '--resolve-dwarf-paths', + '''--save-debugging-info=${p.join(p.absolute(splitDebugInfoPath), splitDebugInfoFileName)}''', + ] + : []; + } + + @override + String? get supplementaryReleaseArtifactArch => 'macos_supplement'; + + /// The link percentage from the most recent patch build. + @visibleForTesting + double? lastBuildLinkPercentage; + + @override + double? get linkPercentage => lastBuildLinkPercentage; + + @override + ReleaseType get releaseType => ReleaseType.macos; + + /// Whether to codesign the release. + bool get codesign => argResults['codesign'] == true; + + @override + String get primaryReleaseArtifactArch => 'app'; + + @override + Future assertPreconditions() async { + try { + await shorebirdValidator.validatePreconditions( + checkShorebirdInitialized: true, + checkUserIsAuthenticated: true, + validators: doctor.macosCommandValidators, + supportedOperatingSystems: {Platform.macOS}, + ); + } on PreconditionFailedException catch (error) { + throw ProcessExit(error.exitCode.code); + } + } + + @override + Future assertUnpatchableDiffs({ + required ReleaseArtifact releaseArtifact, + required File releaseArchive, + required File patchArchive, + }) async { + // TODO(bryanoltman): implement assertUnpatchableDiffs + return const DiffStatus(hasAssetChanges: false, hasNativeChanges: false); + } + + @override + Future buildPatchArtifact({String? releaseVersion}) async { + try { + final (flutterVersionAndRevision, flutterVersion) = await ( + shorebirdFlutter.getVersionAndRevision(), + shorebirdFlutter.getVersion(), + ).wait; + + if ((flutterVersion ?? minimumSupportedMacosFlutterVersion) < + minimumSupportedMacosFlutterVersion) { + logger.err( + ''' +macOS patches are not supported with Flutter versions older than $minimumSupportedMacosFlutterVersion. +For more information see: ${supportedFlutterVersionsUrl.toLink()}''', + ); + throw ProcessExit(ExitCode.software.code); + } + + final buildProgress = logger.detailProgress( + 'Building patch with Flutter $flutterVersionAndRevision', + ); + final MacosBuildResult macosBuildResult; + try { + // If buildMacos is called with a different codesign value than the + // release was, we will erroneously report native diffs. + macosBuildResult = await artifactBuilder.buildMacos( + codesign: codesign, + flavor: flavor, + target: target, + args: argResults.forwardedArgs + + buildNameAndNumberArgsFromReleaseVersion(releaseVersion), + base64PublicKey: argResults.encodedPublicKey, + buildProgress: buildProgress, + ); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + rethrow; + } on ArtifactBuildException catch (error) { + buildProgress.fail('Failed to build macOS app'); + logger.err(error.message); + rethrow; + } + + try { + if (splitDebugInfoPath != null) { + Directory(splitDebugInfoPath!).createSync(recursive: true); + } + await artifactBuilder.buildElfAotSnapshot( + appDillPath: macosBuildResult.kernelFile.path, + outFilePath: _aotOutputPath, + genSnapshotArtifact: ShorebirdArtifact.genSnapshotMacOS, + additionalArgs: splitDebugInfoArgs(splitDebugInfoPath), + ); + } catch (error) { + buildProgress.fail('$error'); + rethrow; + } + + // Copy the kernel file to the build directory so that it can be used + // to generate a patch. + macosBuildResult.kernelFile.copySync(_appDillCopyPath); + + buildProgress.complete(); + } catch (_) { + throw ProcessExit(ExitCode.software.code); + } + + final appPath = artifactManager.getMacOSAppDirectory()!.path; + final tempDir = await Directory.systemTemp.createTemp(); + final zippedApp = File(p.join(tempDir.path, '${p.basename(appPath)}.zip')); + await ditto.archive( + source: appPath, + destination: zippedApp.path, + keepParent: true, + ); + return zippedApp; + } + + @override + Future> createPatchArtifacts({ + required String appId, + required int releaseId, + required File releaseArtifact, + File? supplementArtifact, + }) async { + if (supplementArtifact == null) { + logger.err('Unable to find supplement directory'); + throw ProcessExit(ExitCode.software.code); + } + + // Verify that we have built a patch .app + if (artifactManager.getMacOSAppDirectory()?.path == null) { + logger.err('Unable to find .app directory'); + throw ProcessExit(ExitCode.software.code); + } + + final unzipProgress = logger.progress('Extracting release artifact'); + final releaseAppDirectory = Directory.systemTemp.createTempSync(); + await ditto.extract( + source: releaseArtifact.path, + destination: releaseAppDirectory.path, + ); + + File? releaseClassTableLinkInfoFile; + File? releaseClassTableLinkDebugInfoFile; + final tempDir = Directory.systemTemp.createTempSync(); + await artifactManager.extractZip( + zipFile: supplementArtifact, + outputDirectory: tempDir, + ); + releaseClassTableLinkInfoFile = File(p.join(tempDir.path, 'App.ct.link')); + if (!releaseClassTableLinkInfoFile.existsSync()) { + logger.err('Unable to find class table link info file'); + throw ProcessExit(ExitCode.software.code); + } + + releaseClassTableLinkDebugInfoFile = File( + p.join(tempDir.path, 'App.class_table.json'), + ); + if (!releaseClassTableLinkDebugInfoFile.existsSync()) { + logger.err('Unable to find class table link debug info file'); + throw ProcessExit(ExitCode.software.code); + } + + unzipProgress.complete(); + final releaseArtifactFile = File( + p.join( + releaseAppDirectory.path, + 'Contents', + 'Frameworks', + 'App.framework', + 'App', + ), + ); + + // Copy the release's class table link info file next to the release + // snapshot so that it can be used to generate a patch. + releaseClassTableLinkInfoFile.copySync( + p.join(releaseArtifactFile.parent.path, 'App.ct.link'), + ); + releaseClassTableLinkDebugInfoFile.copySync( + p.join(releaseArtifactFile.parent.path, 'App.class_table.json'), + ); + + // Copy the patch's class table link info file to the build directory + // so that it can be used to generate a patch. + File(_patchClassTableLinkInfoPath).copySync( + p.join(buildDirectory.path, 'out.ct.link'), + ); + File(_patchClassTableLinkDebugInfoPath).copySync( + p.join(buildDirectory.path, 'out.class_table.json'), + ); + + final (:exitCode, :linkPercentage) = await _runLinker( + releaseArtifact: releaseArtifactFile, + kernelFile: File(_appDillCopyPath), + ); + if (exitCode != ExitCode.success.code) throw ProcessExit(exitCode); + if (linkPercentage != null && linkPercentage < Patcher.minLinkPercentage) { + logger.warn(Patcher.lowLinkPercentageWarning(linkPercentage)); + } + lastBuildLinkPercentage = linkPercentage; + + final patchBuildFile = File(_vmcodeOutputPath); + + final patchBaseProgress = logger.progress('Generating patch diff base'); + final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, + ); + + final File patchBaseFile; + try { + // Generate a stable diff base and use that to create a patch. + patchBaseFile = await aotTools.generatePatchDiffBase( + analyzeSnapshotPath: analyzeSnapshotPath, + releaseSnapshot: releaseArtifactFile, + ); + patchBaseProgress.complete(); + } catch (error) { + patchBaseProgress.fail('$error'); + throw ProcessExit(ExitCode.software.code); + } + + final patchFile = File( + await artifactManager.createDiff( + releaseArtifactPath: patchBaseFile.path, + patchArtifactPath: patchBuildFile.path, + ), + ); + + final patchFileSize = patchFile.statSync().size; + final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); + final hash = sha256.convert(patchBuildFile.readAsBytesSync()).toString(); + final hashSignature = privateKeyFile != null + ? codeSigner.sign( + message: hash, + privateKeyPemFile: privateKeyFile, + ) + : null; + + // TODO(bryanoltman): support x86_64 + return { + Arch.arm64: PatchArtifactBundle( + arch: 'aarch64', + path: patchFile.path, + hash: hash, + size: patchFileSize, + hashSignature: hashSignature, + ), + }; + } + + @override + Future extractReleaseVersionFromArtifact(File artifact) async { + final appPath = artifactManager.getMacOSAppDirectory()?.path; + if (appPath == null) { + logger.err('Unable to find .app directory'); + throw ProcessExit(ExitCode.software.code); + } + + final plistFile = File(p.join(appPath, 'Contents', 'Info.plist')); + if (!plistFile.existsSync()) { + logger.err('No Info.plist file found at ${plistFile.path}.'); + throw ProcessExit(ExitCode.software.code); + } + + final plist = Plist(file: plistFile); + try { + return plist.versionNumber; + } catch (error) { + logger.err( + 'Failed to determine release version from ${plistFile.path}: $error', + ); + throw ProcessExit(ExitCode.software.code); + } + } + + @override + Future updatedCreatePatchMetadata( + CreatePatchMetadata metadata, + ) async => + metadata.copyWith( + linkPercentage: lastBuildLinkPercentage, + environment: metadata.environment.copyWith( + xcodeVersion: await xcodeBuild.version(), + ), + ); + + Future<_LinkResult> _runLinker({ + required File releaseArtifact, + required File kernelFile, + }) async { + final patch = File(_aotOutputPath); + + if (!patch.existsSync()) { + logger.err('Unable to find patch AOT file at ${patch.path}'); + return (exitCode: ExitCode.software.code, linkPercentage: null); + } + + final analyzeSnapshot = File( + shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, + ), + ); + + if (!analyzeSnapshot.existsSync()) { + logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}'); + return (exitCode: ExitCode.software.code, linkPercentage: null); + } + + final genSnapshot = shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.genSnapshotMacOS, + ); + + final linkProgress = logger.progress('Linking AOT files'); + double? linkPercentage; + final dumpDebugInfoDir = await aotTools.isLinkDebugInfoSupported() + ? Directory.systemTemp.createTempSync() + : null; + + Future dumpDebugInfo() async { + if (dumpDebugInfoDir == null) return; + + final debugInfoZip = await dumpDebugInfoDir.zipToTempFile(); + debugInfoZip.copySync(p.join('build', debugInfoFile.path)); + logger.detail('Link debug info saved to ${debugInfoFile.path}'); + } + + try { + linkPercentage = await aotTools.link( + base: releaseArtifact.path, + patch: patch.path, + analyzeSnapshot: analyzeSnapshot.path, + genSnapshot: genSnapshot, + outputPath: _vmcodeOutputPath, + workingDirectory: buildDirectory.path, + kernel: kernelFile.path, + dumpDebugInfoPath: dumpDebugInfoDir?.path, + additionalArgs: splitDebugInfoArgs(splitDebugInfoPath), + ); + } catch (error) { + linkProgress.fail('Failed to link AOT files: $error'); + return (exitCode: ExitCode.software.code, linkPercentage: null); + } finally { + await dumpDebugInfo(); + } + linkProgress.complete(); + return (exitCode: ExitCode.success.code, linkPercentage: linkPercentage); + } +} diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch.dart b/packages/shorebird_cli/lib/src/commands/patch/patch.dart index ca8f935c5..d4d864698 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch.dart @@ -2,5 +2,6 @@ export 'aar_patcher.dart'; export 'android_patcher.dart'; export 'ios_framework_patcher.dart'; export 'ios_patcher.dart'; +export 'macos_patcher.dart'; export 'patch_command.dart'; export '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 cee7bc81a..1d96c6e24 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -185,6 +185,10 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl return ExitCode.usage.code; } + if (results.releaseTypes.contains(ReleaseType.macos)) { + logger.warn(macosBetaWarning); + } + final patcherFutures = results.releaseTypes.map(_resolvePatcher).map(createPatch); @@ -219,6 +223,13 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl flavor: flavor, target: target, ); + case ReleaseType.macos: + return MacosPatcher( + argParser: argParser, + argResults: results, + flavor: flavor, + target: target, + ); case ReleaseType.aar: return AarPatcher( argResults: results, diff --git a/packages/shorebird_cli/lib/src/commands/preview_command.dart b/packages/shorebird_cli/lib/src/commands/preview_command.dart index a66acffac..82c10cf2f 100644 --- a/packages/shorebird_cli/lib/src/commands/preview_command.dart +++ b/packages/shorebird_cli/lib/src/commands/preview_command.dart @@ -21,6 +21,7 @@ import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/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'; @@ -237,7 +238,11 @@ class PreviewCommand extends ShorebirdCommand { deviceId: deviceId, track: track, ), - ReleasePlatform.macos => throw UnimplementedError(), + ReleasePlatform.macos => installAndLaunchMacos( + appId: appId, + release: release, + track: track, + ), ReleasePlatform.ios => installAndLaunchIos( appId: appId, release: release, @@ -280,6 +285,65 @@ class PreviewCommand extends ShorebirdCommand { return ReleasePlatform.values.firstWhere((p) => p.displayName == platform); } + Future installAndLaunchMacos({ + required String appId, + required Release release, + required DeploymentTrack track, + }) async { + const platform = ReleasePlatform.macos; + late Directory appDirectory; + late ReleaseArtifact releaseRunnerArtifact; + + try { + releaseRunnerArtifact = await codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: 'app', + platform: platform, + ); + } 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: releaseRunnerArtifact, + platform: platform, + extension: 'app', + ), + ); + + if (!appDirectory.existsSync()) { + final downloadArtifactProgress = logger.progress('Downloading release'); + try { + if (!appDirectory.existsSync()) { + appDirectory.createSync(recursive: true); + } + + final archiveFile = await artifactManager.downloadFile( + Uri.parse(releaseRunnerArtifact.url), + ); + await ditto.extract( + source: archiveFile.path, + destination: appDirectory.path, + ); + downloadArtifactProgress.complete(); + } catch (error) { + downloadArtifactProgress.fail('$error'); + return ExitCode.software.code; + } + } + + // TODO(felangel): wrap `open` and stream logs. + final proc = await process.start('open', ['-n', appDirectory.path]); + return proc.exitCode; + } + Future installAndLaunchAndroid({ required String appId, required Release release, diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index e12aedbff..877b2c3e3 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -180,9 +180,9 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', }) async { final xcarchiveDirectory = artifactManager.getXcarchiveDirectory()!; final String? podfileLockHash; - if (shorebirdEnv.podfileLockFile.existsSync()) { + if (shorebirdEnv.iosPodfileLockFile.existsSync()) { podfileLockHash = sha256 - .convert(shorebirdEnv.podfileLockFile.readAsBytesSync()) + .convert(shorebirdEnv.iosPodfileLockFile.readAsBytesSync()) .toString(); } else { podfileLockHash = null; diff --git a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart new file mode 100644 index 000000000..4c55b20c7 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart @@ -0,0 +1,209 @@ +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_analysis/plist.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/release.dart'; +import 'package:shorebird_cli/src/doctor.dart'; +import 'package:shorebird_cli/src/executables/xcodebuild.dart'; +import 'package:shorebird_cli/src/extensions/arg_results.dart'; +import 'package:shorebird_cli/src/logging/detail_progress.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/metadata/update_release_metadata.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 macos_releaser} +/// Functions to build and publish a macOS release. +/// {@endtemplate} +class MacosReleaser extends Releaser { + /// {@macro macos_releaser} + MacosReleaser({ + required super.argResults, + required super.flavor, + required super.target, + }); + + /// Whether to codesign the release. + bool get codesign => argResults['codesign'] == true; + + @override + ReleaseType get releaseType => ReleaseType.macos; + + @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); + } + + if (argResults.rest.contains('--obfuscate')) { + // Obfuscated releases break patching, so we don't support them. + // See https://github.com/shorebirdtech/shorebird/issues/1619 + logger + ..err('Shorebird does not currently support obfuscation on macOS.') + ..info( + '''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''', + ); + throw ProcessExit(ExitCode.unavailable.code); + } + } + + @override + Future assertPreconditions() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: doctor.macosCommandValidators, + supportedOperatingSystems: {Platform.macOS}, + ); + } 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 < minimumSupportedMacosFlutterVersion) { + logger.err( + ''' +macOS releases are not supported with Flutter versions older than $minimumSupportedMacosFlutterVersion. +For more information see: ${supportedFlutterVersionsUrl.toLink()}''', + ); + throw ProcessExit(ExitCode.usage.code); + } + } + } + + @override + Future buildReleaseArtifacts() async { + if (!codesign) { + logger + ..info( + '''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''', + ) + ..warn( + '''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''', + ); + } + + final flutterVersionString = await shorebirdFlutter.getVersionAndRevision(); + final buildProgress = logger.detailProgress( + 'Building app bundle with Flutter $flutterVersionString', + ); + + try { + await artifactBuilder.buildMacos( + codesign: codesign, + flavor: flavor, + target: target, + args: argResults.forwardedArgs, + base64PublicKey: argResults.encodedPublicKey, + buildProgress: buildProgress, + ); + buildProgress.complete(); + } on ArtifactBuildException catch (error) { + buildProgress.fail(error.message); + throw ProcessExit(ExitCode.software.code); + } + + final appDirectory = artifactManager.getMacOSAppDirectory(); + if (appDirectory == null) { + logger.err('Unable to find .app directory'); + throw ProcessExit(ExitCode.software.code); + } + + return appDirectory; + } + + @override + Future getReleaseVersion({ + required FileSystemEntity releaseArtifactRoot, + }) async { + final plistFile = File( + p.join(releaseArtifactRoot.path, 'Contents', 'Info.plist'), + ); + if (!plistFile.existsSync()) { + logger.err('No Info.plist file found at ${plistFile.path}'); + throw ProcessExit(ExitCode.software.code); + } + + try { + return Plist(file: plistFile).versionNumber; + } catch (error) { + logger.err( + '''Failed to determine release version from ${plistFile.path}: $error''', + ); + throw ProcessExit(ExitCode.software.code); + } + } + + @override + Future uploadReleaseArtifacts({ + required Release release, + required String appId, + }) async { + final appDirectory = artifactManager.getMacOSAppDirectory(); + if (appDirectory == null) { + logger.err('Unable to find .app directory'); + throw ProcessExit(ExitCode.software.code); + } + final supplementDirectory = + artifactManager.getMacosReleaseSupplementDirectory(); + if (supplementDirectory == null) { + logger.err('Unable to find supplement directory'); + throw ProcessExit(ExitCode.software.code); + } + + final String? podfileLockHash; + if (shorebirdEnv.macosPodfileLockFile.existsSync()) { + podfileLockHash = sha256 + .convert(shorebirdEnv.macosPodfileLockFile.readAsBytesSync()) + .toString(); + } else { + podfileLockHash = null; + } + await codePushClientWrapper.createMacosReleaseArtifacts( + appId: appId, + releaseId: release.id, + appPath: appDirectory.path, + isCodesigned: codesign, + podfileLockHash: podfileLockHash, + supplementPath: supplementDirectory.path, + ); + } + + @override + Future updatedReleaseMetadata( + UpdateReleaseMetadata metadata, + ) async => + metadata.copyWith( + environment: metadata.environment.copyWith( + xcodeVersion: await xcodeBuild.version(), + ), + ); + + @override + String get postReleaseInstructions => ''' + +macOS app created at ${artifactManager.getMacOSAppDirectory()!.path}. +'''; +} diff --git a/packages/shorebird_cli/lib/src/commands/release/release.dart b/packages/shorebird_cli/lib/src/commands/release/release.dart index 9ca9dd96c..d26b2cf06 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release.dart @@ -2,5 +2,6 @@ export 'aar_releaser.dart'; export 'android_releaser.dart'; export 'ios_framework_releaser.dart'; export 'ios_releaser.dart'; +export 'macos_releaser.dart'; export 'release_command.dart'; export '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 aed3fdc7b..a6e3bc490 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -156,6 +156,10 @@ of the iOS app that is using this module. (aar and ios-framework only)''', return ExitCode.usage.code; } + if (results.releaseTypes.contains(ReleaseType.macos)) { + logger.warn(macosBetaWarning); + } + final releaserFutures = results.releaseTypes.map(_resolveReleaser).map(createRelease); @@ -182,6 +186,12 @@ of the iOS app that is using this module. (aar and ios-framework only)''', flavor: flavor, target: target, ); + case ReleaseType.macos: + return MacosReleaser( + argResults: results, + flavor: flavor, + target: target, + ); case ReleaseType.iosFramework: return IosFrameworkReleaser( argResults: results, diff --git a/packages/shorebird_cli/lib/src/doctor.dart b/packages/shorebird_cli/lib/src/doctor.dart index 5159cffb5..f092b8642 100644 --- a/packages/shorebird_cli/lib/src/doctor.dart +++ b/packages/shorebird_cli/lib/src/doctor.dart @@ -24,6 +24,11 @@ class Doctor { /// Validators that verify shorebird will work on iOS. final List iosCommandValidators = []; + /// Validators that verify shorebird will work on macOS. + final List macosCommandValidators = [ + // TODO(bryanoltman): ensure app has network capabilities + ]; + /// Validators that should run on all commands. List generalValidators = [ ShorebirdVersionValidator(), diff --git a/packages/shorebird_cli/lib/src/executables/ditto.dart b/packages/shorebird_cli/lib/src/executables/ditto.dart new file mode 100644 index 000000000..e63619d75 --- /dev/null +++ b/packages/shorebird_cli/lib/src/executables/ditto.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; + +/// A reference to a [Ditto] instance. +final dittoRef = create(Ditto.new); + +/// The [Ditto] instance available in the current zone. +Ditto get ditto => read(dittoRef); + +/// A wrapper around the `ditto` command. +/// https://ss64.com/mac/ditto.html +class Ditto { + Future _exec(String command) async { + return process.run('ditto', command.split(' ')); + } + + /// Extracts the contents of a compressed archive at [source] to + /// [destination]. + Future extract({ + required String source, + required String destination, + }) async { + final result = await _exec('-x -k $source $destination'); + if (result.exitCode != 0) { + throw Exception('Failed to extract: ${result.stderr}'); + } + } + + /// Archives the contents of [source] to a compressed archive at + /// [destination]. + Future archive({ + required String source, + required String destination, + bool keepParent = false, + }) async { + final args = [ + '-c', + '-k', + if (keepParent) '--keepParent', + source, + destination, + ]; + final result = await _exec(args.join(' ')); + if (result.exitCode != 0) { + throw Exception('Failed to archive: ${result.stderr}'); + } + } +} diff --git a/packages/shorebird_cli/lib/src/executables/executables.dart b/packages/shorebird_cli/lib/src/executables/executables.dart index 60cfe7e6d..9814dc8e3 100644 --- a/packages/shorebird_cli/lib/src/executables/executables.dart +++ b/packages/shorebird_cli/lib/src/executables/executables.dart @@ -4,6 +4,7 @@ export 'adb.dart'; export 'aot_tools.dart'; export 'bundletool.dart'; export 'devicectl/devicectl.dart'; +export 'ditto.dart'; export 'git.dart'; export 'gradlew.dart'; export 'idevicesyslog.dart'; diff --git a/packages/shorebird_cli/lib/src/platform/macos.dart b/packages/shorebird_cli/lib/src/platform/macos.dart new file mode 100644 index 000000000..7af68a4ea --- /dev/null +++ b/packages/shorebird_cli/lib/src/platform/macos.dart @@ -0,0 +1,11 @@ +import 'package:pub_semver/pub_semver.dart'; + +/// The minimum allowed Flutter version for creating macOS releases. +final minimumSupportedMacosFlutterVersion = Version(3, 27, 0); + +/// A warning message printed at the start of `shorebird release macos` and +/// `shorebird patch macos` commands. +const macosBetaWarning = ''' +macOS support is currently in beta. +Please report issues at https://github.com/shorebirdtech/shorebird/issues/new +'''; diff --git a/packages/shorebird_cli/lib/src/platform/platform.dart b/packages/shorebird_cli/lib/src/platform/platform.dart index 473db70e7..3ab7020e4 100644 --- a/packages/shorebird_cli/lib/src/platform/platform.dart +++ b/packages/shorebird_cli/lib/src/platform/platform.dart @@ -1,5 +1,6 @@ export 'android.dart'; export 'ios.dart'; +export 'macos.dart'; /// {@template arch} /// Build architectures supported by Shorebird. diff --git a/packages/shorebird_cli/lib/src/release_type.dart b/packages/shorebird_cli/lib/src/release_type.dart index dbf8a42cf..570d1e2ac 100644 --- a/packages/shorebird_cli/lib/src/release_type.dart +++ b/packages/shorebird_cli/lib/src/release_type.dart @@ -15,6 +15,9 @@ enum ReleaseType { /// A full Flutter iOS app. ios, + /// A full Flutter macOS app. + macos, + /// An iOS framework used in a hybrid app. iosFramework; @@ -27,6 +30,8 @@ enum ReleaseType { return 'ios'; case ReleaseType.iosFramework: return 'ios-framework'; + case ReleaseType.macos: + return 'macos'; case ReleaseType.aar: return 'aar'; } @@ -41,6 +46,8 @@ enum ReleaseType { return ReleasePlatform.android; case ReleaseType.ios: return ReleasePlatform.ios; + case ReleaseType.macos: + return ReleasePlatform.macos; case ReleaseType.iosFramework: return ReleasePlatform.ios; } diff --git a/packages/shorebird_cli/lib/src/shorebird_artifacts.dart b/packages/shorebird_cli/lib/src/shorebird_artifacts.dart index 23f702a1e..1b22c02e7 100644 --- a/packages/shorebird_cli/lib/src/shorebird_artifacts.dart +++ b/packages/shorebird_cli/lib/src/shorebird_artifacts.dart @@ -10,14 +10,20 @@ import 'package:shorebird_cli/src/shorebird_env.dart'; /// All Shorebird artifacts used explicitly by Shorebird. enum ShorebirdArtifact { - /// The analyze_snapshot executable. - analyzeSnapshot, + /// The iOS analyze_snapshot executable. + analyzeSnapshotIos, + + /// The macOS analyze_snapshot executable. + analyzeSnapshotMacOS, /// The aot_tools executable or kernel file. aotTools, - /// The gen_snapshot executable. - genSnapshot, + /// The gen_snapshot executable for iOS. + genSnapshotIos, + + /// The gen_snapshot executable for macOS. + genSnapshotMacOS, } /// A reference to a [ShorebirdArtifacts] instance. @@ -48,16 +54,20 @@ class ShorebirdCachedArtifacts implements ShorebirdArtifacts { required ShorebirdArtifact artifact, }) { switch (artifact) { - case ShorebirdArtifact.analyzeSnapshot: - return _analyzeSnapshotFile.path; + case ShorebirdArtifact.analyzeSnapshotIos: + return _analyzeSnapshotIosFile.path; + case ShorebirdArtifact.analyzeSnapshotMacOS: + return _analyzeSnapshotMacosFile.path; case ShorebirdArtifact.aotTools: return _aotToolsFile.path; - case ShorebirdArtifact.genSnapshot: - return _genSnapshotFile.path; + case ShorebirdArtifact.genSnapshotIos: + return _genSnapshotIosFile.path; + case ShorebirdArtifact.genSnapshotMacOS: + return _genSnapshotMacOSFile.path; } } - File get _analyzeSnapshotFile { + File get _analyzeSnapshotIosFile { return File( p.join( shorebirdEnv.flutterDirectory.path, @@ -71,6 +81,20 @@ class ShorebirdCachedArtifacts implements ShorebirdArtifacts { ); } + File get _analyzeSnapshotMacosFile { + return File( + p.join( + shorebirdEnv.flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'darwin-x64-release', + 'analyze_snapshot', + ), + ); + } + File get _aotToolsFile { const executableName = 'aot-tools'; final kernelFile = File( @@ -95,7 +119,7 @@ class ShorebirdCachedArtifacts implements ShorebirdArtifacts { ); } - File get _genSnapshotFile { + File get _genSnapshotIosFile { return File( p.join( shorebirdEnv.flutterDirectory.path, @@ -104,7 +128,21 @@ class ShorebirdCachedArtifacts implements ShorebirdArtifacts { 'artifacts', 'engine', 'ios-release', - 'gen_snapshot_arm64', + 'gen_snapshot', + ), + ); + } + + File get _genSnapshotMacOSFile { + return File( + p.join( + shorebirdEnv.flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'darwin-x64-release', + 'gen_snapshot', ), ); } @@ -120,16 +158,20 @@ class ShorebirdLocalEngineArtifacts implements ShorebirdArtifacts { @override String getArtifactPath({required ShorebirdArtifact artifact}) { switch (artifact) { - case ShorebirdArtifact.analyzeSnapshot: - return _analyzeSnapshotFile.path; + case ShorebirdArtifact.analyzeSnapshotIos: + return _analyzeSnapshotIosFile.path; + case ShorebirdArtifact.analyzeSnapshotMacOS: + return _analyzeSnapshotMacosFile.path; case ShorebirdArtifact.aotTools: return _aotToolsFile.path; - case ShorebirdArtifact.genSnapshot: - return _genSnapshotFile.path; + case ShorebirdArtifact.genSnapshotIos: + return _genSnapshotIosFile.path; + case ShorebirdArtifact.genSnapshotMacOS: + return _genSnapshotMacosFile.path; } } - File get _analyzeSnapshotFile { + File get _analyzeSnapshotIosFile { return File( p.join( engineConfig.localEngineSrcPath!, @@ -141,6 +183,18 @@ class ShorebirdLocalEngineArtifacts implements ShorebirdArtifacts { ); } + File get _analyzeSnapshotMacosFile { + return File( + p.join( + engineConfig.localEngineSrcPath!, + 'out', + engineConfig.localEngine, + 'clang_x64', + 'analyze_snapshot', + ), + ); + } + File get _aotToolsFile { return File( p.join( @@ -156,7 +210,7 @@ class ShorebirdLocalEngineArtifacts implements ShorebirdArtifacts { ); } - File get _genSnapshotFile { + File get _genSnapshotIosFile { return File( p.join( engineConfig.localEngineSrcPath!, @@ -167,4 +221,16 @@ class ShorebirdLocalEngineArtifacts implements ShorebirdArtifacts { ), ); } + + File get _genSnapshotMacosFile { + return File( + p.join( + engineConfig.localEngineSrcPath!, + 'out', + engineConfig.localEngine, + 'clang_x64', + 'gen_snapshot', + ), + ); + } } diff --git a/packages/shorebird_cli/lib/src/shorebird_env.dart b/packages/shorebird_cli/lib/src/shorebird_env.dart index 0094c5a4e..0aa60f9ef 100644 --- a/packages/shorebird_cli/lib/src/shorebird_env.dart +++ b/packages/shorebird_cli/lib/src/shorebird_env.dart @@ -102,10 +102,16 @@ class ShorebirdEnv { return File(p.join(flutterDirectory.path, 'bin', 'dart')); } - File get podfileLockFile { + /// The Cocoapods lockfile for this project's iOS app. + File get iosPodfileLockFile { return File(p.join(getFlutterProjectRoot()!.path, 'ios', 'Podfile.lock')); } + /// The Cocoapods lockfile for this project's macOS app. + File get macosPodfileLockFile { + return File(p.join(getFlutterProjectRoot()!.path, 'macos', 'Podfile.lock')); + } + /// The `shorebird.yaml` file for this project. File getShorebirdYamlFile({required Directory cwd}) { return File(p.join(cwd.path, 'shorebird.yaml')); diff --git a/packages/shorebird_cli/pubspec.lock b/packages/shorebird_cli/pubspec.lock index 1ea3e307f..55d25c35e 100644 --- a/packages/shorebird_cli/pubspec.lock +++ b/packages/shorebird_cli/pubspec.lock @@ -154,10 +154,10 @@ packages: dependency: transitive description: name: characters - sha256: "81269c8d3f45541082bfbb117bbc962cfc68b5197eb4c705a00db4ddf394e1c1" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" checked_yaml: dependency: "direct main" description: @@ -481,10 +481,10 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" path: dependency: "direct main" description: @@ -679,18 +679,18 @@ packages: dependency: transitive description: name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted - version: "0.10.12" + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" sprintf: dependency: transitive description: diff --git a/packages/shorebird_cli/test/src/artifact_builder_test.dart b/packages/shorebird_cli/test/src/artifact_builder_test.dart index 484b3bad0..347cd8d8d 100644 --- a/packages/shorebird_cli/test/src/artifact_builder_test.dart +++ b/packages/shorebird_cli/test/src/artifact_builder_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; 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/logging/logging.dart'; @@ -29,6 +30,7 @@ void main() { }); group(ArtifactBuilder, () { + final projectRoot = Directory.systemTemp.createTempSync(); late Ios ios; late ShorebirdLogger logger; late OperatingSystemInterface operatingSystemInterface; @@ -122,7 +124,8 @@ void main() { when(() => operatingSystemInterface.which('flutter')) .thenReturn('/path/to/flutter'); when(() => shorebirdEnv.flutterRevision).thenReturn('1234'); - when(shorebirdEnv.getShorebirdProjectRoot).thenReturn(Directory('')); + + when(shorebirdEnv.getShorebirdProjectRoot).thenReturn(projectRoot); builder = ArtifactBuilder(); }); @@ -702,6 +705,220 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod }); }); + group( + 'buildMacos', + () { + setUp(() { + when(() => buildProcess.stdout).thenAnswer( + (_) => Stream.fromIterable( + [ + ''' +[ ] [ +1 ms] targetingApplePlatform = true +[ ] [ ] extractAppleDebugSymbols = true +[ ] [ ] Will strip AOT snapshot manually after build and dSYM generation. +[ ] [ ] executing: /Users/bryanoltman/shorebirdtech/_shorebird/shorebird/bin/cache/flutter/b1fabdf140ab5591c45dbea4196dc3c018a4ed3a/bin/cache/artifacts/engine/darwin-x64-release/gen_snapshot_arm64 --deterministic --print_class_table_link_debug_info_to=/Users/bryanoltman/Documents/sandbox/macos_sandbox/.dart_tool/flutter_build/f9149091b9c399e05076c18d6b754a0f/App.class_table.json --print_class_table_link_info_to=/Users/bryanoltman/Documents/sandbox/macos_sandbox/.dart_tool/flutter_build/f9149091b9c399e05076c18d6b754a0f/App.ct.link --snapshot_kind=app-aot-assembly --assembly=/Users/bryanoltman/Documents/sandbox/macos_sandbox/.dart_tool/flutter_build/f9149091b9c399e05076c18d6b754a0f/arm64/snapshot_assembly.S /path/to/app.dill +[ ] [ ] targetingApplePlatform = true +[ ] [ ] extractAppleDebugSymbols = true +[ ] [ ] Will strip AOT snapshot manually after build and dSYM generation. +[+5214 ms] [ ] executing: /Users/bryanoltman/shorebirdtech/_shorebird/shorebird/bin/cache/flutter/b1fabdf140ab5591c45dbea4196dc3c018a4ed3a/bin/cache/artifacts/engine/darwin-x64-release/gen_snapshot_x64 --deterministic --print_class_table_link_debug_info_to=/Users/bryanoltman/Documents/sandbox/macos_sandbox/.dart_tool/flutter_build/f9149091b9c399e05076c18d6b754a0f/App.class_table.json --print_class_table_link_info_to=/Users/bryanoltman/Documents/sandbox/macos_sandbox/.dart_tool/flutter_build/f9149091b9c399e05076c18d6b754a0f/App.ct.link --snapshot_kind=app-aot-assembly --assembly=/Users/bryanoltman/Documents/sandbox/macos_sandbox/.dart_tool/flutter_build/f9149091b9c399e05076c18d6b754a0f/x86_64/snapshot_assembly.S /path/to/app.dill +[ ] [+3527 ms] Building App.framework for x86_64... +[ ] [ +6 ms] executing: sysctl hw.optional.arm64 +''', + ].map(utf8.encode), + ), + ); + }); + + group('when .dart_tool directory exists', () { + late Directory dartToolDir; + + setUp(() { + dartToolDir = Directory( + p.join(projectRoot.path, '.dart_tool'), + )..createSync(recursive: true); + }); + + test('deletes .dart_tool directory before building', () async { + expect(dartToolDir.existsSync(), isTrue); + await runWithOverrides(builder.buildMacos); + expect(dartToolDir.existsSync(), isFalse); + }); + }); + + group('with default arguments', () { + test('invokes flutter build with an export options plist', () async { + final result = await runWithOverrides(builder.buildMacos); + + verify( + () => shorebirdProcess.start( + 'flutter', + [ + 'build', + 'macos', + '--release', + ], + runInShell: true, + environment: any(named: 'environment'), + ), + ).called(1); + expect(result.kernelFile.path, equals('/path/to/app.dill')); + }); + }); + + group('when base64PublicKey is not null', () { + const base64PublicKey = 'base64PublicKey'; + + setUp(() { + when( + () => shorebirdProcess.start( + 'flutter', + [ + 'build', + 'macos', + '--release', + ], + runInShell: any(named: 'runInShell'), + environment: { + 'SHOREBIRD_PUBLIC_KEY': base64PublicKey, + }, + ), + ).thenAnswer((_) async => buildProcess); + }); + + test('adds the SHOREBIRD_PUBLIC_KEY to the environment', () async { + await runWithOverrides( + () => builder.buildMacos( + base64PublicKey: base64PublicKey, + ), + ); + + verify( + () => shorebirdProcess.start( + 'flutter', + [ + 'build', + 'macos', + '--release', + ], + runInShell: any(named: 'runInShell'), + environment: { + 'SHOREBIRD_PUBLIC_KEY': base64PublicKey, + }, + ), + ).called(1); + }); + }); + + test('forwards extra arguments to flutter build', () async { + await runWithOverrides( + () => builder.buildMacos( + codesign: false, + flavor: 'flavor', + target: 'target.dart', + args: ['--foo', 'bar'], + ), + ); + + verify( + () => shorebirdProcess.start( + 'flutter', + [ + 'build', + 'macos', + '--release', + '--flavor=flavor', + '--target=target.dart', + '--no-codesign', + '--foo', + 'bar', + ], + runInShell: any(named: 'runInShell'), + ), + ).called(1); + }); + + group('when the build fails', () { + group('with non-zero exit code', () { + setUp(() { + when(() => buildProcess.exitCode) + .thenAnswer((_) async => ExitCode.software.code); + }); + + test('throws ArtifactBuildException', () { + expect( + () => runWithOverrides( + () => builder.buildMacos(codesign: false), + ), + throwsA(isA()), + ); + }); + }); + }); + + group('when an app.dill file is not found in build stdout', () { + setUp(() { + when(() => buildProcess.stdout).thenAnswer( + (_) => Stream.fromIterable( + [ + 'no app.dill', + ].map(utf8.encode), + ), + ); + }); + + test('throws ArtifactBuildException', () { + expect( + () => runWithOverrides(() => builder.buildMacos(codesign: false)), + throwsA( + isA().having( + (e) => e.message, + 'message', + ''' +Unable to find app.dill file. +Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with the logs for this command. +''', + ), + ), + ); + }); + }); + + group('after a build', () { + group('when the build is successful', () { + setUp(() { + when( + () => buildProcess.exitCode, + ).thenAnswer((_) async => ExitCode.success.code); + }); + + verifyCorrectFlutterPubGet( + () async => runWithOverrides( + () => builder.buildMacos(codesign: false), + ), + ); + + group('when the build fails', () { + setUp(() { + when( + () => buildProcess.exitCode, + ).thenAnswer((_) async => ExitCode.software.code); + }); + + verifyCorrectFlutterPubGet( + () async => expectLater( + () async => runWithOverrides( + () => builder.buildMacos(codesign: false), + ), + throwsA(isA()), + ), + ); + }); + }); + }); + }, + testOn: 'mac-os', + ); + group( 'buildIpa', () { @@ -1153,7 +1370,7 @@ Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with setUp(() { when( () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, + artifact: ShorebirdArtifact.genSnapshotIos, ), ).thenReturn('gen_snapshot'); }); @@ -1163,6 +1380,7 @@ Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with () => builder.buildElfAotSnapshot( appDillPath: '/app/dill/path', outFilePath: '/path/to/out', + genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos, additionalArgs: ['--foo', 'bar'], ), ); @@ -1195,6 +1413,7 @@ Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with () => builder.buildElfAotSnapshot( appDillPath: 'asdf', outFilePath: 'asdf', + genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos, ), ), throwsA(isA()), @@ -1208,6 +1427,7 @@ Please file a bug at https://github.com/shorebirdtech/shorebird/issues/new with () => builder.buildElfAotSnapshot( appDillPath: '/app/dill/path', outFilePath: '/path/to/out', + genSnapshotArtifact: ShorebirdArtifact.genSnapshotIos, ), ); diff --git a/packages/shorebird_cli/test/src/artifact_manager_test.dart b/packages/shorebird_cli/test/src/artifact_manager_test.dart index 2ec6ca055..28014203b 100644 --- a/packages/shorebird_cli/test/src/artifact_manager_test.dart +++ b/packages/shorebird_cli/test/src/artifact_manager_test.dart @@ -65,27 +65,20 @@ void main() { (_) async => http.StreamedResponse(const Stream.empty(), HttpStatus.ok), ); - when(() => shorebirdEnv.getShorebirdProjectRoot()) - .thenReturn(projectRoot); + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); artifactManager = ArtifactManager(); patchExecutable = MockPatchExecutable(); when( () => patchExecutable.run( - releaseArtifactPath: any( - named: 'releaseArtifactPath', - ), - patchArtifactPath: any( - named: 'patchArtifactPath', - ), - diffPath: any( - named: 'diffPath', - ), + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + diffPath: any(named: 'diffPath'), ), - ).thenAnswer( - (_) async {}, - ); + ).thenAnswer((_) async {}); }); group('createDiff', () { @@ -94,10 +87,12 @@ void main() { setUp(() { final tmpDir = Directory.systemTemp.createTempSync(); - releaseArtifactFile = File(p.join(tmpDir.path, 'release_artifact')) - ..createSync(recursive: true); - patchArtifactFile = File(p.join(tmpDir.path, 'patch_artifact')) - ..createSync(recursive: true); + releaseArtifactFile = File( + p.join(tmpDir.path, 'release_artifact'), + )..createSync(recursive: true); + patchArtifactFile = File( + p.join(tmpDir.path, 'patch_artifact'), + )..createSync(recursive: true); }); test('throws error when release artifact file does not exist', () async { @@ -663,6 +658,107 @@ void main() { }); }); + group('getMacOSAppDirectory', () { + group('when .app directory exists', () { + late Directory archiveDirectory; + + setUp(() { + archiveDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + ), + )..createSync(recursive: true); + }); + + test('returns path to app directory', () async { + final result = runWithOverrides( + () => artifactManager.getMacOSAppDirectory(), + ); + + expect(result!.path, equals(archiveDirectory.path)); + }); + }); + + group( + 'when multiple .app directories exist', + () { + late Directory oldAppDirectory; + late Directory newAppDirectory; + + setUp(() async { + oldAppDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + ), + )..createSync(recursive: true); + // Wait to ensure the new app directory is created after the old + // app directory. + await Future.delayed(const Duration(milliseconds: 50)); + newAppDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner2.app', + ), + )..createSync(recursive: true); + }); + + test('selects the most recently updated app', () async { + final firstResult = runWithOverrides( + artifactManager.getMacOSAppDirectory, + ); + // The new app directory should be selected because it was + // created after the old app directory. + expect(firstResult!.path, equals(newAppDirectory.path)); + + // Now recreate the old app directory and ensure it is selected. + oldAppDirectory.deleteSync(recursive: true); + oldAppDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + ), + )..createSync(recursive: true); + final secondResult = runWithOverrides( + artifactManager.getMacOSAppDirectory, + ); + expect(secondResult!.path, equals(oldAppDirectory.path)); + }); + }, + testOn: 'mac-os', + ); + + group('when app directory does not exist', () { + test('returns null', () { + expect( + runWithOverrides(artifactManager.getMacOSAppDirectory), + isNull, + ); + }); + }); + }); + group('getIosAppDirectory', () { group('when applications directory does not exist', () { test('returns null', () { @@ -846,5 +942,47 @@ void main() { }); }); }); + + group('getMacosReleaseSupplementDirectory', () { + group('when the directory does not exist', () { + test('returns null', () { + expect( + runWithOverrides( + artifactManager.getMacosReleaseSupplementDirectory, + ), + isNull, + ); + }); + }); + + group('when the directory exists', () { + setUp(() { + Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'shorebird', + ), + ).createSync(recursive: true); + }); + + test('returns path to the directory', () { + expect( + runWithOverrides( + artifactManager.getMacosReleaseSupplementDirectory, + )?.path, + equals( + p.join( + projectRoot.path, + 'build', + 'macos', + 'shorebird', + ), + ), + ); + }); + }); + }); }); } 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 1df996601..215d845eb 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 @@ -10,6 +10,7 @@ import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/deployment_track.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'; @@ -57,9 +58,7 @@ void main() { () => shorebirdFlutter.getVersionForRevision( flutterRevision: any(named: 'flutterRevision'), ), - ).thenAnswer( - (_) async => '3.22.0', - ); + ).thenAnswer((_) async => '3.22.0'); }); test('creates correct instance from environment', () async { @@ -157,6 +156,7 @@ void main() { ); late CodePushClient codePushClient; + late Ditto ditto; late ShorebirdLogger logger; late ShorebirdFlutter shorebirdFlutter; late Progress progress; @@ -168,6 +168,7 @@ void main() { return runScoped( body, values: { + dittoRef.overrideWith(() => ditto), loggerRef.overrideWith(() => logger), platformRef.overrideWith(() => platform), shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), @@ -182,6 +183,7 @@ void main() { setUp(() { codePushClient = MockCodePushClient(); + ditto = MockDitto(); logger = MockShorebirdLogger(); platform = MockPlatform(); progress = MockProgress(); @@ -193,6 +195,12 @@ void main() { shorebirdFlutter = MockShorebirdFlutter(); + when( + () => ditto.archive( + source: any(named: 'source'), + destination: any(named: 'destination'), + ), + ).thenAnswer((_) async {}); when(() => logger.progress(any())).thenReturn(progress); when(() => platform.script).thenReturn( Uri.file( @@ -1935,6 +1943,126 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' }); }); + group('createMacosReleaseArtifacts', () { + final appPath = p.join('path', 'to', 'Runner.app'); + final releaseSupplementPath = p.join('path', 'to', 'supplement'); + + void setUpProjectRoot({String? flavor}) { + Directory( + p.join(projectRoot.path, appPath), + ).createSync(recursive: true); + Directory( + p.join(projectRoot.path, releaseSupplementPath), + ).createSync(recursive: true); + } + + setUp(() { + when( + () => codePushClient.createReleaseArtifact( + appId: any(named: 'appId'), + artifactPath: any(named: 'artifactPath'), + 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 {}); + when( + () => ditto.archive( + source: any(named: 'source'), + destination: any(named: 'destination'), + ), + ).thenAnswer((invocation) async { + final destination = invocation.namedArguments[#destination] as String; + File(destination).createSync(recursive: true); + }); + setUpProjectRoot(); + }); + + test('exits with code 70 when creating app artifact fails', () async { + 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('oh no')); + + await expectLater( + () async => runWithOverrides( + () => codePushClientWrapper.createMacosReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + appPath: p.join(projectRoot.path, appPath), + isCodesigned: false, + supplementPath: p.join(projectRoot.path, releaseSupplementPath), + podfileLockHash: null, + ), + ), + exitsWithCode(ExitCode.software), + ); + }); + + test('exits with code 70 when supplement artifact creation fails', + () async { + const error = 'something went wrong'; + when( + () => codePushClient.createReleaseArtifact( + appId: any(named: 'appId'), + artifactPath: any( + named: 'artifactPath', + that: endsWith('macos_supplement.zip'), + ), + 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(error); + + await expectLater( + () async => runWithOverrides( + () async => codePushClientWrapper.createMacosReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + appPath: p.join(projectRoot.path, appPath), + supplementPath: p.join(projectRoot.path, releaseSupplementPath), + isCodesigned: false, + podfileLockHash: null, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail(any(that: contains(error)))).called(1); + }); + + test('completes successfully when release artifact is created', () async { + await expectLater( + runWithOverrides( + () => codePushClientWrapper.createMacosReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + appPath: p.join(projectRoot.path, appPath), + supplementPath: p.join(projectRoot.path, releaseSupplementPath), + isCodesigned: false, + podfileLockHash: null, + ), + ), + completes, + ); + }); + }); + group('createIosFrameworkReleaseArtifacts', () { final frameworkPath = p.join('path', 'to', 'App.xcframework'); final releaseSupplementPath = p.join('path', 'to', 'supplement'); diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart index 795836216..1eece923c 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_framework_patcher_test.dart @@ -92,6 +92,7 @@ void main() { registerFallbackValue(File('')); registerFallbackValue(const IosArchiveDiffer()); registerFallbackValue(ReleasePlatform.ios); + registerFallbackValue(ShorebirdArtifact.genSnapshotIos); registerFallbackValue(Uri.parse('https://example.com')); }); @@ -366,6 +367,7 @@ void main() { () => artifactBuilder.buildElfAotSnapshot( appDillPath: any(named: 'appDillPath'), outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), additionalArgs: any(named: 'additionalArgs'), ), ).thenThrow(const FileSystemException('error')); @@ -401,6 +403,7 @@ void main() { () => artifactBuilder.buildElfAotSnapshot( appDillPath: any(named: 'appDillPath'), outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), additionalArgs: any(named: 'additionalArgs'), ), ).thenAnswer( @@ -442,6 +445,7 @@ void main() { () => artifactBuilder.buildElfAotSnapshot( appDillPath: any(named: 'appDillPath'), outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), additionalArgs: [ '--dwarf-stack-traces', '--resolve-dwarf-paths', @@ -644,12 +648,12 @@ void main() { ).thenReturn(postLinkerFlutterRevision); when( () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ), ).thenReturn(analyzeSnapshotFile.path); when( () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, + artifact: ShorebirdArtifact.genSnapshotIos, ), ).thenReturn(genSnapshotFile.path); }); @@ -680,7 +684,7 @@ void main() { setUp(() { when( () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ), ).thenReturn(''); setUpProjectRootArtifacts(); diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart index cf98e0266..915035461 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart @@ -103,6 +103,7 @@ void main() { registerFallbackValue(File('')); registerFallbackValue(const IosArchiveDiffer()); registerFallbackValue(ReleasePlatform.ios); + registerFallbackValue(ShorebirdArtifact.genSnapshotIos); registerFallbackValue(Uri.parse('https://example.com')); }); @@ -342,7 +343,7 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync(podfileLockContents); - when(() => shorebirdEnv.podfileLockFile) + when(() => shorebirdEnv.iosPodfileLockFile) .thenReturn(podfileLockFile); }); @@ -609,6 +610,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', () => artifactBuilder.buildElfAotSnapshot( appDillPath: any(named: 'appDillPath'), outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), ), ).thenThrow(const FileSystemException('error')); }); @@ -659,6 +661,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', () => artifactBuilder.buildElfAotSnapshot( appDillPath: any(named: 'appDillPath'), outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), additionalArgs: any(named: 'additionalArgs'), ), ).thenAnswer( @@ -693,6 +696,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', () => artifactBuilder.buildElfAotSnapshot( appDillPath: any(named: 'appDillPath'), outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), additionalArgs: [ '--dwarf-stack-traces', '--resolve-dwarf-paths', @@ -1043,12 +1047,12 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', ).thenReturn(postLinkerFlutterRevision); when( () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ), ).thenReturn(analyzeSnapshotFile.path); when( () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, + artifact: ShorebirdArtifact.genSnapshotIos, ), ).thenReturn(genSnapshotFile.path); }); @@ -1112,7 +1116,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', setUp(() { when( () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ), ).thenReturn(''); setUpProjectRootArtifacts(); diff --git a/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart new file mode 100644 index 000000000..16b069280 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart @@ -0,0 +1,1592 @@ +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/code_signer.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; +import 'package:shorebird_cli/src/config/config.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/shorebird_logger.dart'; +import 'package:shorebird_cli/src/metadata/metadata.dart'; +import 'package:shorebird_cli/src/os/operating_system_interface.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_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_cli/src/version.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../helpers.dart'; +import '../../matchers.dart'; +import '../../mocks.dart'; + +void main() { + group( + MacosPatcher, + () { + late AotTools aotTools; + late ArgParser argParser; + late ArgResults argResults; + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + late CodeSigner codeSigner; + late Ditto ditto; + late Doctor doctor; + late EngineConfig engineConfig; + late Directory flutterDirectory; + late Directory projectRoot; + late Directory appDirectory; + late ShorebirdLogger logger; + late OperatingSystemInterface operatingSystemInterface; + // late PatchDiffChecker patchDiffChecker; + late Progress progress; + late ShorebirdArtifacts shorebirdArtifacts; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late XcodeBuild xcodeBuild; + late MacosPatcher patcher; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + aotToolsRef.overrideWith(() => aotTools), + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + codeSignerRef.overrideWith(() => codeSigner), + dittoRef.overrideWith(() => ditto), + doctorRef.overrideWith(() => doctor), + engineConfigRef.overrideWith(() => engineConfig), + loggerRef.overrideWith(() => logger), + osInterfaceRef.overrideWith(() => operatingSystemInterface), + // patchDiffCheckerRef.overrideWith(() => patchDiffChecker), + processRef.overrideWith(() => shorebirdProcess), + shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + xcodeBuildRef.overrideWith(() => xcodeBuild), + }, + ); + } + + setUpAll(() { + registerFallbackValue(Directory('')); + registerFallbackValue(File('')); + registerFallbackValue(ReleasePlatform.macos); + registerFallbackValue(ShorebirdArtifact.genSnapshotMacOS); + registerFallbackValue(Uri.parse('https://example.com')); + }); + + setUp(() { + aotTools = MockAotTools(); + argParser = MockArgParser(); + argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + ditto = MockDitto(); + codeSigner = MockCodeSigner(); + doctor = MockDoctor(); + engineConfig = MockEngineConfig(); + operatingSystemInterface = MockOperatingSystemInterface(); + // patchDiffChecker = MockPatchDiffChecker(); + progress = MockProgress(); + projectRoot = Directory.systemTemp.createTempSync(); + logger = MockShorebirdLogger(); + shorebirdArtifacts = MockShorebirdArtifacts(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + xcodeBuild = MockXcodeBuild(); + + when(() => argParser.options).thenReturn({}); + + when(() => argResults.options).thenReturn([]); + when(() => argResults.rest).thenReturn([]); + when(() => argResults.wasParsed(any())).thenReturn(false); + + when( + () => ditto.archive( + source: any(named: 'source'), + destination: any(named: 'destination'), + keepParent: any(named: 'keepParent'), + ), + ).thenAnswer((_) async {}); + when( + () => ditto.extract( + source: any(named: 'source'), + destination: any(named: 'destination'), + ), + ).thenAnswer((_) async {}); + + when(() => logger.progress(any())).thenReturn(progress); + + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + + when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => false); + + appDirectory = Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'my.app', + ), + )..createSync(recursive: true); + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(appDirectory); + + patcher = MacosPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + }); + + group('primaryReleaseArtifactArch', () { + test('is "app"', () { + expect(patcher.primaryReleaseArtifactArch, 'app'); + }); + }); + + group('supplementaryReleaseArtifactArch', () { + test('is "macos_supplement"', () { + expect(patcher.supplementaryReleaseArtifactArch, 'macos_supplement'); + }); + }); + + group('releaseType', () { + test('is ReleaseType.macos', () { + expect(patcher.releaseType, ReleaseType.macos); + }); + }); + + group('linkPercentage', () { + group('when linking has not occurred', () { + test('returns null', () { + expect(patcher.linkPercentage, isNull); + }); + }); + + group('when linking has occurred', () { + const linkPercentage = 42.1337; + + setUp(() { + patcher.lastBuildLinkPercentage = linkPercentage; + }); + + test('returns correct link percentage', () { + expect(patcher.linkPercentage, equals(linkPercentage)); + }); + }); + }); + + group('assertPreconditions', () { + setUp(() { + when( + () => doctor.macosCommandValidators, + ).thenReturn([flutterValidator]); + }); + + 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: [flutterValidator], + supportedOperatingSystems: {Platform.macOS}, + ), + ).called(1); + }); + }); + }); + + group('assertUnpatchableDiffs', () { + test('returns no diffs (currently unimplemented)', () async { + final diffStatus = await runWithOverrides( + () => patcher.assertUnpatchableDiffs( + releaseArtifact: FakeReleaseArtifact(), + releaseArchive: File(''), + patchArchive: File(''), + ), + ); + expect( + diffStatus, + equals( + const DiffStatus( + hasAssetChanges: false, + hasNativeChanges: false, + ), + ), + ); + }); + }); + + group('buildPatchArtifact', () { + const flutterVersionAndRevision = '3.27.0 (8495dee1fd)'; + + setUp(() { + when( + () => ditto.archive( + source: any(named: 'source'), + destination: any(named: 'destination'), + keepParent: any(named: 'keepParent'), + ), + ).thenAnswer((invocation) async { + File( + invocation.namedArguments[#destination] as String, + ).createSync(recursive: true); + }); + + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + when( + () => shorebirdFlutter.getVersion(), + ).thenAnswer((_) async => Version(3, 27, 0)); + }); + + group('when specified flutter version is less than minimum', () { + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any( + named: 'checkUserIsAuthenticated', + ), + checkShorebirdInitialized: any( + named: 'checkShorebirdInitialized', + ), + validators: any(named: 'validators'), + supportedOperatingSystems: any( + named: 'supportedOperatingSystems', + ), + ), + ).thenAnswer((_) async {}); + when( + () => shorebirdFlutter.getVersion(), + ).thenAnswer((_) async => Version(3, 0, 0)); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + ''' +macOS patches are not supported with Flutter versions older than $minimumSupportedMacosFlutterVersion. +For more information see: ${supportedFlutterVersionsUrl.toLink()}''', + ), + ).called(1); + }); + }); + + group('when build fails with ProcessException', () { + setUp(() { + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenThrow( + const ProcessException( + 'flutter', + ['build', 'macos'], + 'Build failed', + ), + ); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('Failed to build: Build failed')); + }); + }); + + group('when build fails with ArtifactBuildException', () { + setUp(() { + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenThrow( + ArtifactBuildException('Build failed'), + ); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('Failed to build macOS app')); + }); + }); + + group('when elf aot snapshot build fails', () { + setUp(() { + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenAnswer( + (_) async => + MacosBuildResult(kernelFile: File('/path/to/app.dill')), + ); + when( + () => artifactBuilder.buildElfAotSnapshot( + appDillPath: any(named: 'appDillPath'), + outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), + ), + ).thenThrow(const FileSystemException('error')); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify( + () => progress.fail("FileSystemException: error, path = ''"), + ); + }); + }); + + group('when build succeeds', () { + late File kernelFile; + setUp(() { + kernelFile = File( + p.join(Directory.systemTemp.createTempSync().path, 'app.dill'), + )..createSync(recursive: true); + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + base64PublicKey: any(named: 'base64PublicKey'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenAnswer( + (_) async => MacosBuildResult(kernelFile: kernelFile), + ); + when( + () => artifactBuilder.buildElfAotSnapshot( + appDillPath: any(named: 'appDillPath'), + outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), + additionalArgs: any(named: 'additionalArgs'), + ), + ).thenAnswer( + (invocation) async => + File(invocation.namedArguments[#outFilePath] as String) + ..createSync(recursive: true), + ); + }); + + group('when --split-debug-info is provided', () { + final tempDir = Directory.systemTemp.createTempSync(); + final splitDebugInfoPath = p.join(tempDir.path, 'symbols'); + final splitDebugInfoFile = File( + p.join(splitDebugInfoPath, 'app.darwin-arm64.symbols'), + ); + setUp(() { + when( + () => argResults.wasParsed( + CommonArguments.splitDebugInfoArg.name, + ), + ).thenReturn(true); + when( + () => argResults[CommonArguments.splitDebugInfoArg.name], + ).thenReturn(splitDebugInfoPath); + }); + + test('forwards --split-debug-info to builder', () async { + try { + await runWithOverrides(patcher.buildPatchArtifact); + } catch (_) {} + verify( + () => artifactBuilder.buildElfAotSnapshot( + appDillPath: any(named: 'appDillPath'), + outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), + additionalArgs: [ + '--dwarf-stack-traces', + '--resolve-dwarf-paths', + '--save-debugging-info=${splitDebugInfoFile.path}', + ], + ), + ).called(1); + }); + }); + + group('when releaseVersion is provided', () { + test('forwards --build-name and --build-number to builder', + () async { + await runWithOverrides( + () => patcher.buildPatchArtifact(releaseVersion: '1.2.3+4'), + ); + verify( + () => artifactBuilder.buildMacos( + flavor: any(named: 'flavor'), + codesign: any(named: 'codesign'), + target: any(named: 'target'), + args: any( + named: 'args', + that: containsAll( + ['--build-name=1.2.3', '--build-number=4'], + ), + ), + buildProgress: any(named: 'buildProgress'), + ), + ).called(1); + }); + }); + + group('when the key pair is provided', () { + setUp(() { + when( + () => codeSigner.base64PublicKey(any()), + ).thenReturn('public_key_encoded'); + }); + + test('calls the buildMacos passing the key', () async { + when( + () => argResults.wasParsed(CommonArguments.publicKeyArg.name), + ).thenReturn(true); + + final key = createTempFile('public.pem') + ..writeAsStringSync('public_key'); + + when( + () => argResults[CommonArguments.publicKeyArg.name], + ).thenReturn(key.path); + when( + () => argResults[CommonArguments.publicKeyArg.name], + ).thenReturn(key.path); + await runWithOverrides(patcher.buildPatchArtifact); + + verify( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + base64PublicKey: 'public_key_encoded', + buildProgress: any(named: 'buildProgress'), + ), + ).called(1); + }); + }); + + group('when platform was specified via arg results rest', () { + setUp(() { + when(() => argResults.rest).thenReturn(['macos', '--verbose']); + }); + + test('returns app zip', () async { + final artifact = await runWithOverrides( + patcher.buildPatchArtifact, + ); + expect(p.basename(artifact.path), endsWith('.zip')); + verify( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: ['--verbose'], + buildProgress: any(named: 'buildProgress'), + ), + ).called(1); + }); + }); + + test('returns app zip', () async { + final artifact = await runWithOverrides(patcher.buildPatchArtifact); + expect(p.basename(artifact.path), endsWith('.zip')); + }); + + test('copies app.dill to build directory', () async { + final copiedKernelFile = File( + p.join( + projectRoot.path, + 'build', + 'app.dill', + ), + ); + expect(copiedKernelFile.existsSync(), isFalse); + await runWithOverrides(patcher.buildPatchArtifact); + expect(copiedKernelFile.existsSync(), isTrue); + }); + }); + }); + + group('createPatchArtifacts', () { + const postLinkerFlutterRevision = // cspell: disable-next-line + 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + const appId = 'appId'; + const arch = 'aarch64'; + const releaseId = 1; + const linkFileName = 'out.vmcode'; + const elfAotSnapshotFileName = 'out.aot'; + const releaseArtifact = ReleaseArtifact( + id: 0, + releaseId: releaseId, + arch: arch, + platform: ReleasePlatform.macos, + hash: '#', + size: 42, + url: 'https://example.com', + podfileLockHash: 'podfile-lock-hash', + canSideload: true, + ); + late File releaseArtifactFile; + late File supplementArtifactFile; + + void setUpProjectRootArtifacts() { + File( + p.join( + projectRoot.path, + 'build', + elfAotSnapshotFileName, + ), + ).createSync(recursive: true); + File( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + ), + ).createSync(recursive: true); + File( + p.join( + projectRoot.path, + 'build', + 'macos', + 'shorebird', + 'App.ct.link', + ), + ).createSync(recursive: true); + File( + p.join( + projectRoot.path, + 'build', + 'macos', + 'shorebird', + 'App.class_table.json', + ), + ).createSync(recursive: true); + File( + p.join(projectRoot.path, 'build', linkFileName), + ).createSync(recursive: true); + } + + setUp(() { + releaseArtifactFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'release.app', + ), + )..createSync(recursive: true); + supplementArtifactFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'macos_supplement.zip', + ), + )..createSync(recursive: true); + + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => releaseArtifact); + when(() => artifactManager.downloadFile(any())).thenAnswer((_) async { + final tempDirectory = Directory.systemTemp.createTempSync(); + final file = File(p.join(tempDirectory.path, 'libapp.so')) + ..createSync(); + return file; + }); + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final zipFile = invocation.namedArguments[#zipFile] as File; + final outDir = + invocation.namedArguments[#outputDirectory] as Directory; + File( + p.join(outDir.path, '${p.basename(zipFile.path)}.zip'), + ).createSync(); + }); + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDir = + invocation.namedArguments[#outputDirectory] as Directory; + File( + p.join(outDir.path, 'App.ct.link'), + ).createSync(recursive: true); + File( + p.join(outDir.path, 'App.class_table.json'), + ).createSync(recursive: true); + }); + when( + () => ditto.extract( + source: any(named: 'source'), + destination: any(named: 'destination'), + ), + ).thenAnswer((invocation) async { + final releaseAppDirectory = Directory( + invocation.namedArguments[#destination] as String, + )..createSync(recursive: true); + Directory( + p.join( + releaseAppDirectory.path, + 'Contents', + 'Frameworks', + 'App.framework', + 'App', + ), + ).createSync(recursive: true); + }); + + when(() => engineConfig.localEngine).thenReturn(null); + }); + + group('when patch .app does not exist', () { + setUp(() { + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(null); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + }); + }); + + group('when uses linker', () { + const linkPercentage = 50.0; + late File analyzeSnapshotFile; + late File genSnapshotFile; + + setUp(() { + final shorebirdRoot = Directory.systemTemp.createTempSync(); + flutterDirectory = Directory( + p.join(shorebirdRoot.path, 'bin', 'cache', 'flutter'), + ); + genSnapshotFile = File( + p.join( + flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'darwin-x64-release', + 'gen_snapshot', + ), + ); + analyzeSnapshotFile = File( + p.join( + flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'darwin-x64-release', + 'analyze_snapshot', + ), + )..createSync(recursive: true); + + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + outputPath: any(named: 'outputPath'), + workingDirectory: any(named: 'workingDirectory'), + dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), + additionalArgs: any(named: 'additionalArgs'), + ), + ).thenAnswer((_) async => linkPercentage); + when( + () => shorebirdEnv.flutterRevision, + ).thenReturn(postLinkerFlutterRevision); + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, + ), + ).thenReturn(analyzeSnapshotFile.path); + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.genSnapshotMacOS, + ), + ).thenReturn(genSnapshotFile.path); + }); + + group('when linking fails', () { + group('when supplement directory does not exist', () { + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: startsWith('Unable to find supplement directory'), + ), + ), + ).called(1); + }); + }); + + group('when .app does not exist', () { + setUp(() { + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(null); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: startsWith( + 'Unable to find .app directory', + ), + ), + ), + ).called(1); + }); + }); + + group('when aot snapshot does not exist', () { + setUp(() { + setUpProjectRootArtifacts(); + File( + p.join( + projectRoot.path, + 'build', + elfAotSnapshotFileName, + ), + ).deleteSync(); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any(that: startsWith('Unable to find patch AOT file at')), + ), + ).called(1); + }); + }); + + group('when analyzeSnapshot binary does not exist', () { + setUp(() { + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, + ), + ).thenReturn(''); + setUpProjectRootArtifacts(); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err('Unable to find analyze_snapshot at '), + ).called(1); + }); + }); + + group('when call to aotTools.link fails', () { + setUp(() { + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + outputPath: any(named: 'outputPath'), + workingDirectory: any(named: 'workingDirectory'), + dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), + additionalArgs: any(named: 'additionalArgs'), + ), + ).thenThrow(Exception('oops')); + + setUpProjectRootArtifacts(); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => progress.fail( + 'Failed to link AOT files: Exception: oops', + ), + ).called(1); + }); + }); + }); + + group('when generate patch diff base is supported', () { + setUp(() { + when( + () => aotTools.isGeneratePatchDiffBaseSupported(), + ).thenAnswer((_) async => true); + when( + () => aotTools.generatePatchDiffBase( + analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), + releaseSnapshot: any(named: 'releaseSnapshot'), + ), + ).thenAnswer((_) async => File('')); + }); + + group('when we fail to generate patch diff base', () { + setUp(() { + when( + () => aotTools.generatePatchDiffBase( + analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), + releaseSnapshot: any(named: 'releaseSnapshot'), + ), + ).thenThrow(Exception('oops')); + + setUpProjectRootArtifacts(); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('Exception: oops')).called(1); + }); + }); + + group('when linking and patch diff generation succeeds', () { + const diffPath = 'path/to/diff'; + + setUp(() { + when( + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + ), + ).thenAnswer((_) async => diffPath); + setUpProjectRootArtifacts(); + }); + + test('returns linked patch artifact in patch bundle', () async { + final patchBundle = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ); + + expect(patchBundle, hasLength(1)); + expect( + patchBundle[Arch.arm64], + isA().having( + (b) => b.path, + 'path', + endsWith(diffPath), + ), + ); + }); + + group('when class table link info is not present', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async {}); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + 'Unable to find class table link info file', + ), + ).called(1); + }); + }); + + group('when code signing the patch', () { + setUp(() { + final privateKey = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'test-private.pem', + ), + )..createSync(); + + when(() => argResults[CommonArguments.privateKeyArg.name]) + .thenReturn(privateKey.path); + + when( + () => codeSigner.sign( + message: any(named: 'message'), + privateKeyPemFile: any(named: 'privateKeyPemFile'), + ), + ).thenAnswer((invocation) { + final message = + invocation.namedArguments[#message] as String; + return '$message-signature'; + }); + }); + + test( + '''returns patch artifact bundles with proper hash signatures''', + () async { + final result = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ); + + // Hash the patch artifacts and append '-signature' to get the + // expected signatures, per the mock of [codeSigner.sign] + // above. + const expectedSignature = + '''e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855-signature'''; + + expect( + result.values.first.hashSignature, + equals( + expectedSignature, + ), + ); + }); + }); + + group('when debug info is missing', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDir = invocation.namedArguments[#outputDirectory] + as Directory; + File( + p.join(outDir.path, 'App.ct.link'), + ).createSync(recursive: true); + }); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + 'Unable to find class table link debug info file', + ), + ).called(1); + }); + }); + + group('when class table link info & debug info are present', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDir = invocation.namedArguments[#outputDirectory] + as Directory; + File( + p.join(outDir.path, 'App.ct.link'), + ).createSync(recursive: true); + File( + p.join(outDir.path, 'App.class_table.json'), + ).createSync(recursive: true); + }); + when( + () => ditto.extract( + source: any(named: 'source'), + destination: any(named: 'destination'), + ), + ).thenAnswer((invocation) async { + final destination = + invocation.namedArguments[#destination] as String; + File( + p.join( + destination, + 'Contents', + 'Frameworks', + 'App.framework', + 'App', + ), + ).createSync(recursive: true); + }); + }); + + test('returns linked patch artifact in patch bundle', () async { + final patchBundle = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ); + + expect(patchBundle, hasLength(1)); + expect( + patchBundle[Arch.arm64], + isA().having( + (b) => b.path, + 'path', + endsWith(diffPath), + ), + ); + }); + }); + + group('when isLinkDebugInfoSupported is true', () { + setUp(() { + when( + aotTools.isLinkDebugInfoSupported, + ).thenAnswer((_) async => true); + }); + + test('dumps debug info', () async { + await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ); + verify( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + outputPath: any(named: 'outputPath'), + workingDirectory: any(named: 'workingDirectory'), + dumpDebugInfoPath: any( + named: 'dumpDebugInfoPath', + that: isNotNull, + ), + ), + ).called(1); + verify( + () => logger.detail( + any( + that: contains( + 'Link debug info saved to', + ), + ), + ), + ).called(1); + }); + + group('when aot_tools link fails', () { + setUp(() { + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + outputPath: any(named: 'outputPath'), + workingDirectory: any(named: 'workingDirectory'), + dumpDebugInfoPath: any( + named: 'dumpDebugInfoPath', + that: isNotNull, + ), + ), + ).thenThrow(Exception('oops')); + }); + + test('dumps debug info and logs', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + verify( + () => logger.detail( + any( + that: contains( + 'Link debug info saved to', + ), + ), + ), + ).called(1); + }); + }); + }); + + group('when isLinkDebugInfoSupported is false', () { + setUp(() { + when(aotTools.isLinkDebugInfoSupported) + .thenAnswer((_) async => false); + }); + + test('does not pass dumpDebugInfoPath to aotTools.link', + () async { + await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ); + verify( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + outputPath: any(named: 'outputPath'), + workingDirectory: any(named: 'workingDirectory'), + // ignore: avoid_redundant_argument_values + dumpDebugInfoPath: null, + ), + ).called(1); + }); + }); + }); + }); + }); + }); + + group('extractReleaseVersionFromArtifact', () { + group('when app directory does not exist', () { + setUp(() { + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(null); + }); + + test('exit with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + exitsWithCode(ExitCode.software), + ); + }); + }); + + group('when Info.plist does not exist', () { + setUp(() { + try { + File( + p.join( + appDirectory.path, + 'Contents', + 'Info.plist', + ), + ).deleteSync(recursive: true); + } catch (_) {} + }); + + test('exit with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + exitsWithCode(ExitCode.software), + ); + }); + }); + + group('when empty Info.plist does exist', () { + setUp(() { + File( + p.join( + appDirectory.path, + 'Contents', + 'Info.plist', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync(''' + + + + + +'''); + }); + + test('exits with code 70 and logs error', () async { + await expectLater( + runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + exitsWithCode(ExitCode.software), + ); + verify( + () => logger.err( + any( + that: startsWith('Failed to determine release version'), + ), + ), + ).called(1); + }); + }); + + group('when Info.plist does exist', () { + setUp(() { + File( + p.join( + appDirectory.path, + 'Contents', + 'Info.plist', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync(''' + + + + + ApplicationProperties + + ApplicationPath + Applications/Runner.app + Architectures + + arm64 + + CFBundleIdentifier + com.shorebird.timeShift + CFBundleShortVersionString + 1.2.3 + CFBundleVersion + 1 + + ArchiveVersion + 2 + Name + Runner + SchemeName + Runner + + +'''); + }); + + test('returns correct version', () async { + await expectLater( + runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + completion('1.2.3+1'), + ); + }); + }); + }); + + group('updatedCreatePatchMetadata', () { + const allowAssetDiffs = false; + const allowNativeDiffs = true; + const flutterRevision = '853d13d954df3b6e9c2f07b72062f33c52a9a64b'; + const operatingSystem = 'Mac OS X'; + const operatingSystemVersion = '10.15.7'; + const xcodeVersion = '11'; + + setUp(() { + when( + () => xcodeBuild.version(), + ).thenAnswer((_) async => xcodeVersion); + }); + + const linkPercentage = 100.0; + + setUp(() { + patcher.lastBuildLinkPercentage = linkPercentage; + }); + + test('returns correct metadata', () async { + const metadata = CreatePatchMetadata( + releasePlatform: ReleasePlatform.macos, + usedIgnoreAssetChangesFlag: allowAssetDiffs, + hasAssetChanges: true, + usedIgnoreNativeChangesFlag: allowNativeDiffs, + hasNativeChanges: false, + environment: BuildEnvironmentMetadata( + flutterRevision: flutterRevision, + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + shorebirdYaml: ShorebirdYaml(appId: 'app-id'), + ), + ); + + expect( + runWithOverrides( + () => patcher.updatedCreatePatchMetadata(metadata), + ), + completion( + const CreatePatchMetadata( + releasePlatform: ReleasePlatform.macos, + usedIgnoreAssetChangesFlag: allowAssetDiffs, + hasAssetChanges: true, + usedIgnoreNativeChangesFlag: allowNativeDiffs, + hasNativeChanges: false, + linkPercentage: linkPercentage, + environment: BuildEnvironmentMetadata( + flutterRevision: flutterRevision, + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: xcodeVersion, + shorebirdYaml: ShorebirdYaml(appId: 'app-id'), + ), + ), + ), + ); + }); + }); + }, + testOn: 'mac-os', + ); +} 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 50bab732d..d49cedc8a 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 @@ -342,6 +342,12 @@ void main() { ).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('createPatch', () { @@ -545,6 +551,10 @@ void main() { command.getPatcher(ReleaseType.iosFramework), isA(), ); + expect( + command.getPatcher(ReleaseType.macos), + isA(), + ); }); }); 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 73ca63b82..8cce2d0ed 100644 --- a/packages/shorebird_cli/test/src/commands/preview_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/preview_command_test.dart @@ -22,6 +22,7 @@ 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/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'; @@ -38,6 +39,7 @@ void main() { const releaseId = 42; const androidArtifactId = 21; const iosArtifactId = 12; + const macosArtifactId = 13; late AppMetadata app; late AppleDevice appleDevice; @@ -240,6 +242,7 @@ void main() { when(() => release.platformStatuses).thenReturn({ ReleasePlatform.android: ReleaseStatus.active, ReleasePlatform.ios: ReleaseStatus.active, + ReleasePlatform.macos: ReleaseStatus.active, }); when(() => logger.progress(any())).thenReturn(progress); when(() => progress.fail(any())).thenReturn(null); @@ -1510,8 +1513,148 @@ channel: ${DeploymentTrack.staging.channel} }); }); + group('macos', () { + const releaseArtifactUrl = 'https://example.com/sample.app'; + const releasePlatform = ReleasePlatform.macos; + + late Ditto ditto; + late ShorebirdProcess shorebirdProcess; + late Process process; + + R runWithOverrides(R Function() body) { + return HttpOverrides.runZoned( + () => runScoped( + body, + values: { + adbRef.overrideWith(() => adb), + artifactManagerRef.overrideWith(() => artifactManager), + authRef.overrideWith(() => auth), + bundletoolRef.overrideWith(() => bundletool), + cacheRef.overrideWith(() => cache), + codePushClientWrapperRef + .overrideWith(() => codePushClientWrapper), + dittoRef.overrideWith(() => ditto), + httpClientRef.overrideWith(() => httpClient), + loggerRef.overrideWith(() => logger), + platformRef.overrideWith(() => platform), + processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + ); + } + + setUp(() { + ditto = MockDitto(); + shorebirdProcess = MockShorebirdProcess(); + process = MockProcess(); + + when(() => releaseArtifact.id).thenReturn(macosArtifactId); + when(() => argResults['platform']).thenReturn(releasePlatform.name); + when( + () => artifactManager.downloadFile(any()), + ).thenAnswer((_) async => File('')); + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((_) async {}); + when( + () => ditto.extract( + source: any(named: 'source'), + destination: any(named: 'destination'), + ), + ).thenAnswer((_) async {}); + when(() => release.platformStatuses).thenReturn({ + ReleasePlatform.macos: ReleaseStatus.active, + }); + when(() => releaseArtifact.url).thenReturn(releaseArtifactUrl); + when(() => platform.isMacOS).thenReturn(true); + when(() => shorebirdProcess.start(any(), any())).thenAnswer( + (_) async => process, + ); + }); + + group('when querying for release artifact fails', () { + setUp(() { + final exception = Exception('oops'); + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify( + () => codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: releaseId, + arch: 'app', + platform: releasePlatform, + ), + ).called(1); + }); + }); + + group('when downloading release artifact fails', () { + final exception = Exception('oops'); + setUp(() { + when( + () => artifactManager.downloadFile(any()), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify(() => progress.fail('$exception')).called(1); + }); + }); + + group('when extracting release artifact fails', () { + final exception = Exception('oops'); + setUp(() { + when( + () => ditto.extract( + source: any(named: 'source'), + destination: any(named: 'destination'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify(() => progress.fail('$exception')).called(1); + }); + }); + + group('when process completes with exit code 0', () { + setUp(() { + when(() => process.exitCode).thenAnswer((_) async => 0); + }); + + test('completes successfully', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => shorebirdProcess.start('open', any()), + ).called(1); + }); + }); + }); + group('when no platform is specified', () { const iosReleaseArtifactUrl = 'https://example.com/runner.app'; + const macosReleaseArtifactUrl = 'https://example.com/sample.app'; late Devicectl devicectl; late IOSDeploy iosDeploy; @@ -1520,6 +1663,7 @@ channel: ${DeploymentTrack.staging.channel} late ReleaseArtifact iosReleaseArtifact; late ReleaseArtifact androidReleaseArtifact; + late ReleaseArtifact macosReleaseArtifact; late Adb adb; late Bundletool bundletool; @@ -1557,6 +1701,7 @@ channel: ${DeploymentTrack.staging.channel} iosDeploy = MockIOSDeploy(); iosReleaseArtifact = MockReleaseArtifact(); androidReleaseArtifact = MockReleaseArtifact(); + macosReleaseArtifact = MockReleaseArtifact(); when(() => appleDevice.name).thenReturn('iPhone 12'); when(() => appleDevice.udid).thenReturn('12345678-1234567890ABCDEF'); @@ -1593,6 +1738,11 @@ channel: ${DeploymentTrack.staging.channel} when(() => iosReleaseArtifact.id).thenReturn(iosArtifactId); when(() => iosReleaseArtifact.url).thenReturn(iosReleaseArtifactUrl); + when(() => macosReleaseArtifact.id).thenReturn(macosArtifactId); + when( + () => macosReleaseArtifact.url, + ).thenReturn(macosReleaseArtifactUrl); + when( () => codePushClientWrapper.getReleaseArtifact( appId: any(named: 'appId'), @@ -1602,9 +1752,19 @@ channel: ${DeploymentTrack.staging.channel} ), ).thenAnswer((_) async => iosReleaseArtifact); + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: ReleasePlatform.macos, + ), + ).thenAnswer((_) async => macosReleaseArtifact); + when(() => androidReleaseArtifact.id).thenReturn(androidArtifactId); - when(() => androidReleaseArtifact.url) - .thenReturn(androidReleaseArtifactUrl); + when( + () => androidReleaseArtifact.url, + ).thenReturn(androidReleaseArtifactUrl); when( () => codePushClientWrapper.getReleaseArtifact( diff --git a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart index d76ad6725..2c9758120 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart @@ -738,7 +738,8 @@ To change the version of this release, change your app's version in your pubspec supplementPath: any(named: 'supplementPath'), ), ).thenAnswer((_) async => {}); - when(() => shorebirdEnv.podfileLockFile).thenReturn(podfileLockFile); + when(() => shorebirdEnv.iosPodfileLockFile) + .thenReturn(podfileLockFile); }); test('forwards call to codePushClientWrapper', () async { diff --git a/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart new file mode 100644 index 000000000..5239298c1 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart @@ -0,0 +1,743 @@ +import 'dart:convert'; +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/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/release.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/doctor.dart'; +import 'package:shorebird_cli/src/executables/xcodebuild.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/metadata/metadata.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/validators/validators.dart'; +import 'package:shorebird_cli/src/version.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( + MacosReleaser, + () { + late ArgResults argResults; + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + // late CodeSigner codeSigner; + late Directory projectRoot; + late Doctor doctor; + late Progress progress; + late ShorebirdLogger logger; + // late OperatingSystemInterface operatingSystemInterface; + late ShorebirdFlutterValidator flutterValidator; + // late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late XcodeBuild xcodeBuild; + + late MacosReleaser releaser; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + // codeSignerRef.overrideWith(() => codeSigner), + doctorRef.overrideWith(() => doctor), + // iosRef.overrideWith(() => ios), + loggerRef.overrideWith(() => logger), + // osInterfaceRef.overrideWith(() => operatingSystemInterface), + // processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + xcodeBuildRef.overrideWith(() => xcodeBuild), + }, + ); + } + + setUp(() { + argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + // codeSigner = MockCodeSigner(); + doctor = MockDoctor(); + projectRoot = Directory.systemTemp.createTempSync(); + // operatingSystemInterface = MockOperatingSystemInterface(); + progress = MockProgress(); + logger = MockShorebirdLogger(); + // ios = MockIos(); + flutterValidator = MockShorebirdFlutterValidator(); + // shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + xcodeBuild = MockXcodeBuild(); + + when(() => argResults.rest).thenReturn([]); + when(() => argResults.wasParsed(any())).thenReturn(false); + + when(() => logger.progress(any())).thenReturn(progress); + + releaser = MacosReleaser( + argResults: argResults, + flavor: null, + target: null, + ); + }); + + group('releaseType', () { + test('is macos', () { + expect(releaser.releaseType, ReleaseType.macos); + }); + }); + + group('assertPreconditions', () { + final flutterVersion = Version(3, 0, 0); + + setUp(() { + when(() => doctor.macosCommandValidators) + .thenReturn([flutterValidator]); + when(() => shorebirdFlutter.resolveFlutterVersion(any())) + .thenAnswer((_) async => flutterVersion); + when(flutterValidator.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: [flutterValidator], + supportedOperatingSystems: {Platform.macOS}, + ), + ).called(1); + }); + }); + + group('when specified flutter version is less than minimum', () { + 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']).thenReturn('3.0.0'); + }); + + test('logs error and exits with code 64', () async { + await expectLater( + () => runWithOverrides(releaser.assertPreconditions), + exitsWithCode(ExitCode.usage), + ); + + verify( + () => logger.err( + ''' +macOS releases are not supported with Flutter versions older than $minimumSupportedMacosFlutterVersion. +For more information see: ${supportedFlutterVersionsUrl.toLink()}''', + ), + ).called(1); + }); + }); + }); + + 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('when --obfuscate is passed', () { + setUp(() { + when(() => argResults.rest).thenReturn(['--obfuscate']); + }); + + test('logs error and exits', () async { + await expectLater( + runWithOverrides(releaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + + verify( + () => logger.err( + 'Shorebird does not currently support obfuscation on macOS.', + ), + ).called(1); + verify( + () => logger.info( + '''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''', + ), + ).called(1); + }); + }); + + group('when --obfuscate is not passed', () { + test('returns normally', () async { + await expectLater( + runWithOverrides(releaser.assertArgsAreValid), + completes, + ); + }); + }); + }); + + group('buildReleaseArtifacts', () { + const flutterVersionAndRevision = '3.10.6 (83305b5088)'; + + late Directory appDirectory; + + setUp(() { + when(() => argResults['codesign']).thenReturn(true); + + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenAnswer( + (_) async => MacosBuildResult( + kernelFile: File('/path/to/app.dill'), + ), + ); + + appDirectory = Directory.systemTemp.createTempSync(); + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(appDirectory); + + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + }); + + group('when not codesigning', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(false); + }); + + test('logs warning about patching', () async { + await runWithOverrides(releaser.buildReleaseArtifacts); + + verify( + () => logger.info( + '''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''', + ), + ).called(1); + verify( + () => logger.warn( + '''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''', + ), + ).called(1); + }); + }); + + group('when build fails', () { + setUp(() { + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenThrow(ArtifactBuildException('Failed to build')); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(releaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => progress.fail('Failed to build'), + ).called(1); + }); + }); + + group('when build succeeds', () { + group('when platform was specified via arg results rest', () { + setUp(() { + when(() => argResults.rest).thenReturn(['macos', '--verbose']); + }); + + test('verifies artifacts exist and returns xcarchive path', + () async { + expect( + await runWithOverrides(releaser.buildReleaseArtifacts), + equals(appDirectory), + ); + + verify(() => artifactManager.getMacOSAppDirectory()).called(1); + verify( + () => artifactBuilder.buildMacos( + args: ['--verbose'], + buildProgress: any(named: 'buildProgress'), + ), + ).called(1); + }); + }); + + test('verifies artifacts exist and returns app path', () async { + expect( + await runWithOverrides(releaser.buildReleaseArtifacts), + equals(appDirectory), + ); + + verify(() => artifactManager.getMacOSAppDirectory()).called(1); + }); + }); + + group('when app not found after build', () { + setUp(() { + when(() => artifactManager.getMacOSAppDirectory()).thenReturn(null); + }); + + test('logs message and exits with code 70', () async { + await expectLater( + () => runWithOverrides(releaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err('Unable to find .app directory'), + ).called(1); + }); + }); + + group('when app not found after build', () { + setUp(() { + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(null); + }); + + test('logs message and exits with code 70', () async { + await expectLater( + () => runWithOverrides(releaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err('Unable to find .app directory'), + ).called(1); + }); + }); + }); + + group('getReleaseVersion', () { + late Directory appDirectory; + + setUp(() { + appDirectory = Directory.systemTemp.createTempSync(); + // The Info.plist file is expected to be in the app directory at + // Contents/Info.plist + Directory(p.join(appDirectory.path, 'Contents')) + .createSync(recursive: true); + }); + + group('when plist does not exist', () { + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides( + () => releaser.getReleaseVersion( + releaseArtifactRoot: appDirectory, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + '''No Info.plist file found at ${p.join(appDirectory.path, 'Contents', 'Info.plist')}''', + ), + ).called(1); + }); + }); + + group('when plist does not contain version number', () { + late File plist; + setUp(() { + plist = File(p.join(appDirectory.path, 'Contents', 'Info.plist')) + ..createSync() + ..writeAsStringSync( + ''' + + + + + ApplicationProperties + + + +' +''', + ); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides( + () => releaser.getReleaseVersion( + releaseArtifactRoot: appDirectory, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: startsWith( + 'Failed to determine release version from ${plist.path}', + ), + ), + ), + ).called(1); + }); + }); + + group('when plist contains version number', () { + setUp(() { + File(p.join(appDirectory.path, 'Contents', 'Info.plist')) + ..createSync() + ..writeAsStringSync( + ''' + + + + + ApplicationProperties + + ApplicationPath + Applications/Runner.app + Architectures + + arm64 + + CFBundleIdentifier + com.shorebird.timeShift + CFBundleShortVersionString + 1.2.3 + CFBundleVersion + 1 + + ArchiveVersion + 2 + Name + Runner + SchemeName + Runner + +''', + ); + }); + + test('returns version number from plist', () async { + expect( + await runWithOverrides( + () => releaser.getReleaseVersion( + releaseArtifactRoot: appDirectory, + ), + ), + equals('1.2.3+1'), + ); + }); + }); + }); + + group('uploadReleaseArtifacts', () { + const appId = 'appId'; + const releaseVersion = '1.0.0'; + const flutterRevision = 'deadbeef'; + const flutterVersion = '3.22.0'; + const codesign = true; + const podfileLockContent = 'podfile-lock'; + + final release = Release( + id: 42, + appId: appId, + version: releaseVersion, + flutterRevision: flutterRevision, + flutterVersion: flutterVersion, + displayName: '1.2.3+1', + platformStatuses: {}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + + late Directory appDirectory; + late Directory supplementDirectory; + late File podfileLockFile; + + setUp(() { + when(() => argResults['codesign']).thenReturn(codesign); + + appDirectory = Directory.systemTemp.createTempSync(); + supplementDirectory = Directory.systemTemp.createTempSync(); + + podfileLockFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'Podfile.lock', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync(podfileLockContent); + + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(appDirectory); + when( + () => artifactManager.getMacosReleaseSupplementDirectory(), + ).thenReturn(supplementDirectory); + when( + () => codePushClientWrapper.createMacosReleaseArtifacts( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + appPath: any(named: 'appPath'), + isCodesigned: any(named: 'isCodesigned'), + podfileLockHash: any(named: 'podfileLockHash'), + supplementPath: any(named: 'supplementPath'), + ), + ).thenAnswer((_) async => {}); + + when( + () => shorebirdEnv.macosPodfileLockFile, + ).thenReturn(podfileLockFile); + }); + + group('when app directory does not exist', () { + setUp(() { + when(() => artifactManager.getMacOSAppDirectory()).thenReturn(null); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => releaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err('Unable to find .app directory'), + ).called(1); + }); + }); + + group('when supplement directory does not exist', () { + setUp(() { + when( + () => artifactManager.getMacosReleaseSupplementDirectory(), + ).thenReturn(null); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => releaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ), + exitsWithCode(ExitCode.software), + ); + verify( + () => logger.err('Unable to find supplement directory'), + ).called(1); + }); + }); + + test('forwards call to codePushClientWrapper', () async { + await runWithOverrides( + () => releaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ); + + verify( + () => codePushClientWrapper.createMacosReleaseArtifacts( + appId: appId, + releaseId: release.id, + appPath: appDirectory.path, + isCodesigned: codesign, + podfileLockHash: + '${sha256.convert(utf8.encode(podfileLockContent))}', + supplementPath: supplementDirectory.path, + ), + ).called(1); + }); + }); + + group('updatedReleaseMetadata', () { + const flutterRevision = '853d13d954df3b6e9c2f07b72062f33c52a9a64b'; + const operatingSystem = 'macOS'; + const operatingSystemVersion = '11.0.0'; + const xcodeVersion = '123'; + const flutterVersionOverride = '1.2.3'; + const metadata = UpdateReleaseMetadata( + releasePlatform: ReleasePlatform.macos, + flutterVersionOverride: flutterVersionOverride, + environment: BuildEnvironmentMetadata( + flutterRevision: flutterRevision, + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + shorebirdYaml: ShorebirdYaml(appId: 'app-id'), + ), + ); + + setUp(() { + when( + () => xcodeBuild.version(), + ).thenAnswer((_) async => xcodeVersion); + }); + + test('returns expected metadata', () async { + expect( + runWithOverrides( + () => releaser.updatedReleaseMetadata(metadata), + ), + completion( + const UpdateReleaseMetadata( + releasePlatform: ReleasePlatform.macos, + flutterVersionOverride: flutterVersionOverride, + environment: BuildEnvironmentMetadata( + flutterRevision: flutterRevision, + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + shorebirdYaml: ShorebirdYaml(appId: 'app-id'), + xcodeVersion: xcodeVersion, + ), + ), + ), + ); + }); + }); + + group('postReleaseInstructions', () { + late Directory appDirectory; + + setUp(() { + appDirectory = Directory.systemTemp.createTempSync(); + when(() => artifactManager.getMacOSAppDirectory()) + .thenReturn(appDirectory); + }); + + test('prints xcarchive upload steps', () { + expect( + runWithOverrides(() => releaser.postReleaseInstructions), + equals(''' + +macOS app created at ${appDirectory.path}. +'''), + ); + }); + }); + }, + testOn: 'mac-os', + ); +} 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 6c01e4673..01a59772d 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 @@ -12,6 +12,7 @@ import 'package:shorebird_cli/src/common_arguments.dart'; 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/release_type.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_flutter.dart'; @@ -218,9 +219,19 @@ void main() { command.getReleaser(ReleaseType.iosFramework), isA(), ); + expect( + command.getReleaser(ReleaseType.macos), + isA(), + ); }); }); + 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); + }); + test('executes commands in order, completes successfully', () async { final exitCode = await runWithOverrides(command.run); expect(exitCode, equals(ExitCode.success.code)); diff --git a/packages/shorebird_cli/test/src/executables/ditto_test.dart b/packages/shorebird_cli/test/src/executables/ditto_test.dart new file mode 100644 index 000000000..00abadaf3 --- /dev/null +++ b/packages/shorebird_cli/test/src/executables/ditto_test.dart @@ -0,0 +1,216 @@ +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(Ditto, () { + late ShorebirdProcess process; + late Ditto ditto; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + processRef.overrideWith(() => process), + }, + ); + } + + setUp(() { + process = MockShorebirdProcess(); + ditto = Ditto(); + }); + + group('extract', () { + const source = './source'; + const destination = './destination'; + + group('when process exits with code 0', () { + setUp(() { + when( + () => process.run('ditto', ['-x', '-k', source, destination]), + ).thenAnswer( + (_) async => const ShorebirdProcessResult( + exitCode: 0, + stdout: '', + stderr: '', + ), + ); + }); + + test('completes', () async { + await expectLater( + runWithOverrides( + () => ditto.extract(source: source, destination: destination), + ), + completes, + ); + verify( + () => process.run('ditto', ['-x', '-k', source, destination]), + ).called(1); + }); + }); + + group('when process exits with non-zero exit code', () { + const error = 'oops something went wrong'; + setUp(() { + when( + () => process.run('ditto', ['-x', '-k', source, destination]), + ).thenAnswer( + (_) async => const ShorebirdProcessResult( + exitCode: 1, + stdout: '', + stderr: error, + ), + ); + }); + + test('throws an exception', () async { + await expectLater( + runWithOverrides( + () => ditto.extract(source: source, destination: destination), + ), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains(error), + ), + ), + ); + verify( + () => process.run('ditto', ['-x', '-k', source, destination]), + ).called(1); + }); + }); + }); + + group('archive', () { + const source = './source'; + const destination = './destination'; + + group('when process exits with code 0', () { + setUp(() { + when( + () => process.run( + 'ditto', + ['-c', '-k', source, destination], + ), + ).thenAnswer( + (_) async => const ShorebirdProcessResult( + exitCode: 0, + stdout: '', + stderr: '', + ), + ); + }); + + group('when keepParent is true', () { + setUp(() { + when( + () => process.run( + 'ditto', + [ + '-c', + '-k', + '--keepParent', + source, + destination, + ], + ), + ).thenAnswer( + (_) async => const ShorebirdProcessResult( + exitCode: 0, + stdout: '', + stderr: '', + ), + ); + }); + + test('completes', () async { + await expectLater( + runWithOverrides( + () => ditto.archive( + source: source, + destination: destination, + keepParent: true, + ), + ), + completes, + ); + verify( + () => process.run( + 'ditto', + [ + '-c', + '-k', + '--keepParent', + source, + destination, + ], + ), + ).called(1); + }); + }); + + test('completes', () async { + await expectLater( + runWithOverrides( + () => ditto.archive(source: source, destination: destination), + ), + completes, + ); + verify( + () => process.run( + 'ditto', + ['-c', '-k', source, destination], + ), + ).called(1); + }); + }); + + group('when process exits with non-zero exit code', () { + const error = 'oops something went wrong'; + setUp(() { + when( + () => process.run( + 'ditto', + ['-c', '-k', source, destination], + ), + ).thenAnswer( + (_) async => const ShorebirdProcessResult( + exitCode: 1, + stdout: '', + stderr: error, + ), + ); + }); + + test('throws an exception', () async { + await expectLater( + runWithOverrides( + () => ditto.archive(source: source, destination: destination), + ), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains(error), + ), + ), + ); + verify( + () => process.run( + 'ditto', + ['-c', '-k', source, destination], + ), + ).called(1); + }); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/mocks.dart b/packages/shorebird_cli/test/src/mocks.dart index 959186065..3aafe74f0 100644 --- a/packages/shorebird_cli/test/src/mocks.dart +++ b/packages/shorebird_cli/test/src/mocks.dart @@ -85,6 +85,8 @@ class MockDetailProgress extends Mock implements DetailProgress {} class MockDevicectl extends Mock implements Devicectl {} +class MockDitto extends Mock implements Ditto {} + class MockDirectory extends Mock implements Directory {} class MockDoctor extends Mock implements Doctor {} diff --git a/packages/shorebird_cli/test/src/shorebird_artifacts_test.dart b/packages/shorebird_cli/test/src/shorebird_artifacts_test.dart index a31b19f2c..761c084e4 100644 --- a/packages/shorebird_cli/test/src/shorebird_artifacts_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_artifacts_test.dart @@ -104,11 +104,11 @@ void main() { }); }); - test('returns correct path for gen_snapshot', () { + test('returns correct path for iOS gen_snapshot', () { expect( runWithOverrides( () => artifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, + artifact: ShorebirdArtifact.genSnapshotIos, ), ), equals( @@ -119,17 +119,38 @@ void main() { 'artifacts', 'engine', 'ios-release', - 'gen_snapshot_arm64', + 'gen_snapshot', + ), + ), + ); + }); + + test('returns correct path for macOS gen_snapshot', () { + expect( + runWithOverrides( + () => artifacts.getArtifactPath( + artifact: ShorebirdArtifact.genSnapshotMacOS, + ), + ), + equals( + p.join( + flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'darwin-x64-release', + 'gen_snapshot', ), ), ); }); - test('returns correct path for analyze_snapshot', () { + test('returns correct path for analyze_snapshot on iOS', () { expect( runWithOverrides( () => artifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ), ), equals( @@ -145,6 +166,27 @@ void main() { ), ); }); + + test('returns correct path for analyze_snapshot on macOS', () { + expect( + runWithOverrides( + () => artifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, + ), + ), + equals( + p.join( + flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'darwin-x64-release', + 'analyze_snapshot', + ), + ), + ); + }); }); }); @@ -200,11 +242,11 @@ void main() { ); }); - test('returns correct path for gen_snapshot', () { + test('returns correct path for gen_snapshot on iOS', () { expect( runWithOverrides( () => artifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, + artifact: ShorebirdArtifact.genSnapshotIos, ), ), equals( @@ -219,11 +261,30 @@ void main() { ); }); - test('returns correct path for analyze_snapshot', () { + test('returns correct path for gen_snapshot on macOS', () { + expect( + runWithOverrides( + () => artifacts.getArtifactPath( + artifact: ShorebirdArtifact.genSnapshotMacOS, + ), + ), + equals( + p.join( + localEngineSrcPath, + 'out', + localEngine, + 'clang_x64', + 'gen_snapshot', + ), + ), + ); + }); + + test('returns correct path for analyze_snapshot on iOS', () { expect( runWithOverrides( () => artifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, + artifact: ShorebirdArtifact.analyzeSnapshotIos, ), ), equals( @@ -237,6 +298,25 @@ void main() { ), ); }); + + test('returns correct path for analyze_snapshot on macOS', () { + expect( + runWithOverrides( + () => artifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, + ), + ), + equals( + p.join( + localEngineSrcPath, + 'out', + localEngine, + 'clang_x64', + 'analyze_snapshot', + ), + ), + ); + }); }); }); } diff --git a/packages/shorebird_cli/test/src/shorebird_env_test.dart b/packages/shorebird_cli/test/src/shorebird_env_test.dart index 7f286ae92..29626257c 100644 --- a/packages/shorebird_cli/test/src/shorebird_env_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_env_test.dart @@ -199,13 +199,13 @@ void main() { }); }); - group('podfileLockFile', () { + group('iosPodfileLockFile', () { test('returns correct path', () { final tempDir = Directory.systemTemp.createTempSync(); File(p.join(tempDir.path, 'pubspec.yaml')).createSync(recursive: true); final podfileLockFile = IOOverrides.runZoned( () => runWithOverrides( - () => shorebirdEnv.podfileLockFile, + () => shorebirdEnv.iosPodfileLockFile, ), getCurrentDirectory: () => tempDir, ); @@ -216,6 +216,23 @@ void main() { }); }); + group('macosPodfileLockFile', () { + test('returns correct path', () { + final tempDir = Directory.systemTemp.createTempSync(); + File(p.join(tempDir.path, 'pubspec.yaml')).createSync(recursive: true); + final podfileLockFile = IOOverrides.runZoned( + () => runWithOverrides( + () => shorebirdEnv.macosPodfileLockFile, + ), + getCurrentDirectory: () => tempDir, + ); + expect( + podfileLockFile.path, + equals(p.join(tempDir.path, 'macos', 'Podfile.lock')), + ); + }); + }); + group('flutterBinaryFile', () { test('returns correct path', () { expect(