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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: clock
|
||||
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user