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 e64f6075..206673c0 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -809,6 +809,9 @@ aar artifact already exists, continuing...''', logger.success('\n✅ Published Patch ${patch.number}!'); } + /// Returns a GCP upload link for measuring upload speed. + Future getGCPSpeedTestUrl() => codePushClient.getGCPSpeedTestUrl(); + /// Prints an appropriate error message for the given error and exits with /// code 70. If [progress] is provided, it will be failed with the given /// [message] or [error.toString()] if [message] is null. diff --git a/packages/shorebird_cli/lib/src/commands/doctor_command.dart b/packages/shorebird_cli/lib/src/commands/doctor_command.dart index d8076f43..6cbfd732 100644 --- a/packages/shorebird_cli/lib/src/commands/doctor_command.dart +++ b/packages/shorebird_cli/lib/src/commands/doctor_command.dart @@ -100,6 +100,19 @@ Android Toolchain await networkChecker.checkReachability(); logger.info(''); + if (verbose) { + final progress = logger.progress('Performing GCP speed test'); + + try { + final speed = await networkChecker.performGCPSpeedTest(); + progress.complete('GCP Upload Speed: ${speed.toStringAsFixed(2)} MB/s'); + } on NetworkCheckerException catch (error) { + progress.fail('GCP speed test failed: ${error.message}'); + } catch (error) { + progress.fail('GCP speed test failed: $error'); + } + } + await doctor.runValidators(doctor.generalValidators, applyFixes: shouldFix); return ExitCode.success.code; diff --git a/packages/shorebird_cli/lib/src/network_checker.dart b/packages/shorebird_cli/lib/src/network_checker.dart index 5933483d..cdc5f270 100644 --- a/packages/shorebird_cli/lib/src/network_checker.dart +++ b/packages/shorebird_cli/lib/src/network_checker.dart @@ -1,4 +1,11 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:clock/clock.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:shorebird_cli/src/logger.dart'; @@ -8,6 +15,17 @@ final networkCheckerRef = create(NetworkChecker.new); /// The [NetworkChecker] instance available in the current zone. NetworkChecker get networkChecker => read(networkCheckerRef); +/// {@template network_checker_exception} +/// Thrown when a network check fails. +/// {@endtemplate} +class NetworkCheckerException implements Exception { + /// {@macro network_checker_exception} + const NetworkCheckerException(this.message); + + /// The message associated with the exception. + final String message; +} + /// {@template network_checker} /// Checks reachability of various Shorebird-related endpoints and logs the /// results. @@ -36,4 +54,43 @@ class NetworkChecker { } } } + + /// Uploads a file to GCP to measure upload speed. Returns the upload rate + /// in MB/s. + Future performGCPSpeedTest({ + // If they can't upload the file in two minutes, we can just say it's slow. + Duration uploadTimeout = const Duration(minutes: 2), + }) async { + // Test with a 5MB file. + const uploadMBs = 5; + const fileSize = uploadMBs * 1000 * 1000; + + final tempDir = Directory.systemTemp.createTempSync(); + final testFile = File(p.join(tempDir.path, 'speed_test_file')) + ..writeAsBytesSync(ByteData(fileSize).buffer.asUint8List()); + try { + final uri = await codePushClientWrapper.getGCPSpeedTestUrl(); + final start = clock.now(); + final file = await http.MultipartFile.fromPath('file', testFile.path); + final uploadRequest = http.MultipartRequest('POST', uri)..files.add(file); + final uploadResponse = await httpClient.send(uploadRequest).timeout( + uploadTimeout, + onTimeout: () { + throw const NetworkCheckerException('Upload timed out'); + }, + ); + if (uploadResponse.statusCode != HttpStatus.noContent) { + final body = await uploadResponse.stream.bytesToString(); + throw NetworkCheckerException( + 'Failed to upload file: $body ${uploadResponse.statusCode}', + ); + } + + final end = clock.now(); + return fileSize / (end.difference(start).inMilliseconds * 1000); + } finally { + testFile.deleteSync(); + tempDir.deleteSync(); + } + } } diff --git a/packages/shorebird_cli/pubspec.lock b/packages/shorebird_cli/pubspec.lock index a7b60b3a..96715dbf 100644 --- a/packages/shorebird_cli/pubspec.lock +++ b/packages/shorebird_cli/pubspec.lock @@ -178,7 +178,7 @@ packages: source: hosted version: "0.4.1" clock: - dependency: transitive + dependency: "direct main" description: name: clock sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf diff --git a/packages/shorebird_cli/pubspec.yaml b/packages/shorebird_cli/pubspec.yaml index df1ce9d0..5d6c90d0 100644 --- a/packages/shorebird_cli/pubspec.yaml +++ b/packages/shorebird_cli/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: checked_yaml: ^2.0.3 cli_completion: ^0.5.0 cli_util: ^0.4.1 + clock: ^1.1.1 collection: ^1.18.0 crypto: ^3.0.5 equatable: ^2.0.5 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 f5ecfe30..daa19c88 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 @@ -2360,5 +2360,26 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' }); }); }); + + group('getGCPSpeedTestUrl', () { + final gcpSpeedTestUrl = Uri.parse('https://speedtest.gcp.com'); + + setUp(() { + when(() => codePushClient.getGCPSpeedTestUrl()).thenAnswer( + (_) async => gcpSpeedTestUrl, + ); + }); + + test('calls codePushClient method', () async { + await expectLater( + runWithOverrides( + () => codePushClientWrapper.getGCPSpeedTestUrl(), + ), + completion(gcpSpeedTestUrl), + ); + + verify(() => codePushClient.getGCPSpeedTestUrl()).called(1); + }); + }); }); } diff --git a/packages/shorebird_cli/test/src/commands/doctor_command_test.dart b/packages/shorebird_cli/test/src/commands/doctor_command_test.dart index e570c83f..0e6dd6cd 100644 --- a/packages/shorebird_cli/test/src/commands/doctor_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/doctor_command_test.dart @@ -32,6 +32,7 @@ void main() { late Gradlew gradlew; late Java java; late NetworkChecker networkChecker; + late Progress progress; late ShorebirdLogger logger; late ShorebirdEnv shorebirdEnv; late ShorebirdFlutter shorebirdFlutter; @@ -65,6 +66,7 @@ void main() { java = MockJava(); logger = MockShorebirdLogger(); networkChecker = MockNetworkChecker(); + progress = MockProgress(); shorebirdEnv = MockShorebirdEnv(); shorebirdFlutter = MockShorebirdFlutter(); validator = MockValidator(); @@ -76,9 +78,13 @@ void main() { when(() => androidSdk.adbPath).thenReturn(null); when(() => gradlew.exists(any())).thenReturn(false); when(() => java.home).thenReturn(null); + when(() => logger.progress(any())).thenReturn(progress); when( () => networkChecker.checkReachability(), ).thenAnswer((_) async => {}); + when( + () => networkChecker.performGCPSpeedTest(), + ).thenAnswer((_) async => 1.0); when( () => shorebirdEnv.shorebirdEngineRevision, ).thenReturn(shorebirdEngineRevision); @@ -128,11 +134,18 @@ Engine • revision $shorebirdEngineRevision '''), ).called(1); verify(() => networkChecker.checkReachability()).called(1); + verifyNever(() => networkChecker.performGCPSpeedTest()); }); group('--verbose', () { - test('prints additional information (not detected)', () async { + setUp(() { when(() => argResults['verbose']).thenReturn(true); + when( + () => networkChecker.performGCPSpeedTest(), + ).thenAnswer((_) async => 1.23456789); + }); + + test('prints additional information (not detected)', () async { await runWithOverrides(command.run); final notDetectedText = red.wrap('not detected'); @@ -160,7 +173,6 @@ Android Toolchain }); test('prints additional information (detected)', () async { - when(() => argResults['verbose']).thenReturn(true); when(() => androidStudio.path).thenReturn('test-studio-path'); when(() => androidSdk.path).thenReturn('test-sdk-path'); when(() => androidSdk.adbPath).thenReturn('test-adb-path'); @@ -207,13 +219,16 @@ Android Toolchain ); verify(() => networkChecker.checkReachability()).called(1); + verify(() => networkChecker.performGCPSpeedTest()).called(1); + verify( + () => progress.complete('GCP Upload Speed: 1.23 MB/s'), + ).called(1); }); group('when a gradlew executable exists', () { setUp(() { when(() => gradlew.exists(any())).thenReturn(true); when(() => gradlew.version(any())).thenAnswer((_) async => '7.6.3'); - when(() => argResults['verbose']).thenReturn(true); when(() => androidStudio.path).thenReturn('test-studio-path'); when(() => androidSdk.path).thenReturn('test-sdk-path'); when(() => androidSdk.adbPath).thenReturn('test-adb-path'); @@ -262,6 +277,53 @@ Android Toolchain ); }); }); + + group('when gcp speed test fails', () { + setUp(() { + const flutterVersion = '1.2.3'; + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => flutterVersion); + }); + + group('with NetworkCheckerException', () { + setUp(() { + when( + () => networkChecker.performGCPSpeedTest(), + ).thenThrow(const NetworkCheckerException('oops')); + }); + + test('logs error as detail, continues', () async { + await expectLater( + runWithOverrides(command.run), + completes, + ); + + verify( + () => progress.fail('GCP speed test failed: oops'), + ).called(1); + }); + }); + + group('with generic Exception', () { + setUp(() { + when( + () => networkChecker.performGCPSpeedTest(), + ).thenThrow(Exception('oops')); + }); + + test('logs error as detail, continues', () async { + await expectLater( + runWithOverrides(command.run), + completes, + ); + + verify( + () => progress.fail('GCP speed test failed: Exception: oops'), + ).called(1); + }); + }); + }); }); test('runs validators without applying fixes if no fix flag exists', diff --git a/packages/shorebird_cli/test/src/network_checker_test.dart b/packages/shorebird_cli/test/src/network_checker_test.dart index 19a4a093..530c2741 100644 --- a/packages/shorebird_cli/test/src/network_checker_test.dart +++ b/packages/shorebird_cli/test/src/network_checker_test.dart @@ -1,9 +1,12 @@ +import 'dart:async'; import 'dart:io'; +import 'package:clock/clock.dart'; import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/network_checker.dart'; @@ -14,6 +17,7 @@ import 'mocks.dart'; void main() { group(NetworkChecker, () { + late CodePushClientWrapper codePushClientWrapper; late http.Client httpClient; late ShorebirdLogger logger; late Progress progress; @@ -23,6 +27,7 @@ void main() { return runScoped( () => body(), values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), httpClientRef.overrideWith(() => httpClient), loggerRef.overrideWith(() => logger), }, @@ -35,6 +40,7 @@ void main() { }); setUp(() { + codePushClientWrapper = MockCodePushClientWrapper(); httpClient = MockHttpClient(); logger = MockShorebirdLogger(); progress = MockProgress(); @@ -75,5 +81,112 @@ void main() { }); }); }); + + group('performGCPSpeedTest', () { + final gcpUri = Uri.parse('http://localhost'); + + setUp(() { + when(() => codePushClientWrapper.getGCPSpeedTestUrl()).thenAnswer( + (_) async => gcpUri, + ); + }); + + group('when upload fails', () { + setUp(() { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.badGateway, + ), + ); + }); + + test('throws a NetworkCheckerException', () async { + await expectLater( + runWithOverrides(networkChecker.performGCPSpeedTest), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Failed to upload file'), + ), + ), + ); + }); + }); + + group('when upload times out', () { + const uploadTimeout = Duration(milliseconds: 1); + // Make this a healthy multiple of the upload timeout to avoid flakiness + // on slow (read: Windows) CI machines. + final responseTime = uploadTimeout * 5; + setUp(() { + when(() => httpClient.send(any())).thenAnswer( + (_) async { + await Future.delayed(responseTime); + return http.StreamedResponse( + const Stream.empty(), + HttpStatus.noContent, + ); + }, + ); + }); + + test('throws a NetworkCheckerException', () async { + await expectLater( + () => runWithOverrides( + () => networkChecker.performGCPSpeedTest( + uploadTimeout: uploadTimeout, + ), + ), + throwsA( + isA().having( + (e) => e.message, 'message', equals('Upload timed out')), + ), + ); + }); + }); + + group('when upload succeeds', () { + setUp(() { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.noContent, + ), + ); + }); + + test('returns upload rate in MB/s', () async { + /// 2024-10-16 00:00:00 + final start = DateTime.fromMillisecondsSinceEpoch(1729051200000); + + /// 2024-10-16 00:00:01 + final end = DateTime.fromMillisecondsSinceEpoch(1729051201000); + var hasReturnedStart = false; + + final clock = Clock(() { + if (hasReturnedStart) { + return end; + } else { + hasReturnedStart = true; + return start; + } + }); + await withClock(clock, () async { + final speed = + await runWithOverrides(networkChecker.performGCPSpeedTest); + // Our 5MB file took 1 second to upload, so our speed is 5 MB/s. + expect(speed, equals(5.0)); + + final capturedRequest = verify(() => httpClient.send(captureAny())) + .captured + .last as http.MultipartRequest; + expect(capturedRequest.method, equals('POST')); + expect(capturedRequest.url, equals(gcpUri)); + }); + }); + }); + }); }); } diff --git a/packages/shorebird_code_push_client/lib/src/code_push_client.dart b/packages/shorebird_code_push_client/lib/src/code_push_client.dart index c2827cd8..95b39533 100644 --- a/packages/shorebird_code_push_client/lib/src/code_push_client.dart +++ b/packages/shorebird_code_push_client/lib/src/code_push_client.dart @@ -501,6 +501,20 @@ class CodePushClient { ).organizations; } + /// Returns a GCP upload link for measuring upload speed. + Future getGCPSpeedTestUrl() async { + final response = await _httpClient.get( + Uri.parse('$_v1/diagnostics/gcp_upload'), + ); + + if (!response.isSuccess) { + throw _parseErrorResponse(response.statusCode, response.body); + } + + final jsonBody = json.decode(response.body) as Map; + return Uri.parse(jsonBody['upload_url'] as String); + } + /// Closes the client. void close() => _httpClient.close(); diff --git a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart index 4000ee3d..c95a92f7 100644 --- a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart +++ b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart @@ -1978,6 +1978,50 @@ void main() { }); }); + group('getGCPSpeedTestUrl', () { + group('when request fails', () { + setUp(() { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.failedDependency, + ), + ); + }); + + test('throws exception', () async { + expect( + () async => codePushClient.getGCPSpeedTestUrl(), + throwsA( + isA().having( + (e) => e.message, + 'message', + CodePushClient.unknownErrorMessage, + ), + ), + ); + }); + }); + + group('when request succeeds', () { + setUp(() { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + Stream.value( + utf8.encode('{"upload_url": "https://example.com"}'), + ), + HttpStatus.ok, + ), + ); + }); + + test('returns upload_url as parsed Uri', () async { + final url = await codePushClient.getGCPSpeedTestUrl(); + expect(url, equals(Uri.parse('https://example.com'))); + }); + }); + }); + group('close', () { test('closes the underlying client', () { codePushClient.close();