feat: run GCP upload speed test as part of shorebird doctor -v (#2540)

Co-authored-by: Felix Angelov <felix@shorebird.dev>
This commit is contained in:
Bryan Oltman
2024-10-16 11:54:47 -04:00
committed by GitHub
parent 3d57505335
commit 2a40ec035c
10 changed files with 332 additions and 4 deletions
@@ -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<Uri> 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.
@@ -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;
@@ -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<double> 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();
}
}
}
+1 -1
View File
@@ -178,7 +178,7 @@ packages:
source: hosted
version: "0.4.1"
clock:
dependency: transitive
dependency: "direct main"
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
+1
View File
@@ -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
@@ -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);
});
});
});
}
@@ -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',
@@ -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<NetworkCheckerException>().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<void>.delayed(responseTime);
return http.StreamedResponse(
const Stream.empty(),
HttpStatus.noContent,
);
},
);
});
test('throws a NetworkCheckerException', () async {
await expectLater(
() => runWithOverrides(
() => networkChecker.performGCPSpeedTest(
uploadTimeout: uploadTimeout,
),
),
throwsA(
isA<NetworkCheckerException>().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));
});
});
});
});
});
}
@@ -501,6 +501,20 @@ class CodePushClient {
).organizations;
}
/// Returns a GCP upload link for measuring upload speed.
Future<Uri> 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<String, dynamic>;
return Uri.parse(jsonBody['upload_url'] as String);
}
/// Closes the client.
void close() => _httpClient.close();
@@ -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<CodePushException>().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();