diff --git a/app_dart/analysis_options.yaml b/app_dart/analysis_options.yaml index f4cf71f90..7a71105d3 100644 --- a/app_dart/analysis_options.yaml +++ b/app_dart/analysis_options.yaml @@ -1,5 +1,10 @@ include: ../analysis_options.yaml +analyzer: + exclude: + # Optional: exclude the generated file if you don't want it analyzed + - hook/build.dart + linter: rules: # a few rules listed below are the ones we would like to exclude from flutter_lint package, for app_dart diff --git a/app_dart/bin/gae_server.dart b/app_dart/bin/gae_server.dart index ac93aac6d..a37142ef9 100644 --- a/app_dart/bin/gae_server.dart +++ b/app_dart/bin/gae_server.dart @@ -11,6 +11,7 @@ import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/server.dart'; import 'package:cocoon_service/src/foundation/appengine_utils.dart'; import 'package:cocoon_service/src/foundation/providers.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/service/big_query.dart'; import 'package:cocoon_service/src/service/build_status_service.dart'; import 'package:cocoon_service/src/service/commit_service.dart'; @@ -143,7 +144,9 @@ Future main() async { ); return runAppEngine( - server, + (HttpRequest request) async { + await server(request.toRequest()); + }, onAcceptingConnections: (InternetAddress address, int port) { final host = address.isLoopback ? 'localhost' : address.host; print('Serving requests at http://$host:$port/'); diff --git a/app_dart/hook/build.dart b/app_dart/hook/build.dart new file mode 100644 index 000000000..7de23c08b --- /dev/null +++ b/app_dart/hook/build.dart @@ -0,0 +1,32 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:native_assets_cli/native_assets_cli.dart'; + +void main(List args) async { + await build(args, (config, output) async { + // 1. Read the source file (e.g., your App Engine config) + final configFile = File('config.yaml'); + final content = await configFile.readAsString(); + + // 2. Define the path for the generated code + // It's best to put this in 'lib/src/generated_config.dart' + final outputFile = File('lib/src/generated_config.dart'); + + // 3. Write the file as a raw string constant + await outputFile.writeAsString(""" +// GENERATED CODE - DO NOT MODIFY BY HAND +// Generated by build.dart hook + +const String configFileContent = + r\'\'\'$content\'\'\'; +"""); + + // 4. (Optional) Tell Dart that this hook depends on the config file + // This ensures that if config.yaml changes, the hook runs again. + output.addDependency(configFile.uri); + }); +} diff --git a/app_dart/lib/cocoon_service.dart b/app_dart/lib/cocoon_service.dart index 27214df51..434bd510e 100644 --- a/app_dart/lib/cocoon_service.dart +++ b/app_dart/lib/cocoon_service.dart @@ -4,6 +4,13 @@ export 'src/foundation/context.dart'; export 'src/foundation/utils.dart'; +export 'src/model/firestore/base.dart' hide AppDocument; +export 'src/model/firestore/commit.dart'; +export 'src/model/firestore/presubmit_check.dart'; +export 'src/model/firestore/presubmit_guard.dart'; +export 'src/model/firestore/suppressed_test.dart'; +export 'src/model/firestore/task.dart'; +export 'src/model/firestore/tree_status_change.dart'; export 'src/request_handlers/check_flaky_builders.dart'; export 'src/request_handlers/create_branch.dart'; export 'src/request_handlers/dart_internal_subscription.dart'; @@ -35,6 +42,7 @@ export 'src/request_handling/authentication.dart'; export 'src/request_handling/cache_request_handler.dart'; export 'src/request_handling/checkrun_authentication.dart'; export 'src/request_handling/dashboard_authentication.dart'; +export 'src/request_handling/http_utils.dart'; export 'src/request_handling/pubsub.dart'; export 'src/request_handling/pubsub_authentication.dart'; export 'src/request_handling/request_handler.dart'; @@ -47,6 +55,7 @@ export 'src/service/build_bucket_client.dart'; export 'src/service/cache_service.dart'; export 'src/service/config.dart'; export 'src/service/firestore.dart'; +export 'src/service/flags/dynamic_config.dart'; export 'src/service/gerrit_service.dart'; export 'src/service/github_checks_service.dart'; export 'src/service/luci_build_service.dart'; diff --git a/app_dart/lib/server.dart b/app_dart/lib/server.dart index dac2ad5ba..bd6c5ff5d 100644 --- a/app_dart/lib/server.dart +++ b/app_dart/lib/server.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; import 'dart:math'; import 'cocoon_service.dart'; @@ -25,7 +24,7 @@ import 'src/service/content_aware_hash_service.dart'; import 'src/service/discord_service.dart'; import 'src/service/scheduler/ci_yaml_fetcher.dart'; -typedef Server = Future Function(HttpRequest); +typedef Server = Future Function(Request); /// Creates a service with the given dependencies. Server createServer({ @@ -404,7 +403,7 @@ Server createServer({ '/readiness_check': ReadinessCheck(config: config), }; - return (HttpRequest request) async { + return (Request request) async { if (handlers.containsKey(request.uri.path)) { final handler = handlers[request.uri.path]!; await handler.service(request); diff --git a/app_dart/lib/src/foundation/github_checks_util.dart b/app_dart/lib/src/foundation/github_checks_util.dart index 6a65f37df..7a1728c81 100644 --- a/app_dart/lib/src/foundation/github_checks_util.dart +++ b/app_dart/lib/src/foundation/github_checks_util.dart @@ -3,13 +3,13 @@ // found in the LICENSE file. import 'dart:core'; -import 'dart:io'; import 'package:cocoon_server/logging.dart'; import 'package:github/github.dart' as github; import 'package:github/hooks.dart'; import 'package:retry/retry.dart'; +import '../request_handling/http_utils.dart'; import '../service/config.dart'; /// Wrapper class for github checkrun service. This is used to simplify diff --git a/app_dart/lib/src/foundation/utils.dart b/app_dart/lib/src/foundation/utils.dart index 222d2ecae..4ac88a966 100644 --- a/app_dart/lib/src/foundation/utils.dart +++ b/app_dart/lib/src/foundation/utils.dart @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_server/logging.dart'; import 'package:github/github.dart'; diff --git a/app_dart/lib/src/generated_config.dart b/app_dart/lib/src/generated_config.dart new file mode 100644 index 000000000..e9ca5e239 --- /dev/null +++ b/app_dart/lib/src/generated_config.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// Generated by build.dart hook + +const String configFileContent = + r'''# Defines the config options for Flutter CI (Cocoon) +# +# The schema for this file is defined in DynamicConfig of +# app_dart/lib/src/service/config.dart + +# TODO(matanlurey): Remove this flag, it is currently unused. +allowManualTreeClosures: true + +backfillerCommitLimit: 50 + +ciYaml: + +contentAwareHashing: + # This will cause PRs that enter the merge queue to wait on CAH hashing + # to happen before scheduling builds. + waitOnContentHash: true + +# Whether to close the MQ guard right after LUCI presubmit completed +# instead of doing that as part of the `check_run` GitHub event handling. +closeMqGuardAfterPresubmit: true + +# Whether to allow the tree status to be suppressed for specific failed tests. +dynamicTestSuppression: true + +# Whether to allow unified check run flow to specific users or to everyone. +unifiedCheckRunFlow: + useForAll: false + useForUsers: + - ievdokdm + - matanlurey +'''; diff --git a/app_dart/lib/src/model/firestore/account.dart b/app_dart/lib/src/model/firestore/account.dart index 50d82b6a3..c9527e147 100644 --- a/app_dart/lib/src/model/firestore/account.dart +++ b/app_dart/lib/src/model/firestore/account.dart @@ -2,11 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; - import 'package:googleapis/firestore/v1.dart' as g; import 'package:path/path.dart' as p; +import '../../request_handling/http_utils.dart'; import '../../service/firestore.dart'; import 'base.dart'; diff --git a/app_dart/lib/src/model/firestore/commit.dart b/app_dart/lib/src/model/firestore/commit.dart index ea2e04886..a9f105d31 100644 --- a/app_dart/lib/src/model/firestore/commit.dart +++ b/app_dart/lib/src/model/firestore/commit.dart @@ -5,15 +5,12 @@ /// @docImport 'task.dart'; library; -import 'dart:io'; - import 'package:github/github.dart'; import 'package:googleapis/firestore/v1.dart' hide Status; import 'package:path/path.dart' as p; import 'package:truncate/truncate.dart'; import '../../../cocoon_service.dart'; -import '../../service/firestore.dart'; import '../commit_ref.dart'; import 'base.dart'; diff --git a/app_dart/lib/src/model/firestore/content_aware_hash_builds.dart b/app_dart/lib/src/model/firestore/content_aware_hash_builds.dart index a7a8ce3c5..e63095ad2 100644 --- a/app_dart/lib/src/model/firestore/content_aware_hash_builds.dart +++ b/app_dart/lib/src/model/firestore/content_aware_hash_builds.dart @@ -2,11 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; - import 'package:googleapis/firestore/v1.dart' as g; import 'package:path/path.dart' as p; +import '../../request_handling/http_utils.dart'; import '../../service/firestore.dart'; import 'base.dart'; diff --git a/app_dart/lib/src/model/firestore/github_build_status.dart b/app_dart/lib/src/model/firestore/github_build_status.dart index 23a43c38c..5d1cf68e7 100644 --- a/app_dart/lib/src/model/firestore/github_build_status.dart +++ b/app_dart/lib/src/model/firestore/github_build_status.dart @@ -7,7 +7,6 @@ import 'package:googleapis/firestore/v1.dart' hide Status; import 'package:path/path.dart' as p; import '../../../cocoon_service.dart'; -import '../../service/firestore.dart'; import 'base.dart'; const String kGithubBuildStatusCollectionId = 'githubBuildStatuses'; diff --git a/app_dart/lib/src/model/firestore/github_gold_status.dart b/app_dart/lib/src/model/firestore/github_gold_status.dart index a6743869a..4aab4ea86 100644 --- a/app_dart/lib/src/model/firestore/github_gold_status.dart +++ b/app_dart/lib/src/model/firestore/github_gold_status.dart @@ -7,7 +7,6 @@ import 'package:googleapis/firestore/v1.dart' hide Status; import 'package:path/path.dart' as p; import '../../../cocoon_service.dart'; -import '../../service/firestore.dart'; import 'base.dart'; const String kGithubGoldStatusCollectionId = 'githubGoldStatuses'; diff --git a/app_dart/lib/src/model/firestore/presubmit_guard.dart b/app_dart/lib/src/model/firestore/presubmit_guard.dart index 8835261cb..5b2e48214 100644 --- a/app_dart/lib/src/model/firestore/presubmit_guard.dart +++ b/app_dart/lib/src/model/firestore/presubmit_guard.dart @@ -13,7 +13,6 @@ import 'package:googleapis/firestore/v1.dart' hide Status; import 'package:path/path.dart' as p; import '../../../cocoon_service.dart'; -import '../../service/firestore.dart'; import 'base.dart'; final class PresubmitGuardId extends AppDocumentId { diff --git a/app_dart/lib/src/model/firestore/task.dart b/app_dart/lib/src/model/firestore/task.dart index 312804ac0..e049e8e70 100644 --- a/app_dart/lib/src/model/firestore/task.dart +++ b/app_dart/lib/src/model/firestore/task.dart @@ -12,7 +12,6 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import '../../../cocoon_service.dart'; -import '../../service/firestore.dart'; import '../bbv2_extension.dart'; import '../ci_yaml/target.dart'; import '../task_ref.dart'; diff --git a/app_dart/lib/src/request_handlers/get_build_status_badge.dart b/app_dart/lib/src/request_handlers/get_build_status_badge.dart index d0d043026..fbf51dc65 100644 --- a/app_dart/lib/src/request_handlers/get_build_status_badge.dart +++ b/app_dart/lib/src/request_handlers/get_build_status_badge.dart @@ -3,11 +3,11 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'package:cocoon_common/rpc_model.dart' as rpc_model; import 'package:meta/meta.dart'; +import '../request_handling/http_utils.dart'; import '../request_handling/request_handler.dart'; import '../request_handling/response.dart'; import 'get_build_status.dart'; @@ -48,12 +48,10 @@ final class GetBuildStatusBadge extends GetBuildStatus { Future get(Request request) async { return Response.string( generateSVG(await super.createResponse(request)), - contentType: _imageSvgXml, + contentType: kImageSvgXml, ); } - static final _imageSvgXml = ContentType.parse('image/svg+xml'); - @visibleForTesting String generateSVG(rpc_model.BuildStatusResponse response) { final passing = response.failingTasks.isEmpty; diff --git a/app_dart/lib/src/request_handlers/get_engine_artifacts_ready.dart b/app_dart/lib/src/request_handlers/get_engine_artifacts_ready.dart index 66dadd9b8..5cdff1e09 100644 --- a/app_dart/lib/src/request_handlers/get_engine_artifacts_ready.dart +++ b/app_dart/lib/src/request_handlers/get_engine_artifacts_ready.dart @@ -2,12 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; - import 'package:googleapis/firestore/v1.dart'; import '../../cocoon_service.dart'; -import '../model/firestore/base.dart'; import '../model/firestore/ci_staging.dart'; import '../request_handling/exceptions.dart'; import '../request_handling/public_api_request_handler.dart'; diff --git a/app_dart/lib/src/request_handlers/get_presubmit_checks.dart b/app_dart/lib/src/request_handlers/get_presubmit_checks.dart index 45ee4987f..78a54ab8c 100644 --- a/app_dart/lib/src/request_handlers/get_presubmit_checks.dart +++ b/app_dart/lib/src/request_handlers/get_presubmit_checks.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'package:cocoon_common/rpc_model.dart'; diff --git a/app_dart/lib/src/request_handlers/get_presubmit_guard.dart b/app_dart/lib/src/request_handlers/get_presubmit_guard.dart index ab2ca007c..0fb259a18 100644 --- a/app_dart/lib/src/request_handlers/get_presubmit_guard.dart +++ b/app_dart/lib/src/request_handlers/get_presubmit_guard.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'package:cocoon_common/guard_status.dart'; import 'package:cocoon_common/rpc_model.dart' as rpc_model; diff --git a/app_dart/lib/src/request_handlers/get_presubmit_guard_summaries.dart b/app_dart/lib/src/request_handlers/get_presubmit_guard_summaries.dart index 238b1fe23..ab846d573 100644 --- a/app_dart/lib/src/request_handlers/get_presubmit_guard_summaries.dart +++ b/app_dart/lib/src/request_handlers/get_presubmit_guard_summaries.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'dart:math'; import 'package:cocoon_common/guard_status.dart'; @@ -12,7 +11,6 @@ import 'package:github/github.dart'; import 'package:meta/meta.dart'; import '../../cocoon_service.dart'; -import '../model/firestore/presubmit_guard.dart'; import '../request_handling/public_api_request_handler.dart'; import '../service/firestore/unified_check_run.dart'; diff --git a/app_dart/lib/src/request_handlers/get_status.dart b/app_dart/lib/src/request_handlers/get_status.dart index eefc7ee49..38d0be1cf 100644 --- a/app_dart/lib/src/request_handlers/get_status.dart +++ b/app_dart/lib/src/request_handlers/get_status.dart @@ -10,7 +10,6 @@ import 'package:github/github.dart'; import 'package:meta/meta.dart'; import '../../cocoon_service.dart'; -import '../model/firestore/commit.dart'; import '../request_handling/public_api_request_handler.dart'; import '../service/build_status_service.dart'; diff --git a/app_dart/lib/src/request_handlers/get_suppressed_tests.dart b/app_dart/lib/src/request_handlers/get_suppressed_tests.dart index 8110d9d60..12ba8dc8a 100644 --- a/app_dart/lib/src/request_handlers/get_suppressed_tests.dart +++ b/app_dart/lib/src/request_handlers/get_suppressed_tests.dart @@ -8,7 +8,6 @@ import 'package:github/github.dart'; import 'package:meta/meta.dart'; import '../../cocoon_service.dart'; -import '../model/firestore/suppressed_test.dart'; import '../request_handling/public_api_request_handler.dart'; /// Request handler to get a list of suppressed tests. diff --git a/app_dart/lib/src/request_handlers/github/webhook_subscription.dart b/app_dart/lib/src/request_handlers/github/webhook_subscription.dart index 8d2982ec0..0e1535fdb 100644 --- a/app_dart/lib/src/request_handlers/github/webhook_subscription.dart +++ b/app_dart/lib/src/request_handlers/github/webhook_subscription.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_common/is_release_branch.dart'; import 'package:cocoon_server/logging.dart'; diff --git a/app_dart/lib/src/request_handlers/postsubmit_luci_subscription.dart b/app_dart/lib/src/request_handlers/postsubmit_luci_subscription.dart index d23ab7ccb..1d305c0ff 100644 --- a/app_dart/lib/src/request_handlers/postsubmit_luci_subscription.dart +++ b/app_dart/lib/src/request_handlers/postsubmit_luci_subscription.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; +import 'package:archive/archive.dart'; import 'package:buildbucket/buildbucket_pb.dart' as bbv2; import 'package:cocoon_common/is_release_branch.dart'; import 'package:cocoon_server/logging.dart'; @@ -77,7 +77,9 @@ final class PostsubmitLuciSubscription extends SubscriptionHandler { log.debug('Updating buildId=${build.id} for result=${build.status}'); // Add build fields that are stored in a separate compressed buffer. - build.mergeFromBuffer(ZLibCodec().decode(buildsPubSub.buildLargeFields)); + build.mergeFromBuffer( + const ZLibDecoder().decodeBytes(buildsPubSub.buildLargeFields), + ); log.info('build ${build.toProto3Json()}'); final fsTask = await fs.Task.fromFirestore(_firestore, userData.taskId); diff --git a/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart b/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart index 474da52cb..a6dcebfdf 100644 --- a/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart +++ b/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; +import 'package:archive/archive.dart'; import 'package:buildbucket/buildbucket_pb.dart' as bbv2; import 'package:cocoon_server/logging.dart'; @@ -80,7 +80,9 @@ final class PresubmitLuciSubscription extends SubscriptionHandler { var build = buildsPubSub.build; // Add build fields that are stored in a separate compressed buffer. - build.mergeFromBuffer(ZLibCodec().decode(buildsPubSub.buildLargeFields)); + build.mergeFromBuffer( + const ZLibDecoder().decodeBytes(buildsPubSub.buildLargeFields), + ); final builderName = build.builder.builder; final tagSet = BuildTags.fromStringPairs(build.tags); diff --git a/app_dart/lib/src/request_handlers/push_gold_status_to_github.dart b/app_dart/lib/src/request_handlers/push_gold_status_to_github.dart index feabf6233..9a12349b5 100644 --- a/app_dart/lib/src/request_handlers/push_gold_status_to_github.dart +++ b/app_dart/lib/src/request_handlers/push_gold_status_to_github.dart @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_server/logging.dart'; import 'package:github/github.dart'; diff --git a/app_dart/lib/src/request_handling/api_request_handler.dart b/app_dart/lib/src/request_handling/api_request_handler.dart index 3ad67f2a2..76ec79f30 100644 --- a/app_dart/lib/src/request_handling/api_request_handler.dart +++ b/app_dart/lib/src/request_handling/api_request_handler.dart @@ -3,13 +3,13 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; +import 'dart:convert'; import 'package:meta/meta.dart'; import 'authentication.dart'; import 'exceptions.dart'; +import 'http_utils.dart'; import 'public_api_request_handler.dart'; import 'request_handler.dart'; @@ -39,7 +39,7 @@ abstract base class ApiRequestHandler extends PublicApiRequestHandler { @override Future service( - HttpRequest request, { + Request request, { Future Function(HttpStatusException)? onError, }) async { AuthenticatedContext context; @@ -47,9 +47,8 @@ abstract base class ApiRequestHandler extends PublicApiRequestHandler { context = await authenticationProvider.authenticate(request); } on Unauthenticated catch (error) { final response = request.response; - response - ..statusCode = HttpStatus.unauthorized - ..write(error.message); + response.statusCode = HttpStatus.unauthorized; + await response.addStream(Stream.value(utf8.encode(error.message))); await response.flush(); await response.close(); return; @@ -64,9 +63,6 @@ abstract base class ApiRequestHandler extends PublicApiRequestHandler { class ApiKey extends RequestKey { const ApiKey._(super.name); - static const ApiKey requestBody = ApiKey._( - 'requestBody', - ); static const ApiKey authContext = ApiKey._('authenticatedContext'); static const ApiKey> requestData = diff --git a/app_dart/lib/src/request_handling/authentication.dart b/app_dart/lib/src/request_handling/authentication.dart index 218ec07a5..56bc72041 100644 --- a/app_dart/lib/src/request_handling/authentication.dart +++ b/app_dart/lib/src/request_handling/authentication.dart @@ -3,12 +3,12 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'package:meta/meta.dart'; import '../foundation/context.dart'; import 'exceptions.dart'; +import 'request_handler.dart'; @immutable abstract interface class AuthenticationProvider { @@ -20,7 +20,7 @@ abstract interface class AuthenticationProvider { /// /// This will throw an [Unauthenticated] exception if the request is /// unauthenticated. - Future authenticate(HttpRequest request); + Future authenticate(Request request); } /// Class that represents an authenticated request having been made, and any diff --git a/app_dart/lib/src/request_handling/cache_request_handler.dart b/app_dart/lib/src/request_handling/cache_request_handler.dart index b8e3c04f8..53664b9d2 100644 --- a/app_dart/lib/src/request_handling/cache_request_handler.dart +++ b/app_dart/lib/src/request_handling/cache_request_handler.dart @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; import 'package:cocoon_common/core_extensions.dart'; @@ -12,6 +11,7 @@ import 'package:meta/meta.dart'; import '../request_handling/request_handler.dart'; import '../service/cache_service.dart'; +import 'http_utils.dart'; import 'response.dart'; /// A [RequestHandler] for serving cached responses. @@ -124,7 +124,7 @@ final class _CachedHttpResponse { return _CachedHttpResponse._( decoded['statusCode'] as int, base64.decode(decoded['body'] as String), - contentType != null ? ContentType.parse(contentType) : null, + contentType != null ? MediaType.parse(contentType) : null, ); } @@ -132,7 +132,7 @@ final class _CachedHttpResponse { final int statusCode; final Uint8List body; - final ContentType? contentType; + final MediaType? contentType; /// Returns a binary encoding of the HTTP response. Uint8List toBytes() { @@ -140,8 +140,7 @@ final class _CachedHttpResponse { json.encode({ 'statusCode': statusCode, 'body': base64.encode(body), - if (contentType case final contentType?) - 'contentType': contentType.value, + if (contentType case final contentType?) 'contentType': '$contentType', }), ); diff --git a/app_dart/lib/src/request_handling/checkrun_authentication.dart b/app_dart/lib/src/request_handling/checkrun_authentication.dart index 10350d1e1..a19fa10f6 100644 --- a/app_dart/lib/src/request_handling/checkrun_authentication.dart +++ b/app_dart/lib/src/request_handling/checkrun_authentication.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'package:cocoon_server/logging.dart'; import 'package:github/github.dart'; @@ -16,7 +15,7 @@ import '../model/google/token_info.dart'; import '../service/firebase_jwt_validator.dart'; import 'exceptions.dart'; -/// Class capable of authenticating [HttpRequest]s from the Checkrun page. +/// Class capable of authenticating [Request]s from the Checkrun page. /// /// There are two types of authentication this class supports: /// @@ -77,7 +76,7 @@ interface class CheckrunAuthentication implements AuthenticationProvider { /// This will throw an [Unauthenticated] exception if the request is /// unauthenticated. @override - Future authenticate(HttpRequest request) async { + Future authenticate(Request request) async { /// Walk through the providers for (final provider in _authenticationChain) { try { @@ -90,7 +89,7 @@ interface class CheckrunAuthentication implements AuthenticationProvider { } } -/// Class capable of authenticating [HttpRequest]s from the Checkrun page. +/// Class capable of authenticating [Request]s from the Checkrun page. class GithubAuthentication implements AuthenticationProvider { GithubAuthentication({ required CacheService cache, @@ -118,10 +117,9 @@ class GithubAuthentication implements AuthenticationProvider { /// Attempt to validate a JWT as a Firebase token. /// And then validate whether the token has flutter repo write permissions. @override - Future authenticate(HttpRequest request) async { + Future authenticate(Request request) async { try { - if (request.headers.value('X-Flutter-IdToken') - case final idTokenFromHeader?) { + if (request.header('X-Flutter-IdToken') case final idTokenFromHeader?) { final token = await _validator.decodeAndVerify(idTokenFromHeader); log.info('authing with github.com'); return authenticateGithub( diff --git a/app_dart/lib/src/request_handling/dashboard_authentication.dart b/app_dart/lib/src/request_handling/dashboard_authentication.dart index 62139ec42..b6f65cac2 100644 --- a/app_dart/lib/src/request_handling/dashboard_authentication.dart +++ b/app_dart/lib/src/request_handling/dashboard_authentication.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'package:cocoon_server/logging.dart'; import 'package:meta/meta.dart'; @@ -16,7 +15,7 @@ import '../model/google/token_info.dart'; import '../service/firebase_jwt_validator.dart'; import 'exceptions.dart'; -/// Class capable of authenticating [HttpRequest]s from the Dashboard +/// Class capable of authenticating [Request]s from the Dashboard /// /// There are two types of authentication this class supports: /// @@ -76,7 +75,7 @@ interface class DashboardAuthentication implements AuthenticationProvider { /// This will throw an [Unauthenticated] exception if the request is /// unauthenticated. @override - Future authenticate(HttpRequest request) async { + Future authenticate(Request request) async { /// Walk through the providers for (final provider in _authenticationChain) { try { @@ -116,10 +115,9 @@ class DashboardFirebaseAuthentication implements AuthenticationProvider { /// NOTE: Until we fully switch over to Firebase; we could have a mix of JWT /// coming into cocoon. This should not be fatal. @override - Future authenticate(HttpRequest request) async { + Future authenticate(Request request) async { try { - if (request.headers.value('X-Flutter-IdToken') - case final idTokenFromHeader?) { + if (request.header('X-Flutter-IdToken') case final idTokenFromHeader?) { final token = await _validator.decodeAndVerify(idTokenFromHeader); log.info('authed with firebase: ${token.email}'); return authenticateFirebase( @@ -182,8 +180,8 @@ class DashboardCronAuthentication implements AuthenticationProvider { final ClientContextProvider _clientContextProvider; @override - Future authenticate(HttpRequest request) async { - if (request.headers.value('X-Appengine-Cron') == 'true') { + Future authenticate(Request request) async { + if (request.header('X-Appengine-Cron') == 'true') { return AuthenticatedContext( clientContext: _clientContextProvider(), email: 'CRON_JOB', diff --git a/app_dart/lib/src/request_handling/exceptions.dart b/app_dart/lib/src/request_handling/exceptions.dart index 28d47720a..a8683ca07 100644 --- a/app_dart/lib/src/request_handling/exceptions.dart +++ b/app_dart/lib/src/request_handling/exceptions.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; +import 'http_utils.dart'; /// An exception that may be thrown by a [RequestHandler] to trigger an error /// HTTP response. diff --git a/app_dart/lib/src/request_handling/http_io.dart b/app_dart/lib/src/request_handling/http_io.dart new file mode 100644 index 000000000..6630df771 --- /dev/null +++ b/app_dart/lib/src/request_handling/http_io.dart @@ -0,0 +1,104 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cocoon_common/core_extensions.dart'; + +import 'http_utils.dart'; +import 'request_handler.dart'; + +/// Creates a [Request] by wrapping an existing [HttpRequest]. +extension ToRequest on HttpRequest { + Request toRequest() => _HttpRequest(this); +} + +/// A request that is backed by an [HttpRequest]. +final class _HttpRequest implements Request { + _HttpRequest(this._request); + final HttpRequest _request; + + @override + Uri get uri => _request.uri; + + @override + String get method => _request.method; + + @override + late final RequestResponse response = _HttpResponse(_request.response); + + @override + String? header(String name) { + return _request.headers.value(name); + } + + @override + Future readBodyAsBytes() async { + if (_bodyAsBytes case final previousCall?) { + return previousCall; + } + final builder = await _request.fold(BytesBuilder(copy: false), ( + builder, + data, + ) { + builder.add(data); + return builder; + }); + return _bodyAsBytes = builder.takeBytes(); + } + + Uint8List? _bodyAsBytes; + + @override + Future readBodyAsString() async { + return utf8.decode(await readBodyAsBytes()); + } + + @override + Future> readBodyAsJson() async { + final bytes = await readBodyAsBytes(); + return bytes.isEmpty ? {} : bytes.parseAsJsonObject(); + } +} + +final class _HttpResponse implements RequestResponse { + _HttpResponse(this._response); + final HttpResponse _response; + + @override + int get statusCode => _response.statusCode; + @override + set statusCode(int value) => _response.statusCode = value; + + @override + set contentType(MediaType? value) { + if (value != null) { + _response.headers.contentType = ContentType( + value.type, + value.subtype, + charset: value.parameters['charset'], + ); + } else { + _response.headers.contentType = null; + } + } + + @override + Future addStream(Stream stream) => + _response.addStream(stream); + + @override + Future flush() => _response.flush(); + + @override + Future close() => _response.close(); + + @override + Future redirect( + Uri location, { + int status = HttpStatus.movedTemporarily, + }) => _response.redirect(location, status: status); +} diff --git a/app_dart/lib/src/request_handling/http_utils.dart b/app_dart/lib/src/request_handling/http_utils.dart new file mode 100644 index 000000000..b564b8655 --- /dev/null +++ b/app_dart/lib/src/request_handling/http_utils.dart @@ -0,0 +1,62 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:http_parser/http_parser.dart'; + +export 'package:http_parser/http_parser.dart' show MediaType; + +/// A collection of HTTP status codes. +/// +/// This is used instead of 'dart:io' to keep the library platform-neutral. +class HttpStatus { + static const int ok = 200; + static const int accepted = 202; + static const int permanentRedirect = 308; + static const int movedTemporarily = 302; + static const int badRequest = 400; + static const int unauthorized = 401; + static const int forbidden = 403; + static const int notFound = 404; + static const int methodNotAllowed = 405; + static const int conflict = 409; + static const int internalServerError = 500; + static const int serviceUnavailable = 503; +} + +/// A collection of HTTP header names. +/// +/// This is used instead of 'dart:io' to keep the library platform-neutral. +class HttpHeaders { + static const String contentTypeHeader = 'content-type'; + static const String acceptHeader = 'accept'; + static const String authorizationHeader = 'authorization'; +} + +final MediaType kContentTypeText = MediaType('text', 'plain', { + 'charset': 'utf-8', +}); +final MediaType kContentTypeHtml = MediaType('text', 'html', { + 'charset': 'utf-8', +}); +final MediaType kContentTypeJson = MediaType('application', 'json', { + 'charset': 'utf-8', +}); +final MediaType kContentTypeBinary = MediaType('application', 'octet-stream'); +final MediaType kImageSvgXml = MediaType('image', 'svg+xml'); + +/// Exception thrown when an HTTP request fails. +class HttpException implements Exception { + HttpException(this.message); + final String message; + @override + String toString() => 'HttpException: $message'; +} + +/// Exception thrown when a socket operation fails. +class SocketException implements Exception { + SocketException(this.message); + final String message; + @override + String toString() => 'SocketException: $message'; +} diff --git a/app_dart/lib/src/request_handling/pubsub_authentication.dart b/app_dart/lib/src/request_handling/pubsub_authentication.dart index eb6a290da..460509e8b 100644 --- a/app_dart/lib/src/request_handling/pubsub_authentication.dart +++ b/app_dart/lib/src/request_handling/pubsub_authentication.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; import 'package:cocoon_server/logging.dart'; import 'package:googleapis/oauth2/v2.dart'; @@ -14,7 +13,7 @@ import '../foundation/providers.dart'; import '../foundation/typedefs.dart'; import 'exceptions.dart'; -/// Class capable of authenticating [HttpRequest]s for PubSub messages. +/// Class capable of authenticating [Request]s for PubSub messages. /// /// This class implements an ACL on a [RequestHandler] to ensure only automated /// systems can access the endpoints. @@ -58,8 +57,8 @@ class PubsubAuthenticationProvider implements AuthenticationProvider { /// This will throw an [Unauthenticated] exception if the request is /// unauthenticated. @override - Future authenticate(HttpRequest request) async { - final idToken = request.headers.value(HttpHeaders.authorizationHeader); + Future authenticate(Request request) async { + final idToken = request.header(HttpHeaders.authorizationHeader); final clientContext = clientContextProvider(); diff --git a/app_dart/lib/src/request_handling/request_handler.dart b/app_dart/lib/src/request_handling/request_handler.dart index 693f6fa38..228a071ab 100644 --- a/app_dart/lib/src/request_handling/request_handler.dart +++ b/app_dart/lib/src/request_handling/request_handler.dart @@ -3,11 +3,8 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; -import 'package:cocoon_common/core_extensions.dart'; import 'package:cocoon_server/logging.dart'; import 'package:meta/meta.dart'; @@ -30,7 +27,7 @@ abstract base class RequestHandler { /// should override one of [get] or [post], depending on which methods /// they support. Future service( - HttpRequest request, { + Request request, { Future Function(HttpStatusException)? onError, }) async { final response = request.response; @@ -39,10 +36,10 @@ abstract base class RequestHandler { Response body; switch (request.method) { case 'GET': - body = await get(Request.fromHttpRequest(request)); + body = await get(request); break; case 'POST': - body = await post(Request.fromHttpRequest(request)); + body = await post(request); break; default: throw MethodNotAllowed(request.method); @@ -69,8 +66,8 @@ abstract base class RequestHandler { /// Responds (using [response]). /// /// Returns a future that completes when [response] has been closed. - Future _respond(HttpResponse response, Response body) async { - response.headers.contentType = body.contentType; + Future _respond(RequestResponse response, Response body) async { + response.contentType = body.contentType; response.statusCode = body.statusCode; await response.addStream(body.body); await response.flush(); @@ -113,13 +110,16 @@ abstract base class RequestHandler { } /// A request received on a [RequestHandler]. -abstract mixin class Request { - /// Creates a [Request] by wrapping an existing [HttpRequest]. - factory Request.fromHttpRequest(HttpRequest request) = _HttpRequest; - +abstract class Request { /// URL the request was served to, including query parameters. Uri get uri; + /// The HTTP method of the request. + String get method; + + /// The response that will be sent to the client. + RequestResponse get response; + /// Returns the value for the header with the given [name]. /// /// The value must not have more than one value. @@ -133,46 +133,25 @@ abstract mixin class Request { Future readBodyAsBytes(); /// Reads the body as a UTF-8 string. - Future readBodyAsString() async { - return utf8.decode(await readBodyAsBytes()); - } + Future readBodyAsString(); /// Reads the body as a JSON object. - Future> readBodyAsJson() async { - final bytes = await readBodyAsBytes(); - return bytes.isEmpty ? {} : bytes.parseAsJsonObject(); - } + Future> readBodyAsJson(); } -/// A request that is backed by an [HttpRequest]. -final class _HttpRequest with Request { - _HttpRequest(this._request); - final HttpRequest _request; - - @override - Uri get uri => _request.uri; - - @override - String? header(String name) { - return _request.headers.value(name); - } +abstract interface class RequestResponse { + int get statusCode; + set statusCode(int value); - @override - Future readBodyAsBytes() async { - if (_bodyAsBytes case final previousCall?) { - return previousCall; - } - final builder = await _request.fold(BytesBuilder(copy: false), ( - builder, - data, - ) { - builder.add(data); - return builder; - }); - return _bodyAsBytes = builder.takeBytes(); - } + set contentType(MediaType? value); - Uint8List? _bodyAsBytes; + Future addStream(Stream stream); + Future flush(); + Future close(); + Future redirect( + Uri location, { + int status = HttpStatus.movedTemporarily, + }); } /// A key that can be used to index a value within the request context. @@ -185,13 +164,6 @@ class RequestKey { final String name; - static const RequestKey request = RequestKey( - 'request', - ); - static const RequestKey response = RequestKey( - 'response', - ); - @override String toString() => '$runtimeType($name)'; } diff --git a/app_dart/lib/src/request_handling/response.dart b/app_dart/lib/src/request_handling/response.dart index d39134a2e..cb2d7a1dd 100644 --- a/app_dart/lib/src/request_handling/response.dart +++ b/app_dart/lib/src/request_handling/response.dart @@ -3,11 +3,12 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; import 'package:meta/meta.dart'; +import 'http_utils.dart'; + /// An HTTP response returned by a request handler. /// /// A response encapsulates: @@ -26,19 +27,19 @@ abstract final class Response { /// Creates an UTF-8 string response of [content]. /// - /// By default, uses [ContentType.text] and [HttpStatus.ok]. + /// By default, uses [kContentTypeText] and [HttpStatus.ok]. factory Response.string( String content, { // - ContentType? contentType, + MediaType? contentType, int statusCode, }) = _StringBody; /// Creates a byte-encoded stream of [content]. /// - /// By default, uses [ContentType.binary] and [HttpStatus.ok]. + /// By default, uses [kContentTypeBinary] and [HttpStatus.ok]. factory Response.stream( Stream content, { // - ContentType? contentType, + MediaType? contentType, int statusCode, }) = _StreamBody; @@ -48,14 +49,14 @@ abstract final class Response { /// that defines a `toJson()` method that returns a JSON type, or a [List] or /// [Map] of other JSON types). /// - /// By default, uses [ContentType.json] and [HttpStatus.ok]. + /// By default, uses [kContentTypeJson] and [HttpStatus.ok]. factory Response.json(Object? value, {int statusCode}) = _JsonBody; /// A [Response] with an _empty_ [body] and [HttpStatus.ok]. static const Response emptyOk = _EmptyBody(); /// Format of the body. - ContentType? get contentType; + MediaType? get contentType; /// Status code of the response. final int statusCode; @@ -71,7 +72,7 @@ final class _EmptyBody extends Response { const _EmptyBody(); @override - ContentType? get contentType => null; + MediaType? get contentType => null; @override Stream get body => const Stream.empty(); @@ -81,14 +82,14 @@ final class _StringBody extends Response { const _StringBody( this._content, { // super.statusCode, - ContentType? contentType, + MediaType? contentType, }) : _contentType = contentType; final String _content; @override - ContentType get contentType => _contentType ?? ContentType.text; - final ContentType? _contentType; + MediaType get contentType => _contentType ?? kContentTypeText; + final MediaType? _contentType; @override Stream get body { @@ -106,7 +107,7 @@ final class _JsonBody extends Response { final Object? _content; @override - ContentType get contentType => ContentType.json; + MediaType get contentType => kContentTypeJson; static final _utf8JsonEncoder = JsonUtf8Encoder(); @override @@ -126,12 +127,12 @@ final class _StreamBody extends Response { const _StreamBody( this._stream, { // super.statusCode, - ContentType? contentType, + MediaType? contentType, }) : _contentType = contentType; @override - ContentType get contentType => _contentType ?? ContentType.binary; - final ContentType? _contentType; + MediaType get contentType => _contentType ?? kContentTypeBinary; + final MediaType? _contentType; final Stream _stream; diff --git a/app_dart/lib/src/request_handling/static_file_handler.dart b/app_dart/lib/src/request_handling/static_file_handler.dart index 8a612c862..5005a76a1 100644 --- a/app_dart/lib/src/request_handling/static_file_handler.dart +++ b/app_dart/lib/src/request_handling/static_file_handler.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io' show ContentType; import 'dart:typed_data'; import 'package:file/file.dart'; @@ -54,7 +53,7 @@ final class StaticFileHandler extends RequestHandler { 'application/octet-stream'; return Response.stream( file.openRead().cast(), - contentType: ContentType.parse(mimeType), + contentType: MediaType.parse(mimeType), ); } else { throw NotFoundException(resultPath); diff --git a/app_dart/lib/src/request_handling/subscription_handler.dart b/app_dart/lib/src/request_handling/subscription_handler.dart index 77234d49f..a9ae69210 100644 --- a/app_dart/lib/src/request_handling/subscription_handler.dart +++ b/app_dart/lib/src/request_handling/subscription_handler.dart @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; import 'package:cocoon_server/logging.dart'; @@ -15,6 +14,7 @@ import '../service/cache_service.dart'; import 'api_request_handler.dart'; import 'authentication.dart'; import 'exceptions.dart'; +import 'http_utils.dart'; import 'pubsub_authentication.dart'; import 'request_handler.dart'; @@ -56,7 +56,7 @@ abstract base class SubscriptionHandler extends RequestHandler { @override Future service( - HttpRequest request, { + Request request, { Future Function(HttpStatusException)? onError, }) async { AuthenticatedContext authContext; @@ -65,22 +65,20 @@ abstract base class SubscriptionHandler extends RequestHandler { authContext = await auth.authenticate(request); } on Unauthenticated catch (error) { final response = request.response; - response - ..statusCode = HttpStatus.unauthorized - ..write(error.message); + response.statusCode = HttpStatus.unauthorized; + await response.addStream(Stream.value(utf8.encode(error.message))); await response.flush(); await response.close(); return; } - List body; + Uint8List body; try { - body = await request.expand((List chunk) => chunk).toList(); + body = await request.readBodyAsBytes(); } catch (error) { final response = request.response; - response - ..statusCode = HttpStatus.internalServerError - ..write('$error'); + response.statusCode = HttpStatus.internalServerError; + await response.addStream(Stream.value(utf8.encode('$error'))); await response.flush(); await response.close(); return; @@ -94,9 +92,8 @@ abstract base class SubscriptionHandler extends RequestHandler { pubSubPushMessage = PubSubPushMessage.fromJson(json); } catch (error) { final response = request.response; - response - ..statusCode = HttpStatus.internalServerError - ..write('$error'); + response.statusCode = HttpStatus.internalServerError; + await response.addStream(Stream.value(utf8.encode('$error'))); await response.flush(); await response.close(); return; @@ -119,9 +116,11 @@ abstract base class SubscriptionHandler extends RequestHandler { if (messageLock != null) { // No-op - There's already a write lock for this message log.info('Ignoring $messageId, we already are/were writing a message'); - final response = request.response - ..statusCode = HttpStatus.ok - ..write('$messageId was already processed'); + final response = request.response; + response.statusCode = HttpStatus.ok; + await response.addStream( + Stream.value(utf8.encode('$messageId was already processed')), + ); await response.flush(); await response.close(); return; diff --git a/app_dart/lib/src/request_handling/swarming_authentication.dart b/app_dart/lib/src/request_handling/swarming_authentication.dart index f2a1b0228..ae0a412de 100644 --- a/app_dart/lib/src/request_handling/swarming_authentication.dart +++ b/app_dart/lib/src/request_handling/swarming_authentication.dart @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_server/logging.dart'; import 'package:meta/meta.dart'; @@ -15,7 +14,7 @@ import '../foundation/typedefs.dart'; import '../model/google/token_info.dart'; import 'exceptions.dart'; -/// Class capable of authenticating [HttpRequest]s for infra endpoints. +/// Class capable of authenticating [Request]s for infra endpoints. /// /// This class implements an ACL on a [RequestHandler] to ensure only automated /// systems can access the endpoints. @@ -62,8 +61,8 @@ class SwarmingAuthenticationProvider implements AuthenticationProvider { /// This will throw an [Unauthenticated] exception if the request is /// unauthenticated. @override - Future authenticate(HttpRequest request) async { - final swarmingToken = request.headers.value(kSwarmingTokenHeader); + Future authenticate(Request request) async { + final swarmingToken = request.header(kSwarmingTokenHeader); final clientContext = clientContextProvider(); diff --git a/app_dart/lib/src/service/build_bucket_client.dart b/app_dart/lib/src/service/build_bucket_client.dart index 89df404e0..282fd4797 100644 --- a/app_dart/lib/src/service/build_bucket_client.dart +++ b/app_dart/lib/src/service/build_bucket_client.dart @@ -3,13 +3,13 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'package:buildbucket/buildbucket_pb.dart' as bbv2; import 'package:cocoon_server/logging.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; +import '../request_handling/http_utils.dart'; import 'access_token_provider.dart'; // TODO generalize the two clients to remove this. diff --git a/app_dart/lib/src/service/build_status_service.dart b/app_dart/lib/src/service/build_status_service.dart index aebde8927..810a98a9a 100644 --- a/app_dart/lib/src/service/build_status_service.dart +++ b/app_dart/lib/src/service/build_status_service.dart @@ -12,8 +12,6 @@ import 'package:meta/meta.dart'; import '../../cocoon_service.dart'; import '../model/firestore/github_build_status.dart'; -import '../model/firestore/suppressed_test.dart'; -import '../model/firestore/tree_status_change.dart'; import 'build_status_provider/commit_tasks_status.dart'; /// Class that calculates the current build status. diff --git a/app_dart/lib/src/service/firestore.dart b/app_dart/lib/src/service/firestore.dart index b8ca80526..a9fa9cf7c 100644 --- a/app_dart/lib/src/service/firestore.dart +++ b/app_dart/lib/src/service/firestore.dart @@ -13,11 +13,9 @@ import 'package:googleapis/firestore/v1.dart'; import 'package:meta/meta.dart'; import '../../cocoon_service.dart'; -import '../model/firestore/commit.dart'; import '../model/firestore/github_build_status.dart'; import '../model/firestore/github_gold_status.dart'; -import '../model/firestore/task.dart'; import 'firestore/commit_and_tasks.dart'; export '../model/common/firestore_extensions.dart'; diff --git a/app_dart/lib/src/service/flags/dynamic_config.dart b/app_dart/lib/src/service/flags/dynamic_config.dart index 8c246ceb4..ec2789c8e 100644 --- a/app_dart/lib/src/service/flags/dynamic_config.dart +++ b/app_dart/lib/src/service/flags/dynamic_config.dart @@ -6,13 +6,11 @@ /// @docImport 'dynamic_config_updater.dart'; library; -import 'dart:io' as io; - import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; -import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; +import '../../generated_config.dart'; import 'ci_yaml_flags.dart'; import 'content_aware_hashing_flags.dart'; import 'dynamic_config_updater.dart'; @@ -126,26 +124,9 @@ final class DynamicConfig { return DynamicConfigUpdater().fetchDynamicConfig(); } - /// Returns the latest copy of [DynamicConfig] fetched from the repository. - static Future fromLocalFileSystem() async { - final execPath = io.Platform.resolvedExecutable; - - // Walk backwards until the root of the Cocoon repository is found. - var dir = io.File(execPath).parent; - while (dir.path != dir.parent.path) { - final gitDir = io.Directory(p.join(dir.path, '.git')); - if (await gitDir.exists()) { - break; - } - dir = dir.parent; - } - - final configPath = io.File(p.join(dir.path, 'app_dart', 'config.yaml')); - if (!await configPath.exists()) { - throw StateError('Could not find config.yaml at ${configPath.path}'); - } - - final yaml = loadYaml(await configPath.readAsString()) as YamlMap; + /// Returns the copy of [DynamicConfig] that was created at build time. + static DynamicConfig fromLocalFileSystem() { + final yaml = loadYaml(configFileContent) as YamlMap; return DynamicConfig.fromYaml(yaml); } diff --git a/app_dart/lib/src/service/flags/dynamic_config_updater.dart b/app_dart/lib/src/service/flags/dynamic_config_updater.dart index cf2e967f5..49519f174 100644 --- a/app_dart/lib/src/service/flags/dynamic_config_updater.dart +++ b/app_dart/lib/src/service/flags/dynamic_config_updater.dart @@ -11,7 +11,6 @@ import 'package:yaml/yaml.dart' show YamlMap, loadYaml; import '../../../cocoon_service.dart'; import '../../foundation/providers.dart' show Providers; import '../../foundation/typedefs.dart' show HttpClientProvider; -import 'dynamic_config.dart'; /// Responsibly polls for configuration changes to our service config. /// diff --git a/app_dart/lib/src/service/gerrit_service.dart b/app_dart/lib/src/service/gerrit_service.dart index aac469393..0bb7d2ee8 100644 --- a/app_dart/lib/src/service/gerrit_service.dart +++ b/app_dart/lib/src/service/gerrit_service.dart @@ -4,7 +4,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_server/logging.dart'; import 'package:github/github.dart'; @@ -14,6 +13,7 @@ import 'package:meta/meta.dart'; import '../model/gerrit/commit.dart'; import '../request_handling/exceptions.dart'; +import '../request_handling/http_utils.dart'; import 'config.dart'; /// Communicates with gerrit APIs https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html diff --git a/app_dart/lib/src/service/luci_build_service.dart b/app_dart/lib/src/service/luci_build_service.dart index 4db9d0798..50c66a8b5 100644 --- a/app_dart/lib/src/service/luci_build_service.dart +++ b/app_dart/lib/src/service/luci_build_service.dart @@ -19,7 +19,6 @@ import '../../cocoon_service.dart'; import '../foundation/github_checks_util.dart'; import '../model/ci_yaml/target.dart'; import '../model/commit_ref.dart'; -import '../model/firestore/base.dart'; import '../model/firestore/pr_check_runs.dart' as fs; import '../model/firestore/task.dart' as fs; import '../model/github/checks.dart' as cocoon_checks; diff --git a/app_dart/lib/src/service/scheduler.dart b/app_dart/lib/src/service/scheduler.dart index f4a034a38..904acc9ec 100644 --- a/app_dart/lib/src/service/scheduler.dart +++ b/app_dart/lib/src/service/scheduler.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'dart:math'; import 'package:cocoon_common/task_status.dart'; @@ -34,6 +33,7 @@ import '../model/github/checks.dart' as cocoon_checks; import '../model/github/checks.dart' show MergeGroup; import '../model/github/workflow_job.dart'; import '../model/proto/internal/scheduler.pb.dart' as pb; +import '../request_handling/http_utils.dart'; import 'big_query.dart'; import 'cache_service.dart'; import 'config.dart'; diff --git a/app_dart/lib/src/service/scheduler/process_check_run_result.dart b/app_dart/lib/src/service/scheduler/process_check_run_result.dart index f948409cc..4db96218a 100644 --- a/app_dart/lib/src/service/scheduler/process_check_run_result.dart +++ b/app_dart/lib/src/service/scheduler/process_check_run_result.dart @@ -5,11 +5,10 @@ /// @docImport 'package:cocoon_service/src/service/scheduler.dart'; library; -import 'dart:io'; - import 'package:cocoon_server/logging.dart'; import 'package:meta/meta.dart'; +import '../../request_handling/http_utils.dart'; import '../../request_handling/response.dart'; /// Possible results for [Scheduler.processCheckRun]. diff --git a/app_dart/pubspec.yaml b/app_dart/pubspec.yaml index 9acaaa86d..a75a91494 100644 --- a/app_dart/pubspec.yaml +++ b/app_dart/pubspec.yaml @@ -14,6 +14,7 @@ environment: dependencies: appengine: 0.13.11 + archive: ^4.0.7 args: ^2.6.0 buildbucket: path: ../packages/buildbucket-dart @@ -35,6 +36,7 @@ dependencies: graphql: ^5.2.3 grpc: ^4.0.1 http: ^1.2.1 + http_parser: ^4.1.2 jose_plus: ^0.4.6 json_annotation: ^4.9.0 logging: ^1.3.0 @@ -65,6 +67,7 @@ dev_dependencies: fake_async: ^1.3.3 json_serializable: ^6.9.4 mockito: ^5.4.6 + native_assets_cli: ^0.18.0 platform: ^3.1.6 test: ^1.25.15 diff --git a/app_dart/test/foundation/utils_test.dart b/app_dart/test/foundation/utils_test.dart index 49a69b2b6..61d80e49f 100644 --- a/app_dart/test/foundation/utils_test.dart +++ b/app_dart/test/foundation/utils_test.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_common/cocoon_common.dart'; import 'package:cocoon_common_test/cocoon_common_test.dart'; @@ -12,6 +11,7 @@ import 'package:cocoon_server/logging.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/src/foundation/utils.dart'; import 'package:cocoon_service/src/model/ci_yaml/target.dart'; +import 'package:cocoon_service/src/request_handling/http_utils.dart'; import 'package:cocoon_service/src/service/get_files_changed.dart'; import 'package:github/github.dart'; import 'package:http/http.dart' as http; diff --git a/app_dart/test/model/firestore/suppressed_test_test.dart b/app_dart/test/model/firestore/suppressed_test_test.dart index e64498c6e..79f9ee786 100644 --- a/app_dart/test/model/firestore/suppressed_test_test.dart +++ b/app_dart/test/model/firestore/suppressed_test_test.dart @@ -4,7 +4,6 @@ import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/cocoon_service.dart'; -import 'package:cocoon_service/src/model/firestore/suppressed_test.dart'; import 'package:googleapis/firestore/v1.dart'; import 'package:test/test.dart'; diff --git a/app_dart/test/request_handlers/get_presubmit_checks_test.dart b/app_dart/test/request_handlers/get_presubmit_checks_test.dart index b9f6e6970..c146ee46f 100644 --- a/app_dart/test/request_handlers/get_presubmit_checks_test.dart +++ b/app_dart/test/request_handlers/get_presubmit_checks_test.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_common/rpc_model.dart'; import 'package:cocoon_common/task_status.dart'; diff --git a/app_dart/test/request_handlers/get_suppressed_tests_test.dart b/app_dart/test/request_handlers/get_suppressed_tests_test.dart index d10cc6a2a..35d1cc1e7 100644 --- a/app_dart/test/request_handlers/get_suppressed_tests_test.dart +++ b/app_dart/test/request_handlers/get_suppressed_tests_test.dart @@ -7,8 +7,6 @@ import 'dart:convert'; import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/cocoon_service.dart'; -import 'package:cocoon_service/src/model/firestore/suppressed_test.dart'; -import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; import 'package:test/test.dart'; import '../src/request_handling/api_request_handler_tester.dart'; diff --git a/app_dart/test/request_handlers/presubmit_luci_subscription_test.dart b/app_dart/test/request_handlers/presubmit_luci_subscription_test.dart index 3432b2de4..4a71a6a97 100644 --- a/app_dart/test/request_handlers/presubmit_luci_subscription_test.dart +++ b/app_dart/test/request_handlers/presubmit_luci_subscription_test.dart @@ -11,7 +11,6 @@ import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/model/commit_ref.dart'; import 'package:cocoon_service/src/model/common/presubmit_completed_check.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; -import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; import 'package:cocoon_service/src/service/luci_build_service/build_tags.dart'; import 'package:cocoon_service/src/service/luci_build_service/user_data.dart'; import 'package:fixnum/fixnum.dart'; diff --git a/app_dart/test/request_handlers/rerun_prod_task_test.dart b/app_dart/test/request_handlers/rerun_prod_task_test.dart index fc518d34e..74e2604df 100644 --- a/app_dart/test/request_handlers/rerun_prod_task_test.dart +++ b/app_dart/test/request_handlers/rerun_prod_task_test.dart @@ -9,7 +9,6 @@ import 'package:cocoon_server/logging.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/model/firestore/task.dart' as fs; -import 'package:cocoon_service/src/model/firestore/task.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; diff --git a/app_dart/test/request_handling/api_request_handler_test.dart b/app_dart/test/request_handling/api_request_handler_test.dart index da367846b..b8fb396c0 100644 --- a/app_dart/test/request_handling/api_request_handler_test.dart +++ b/app_dart/test/request_handling/api_request_handler_test.dart @@ -12,6 +12,7 @@ import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server/logging.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/src/request_handling/api_request_handler.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/request_handling/request_handler.dart'; import 'package:cocoon_service/src/request_handling/response.dart'; import 'package:gcloud/service_scope.dart' as ss; @@ -30,7 +31,7 @@ void main() { runZoned(() { return ss.fork(() { ss.register(#appengine.logging, log); - return handler.service(request); + return handler.service(request.toRequest()); }); }); }); diff --git a/app_dart/test/request_handling/cache_request_handler_test.dart b/app_dart/test/request_handling/cache_request_handler_test.dart index 146bb5a4b..3f0ebfffb 100644 --- a/app_dart/test/request_handling/cache_request_handler_test.dart +++ b/app_dart/test/request_handling/cache_request_handler_test.dart @@ -3,11 +3,11 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/src/request_handling/cache_request_handler.dart'; +import 'package:cocoon_service/src/request_handling/http_utils.dart'; import 'package:cocoon_service/src/request_handling/response.dart'; import 'package:cocoon_service/src/service/cache_service.dart'; import 'package:test/test.dart'; @@ -61,7 +61,7 @@ void main() { body: Response.string( 'hello!', statusCode: 400, - contentType: ContentType.json, + contentType: kContentTypeJson, ), config: FakeConfig(), ); @@ -76,11 +76,7 @@ void main() { expect(response.statusCode, 400); expect( response.contentType, - isA().having( - (c) => c.value, - 'value', - ContentType.json.value, - ), + isA().having((c) => '$c', 'type', kContentTypeJson.toString()), ); }); diff --git a/app_dart/test/request_handling/checkrun_authentication_test.dart b/app_dart/test/request_handling/checkrun_authentication_test.dart index 7ba78a78a..a484c6e9a 100644 --- a/app_dart/test/request_handling/checkrun_authentication_test.dart +++ b/app_dart/test/request_handling/checkrun_authentication_test.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server_test/mocks.mocks.dart'; @@ -12,6 +11,7 @@ import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/model/google/firebase_jwt_claim.dart'; import 'package:cocoon_service/src/model/google/token_info.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/service/github_service.dart'; import 'package:github/github.dart'; import 'package:http/http.dart' as http; @@ -82,7 +82,7 @@ void main() { config.githubService = githubService; request.headers.set('X-Flutter-IdToken', 'trustmebro'); - final result = await auth.authenticate(request); + final result = await auth.authenticate(request.toRequest()); expect(result.email, email); }, ); @@ -128,7 +128,10 @@ void main() { config.githubService = githubService; request.headers.set('X-Flutter-IdToken', 'trustmebro'); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }, ); }); diff --git a/app_dart/test/request_handling/dashboard_authentication_test.dart b/app_dart/test/request_handling/dashboard_authentication_test.dart index 83fae7fb5..66a0167d3 100644 --- a/app_dart/test/request_handling/dashboard_authentication_test.dart +++ b/app_dart/test/request_handling/dashboard_authentication_test.dart @@ -8,6 +8,7 @@ import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/model/firestore/account.dart'; import 'package:cocoon_service/src/model/google/token_info.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:test/test.dart'; void main() { @@ -28,12 +29,15 @@ void main() { test('succeeds for App Engine cronjobs', () async { request.headers.set('X-Appengine-Cron', 'true'); - final result = await auth.authenticate(request); + final result = await auth.authenticate(request.toRequest()); expect(result.clientContext, same(clientContext)); }); test('throws for non App Engine cronjobs', () async { - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); }); @@ -62,7 +66,7 @@ void main() { TokenInfo(email: 'abc123@google.com', issued: DateTime.now()), ); request.headers.set('X-Flutter-IdToken', 'trustmebro'); - final result = await auth.authenticate(request); + final result = await auth.authenticate(request.toRequest()); expect(result.email, 'abc123@google.com'); }); @@ -72,7 +76,7 @@ void main() { TokenInfo(email: 'abc123@gmail.com', issued: DateTime.now()), ); request.headers.set('X-Flutter-IdToken', 'trustmebro'); - final result = await auth.authenticate(request); + final result = await auth.authenticate(request.toRequest()); expect(result.email, 'abc123@gmail.com'); }); @@ -81,12 +85,18 @@ void main() { TokenInfo(email: 'abc123@gmail.com', issued: DateTime.now()), ); request.headers.set('X-Flutter-IdToken', 'trustmebro'); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); test('fails for non-firebase jwt', () { request.headers.set('X-Flutter-IdToken', 'trustmebro'); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); }); } diff --git a/app_dart/test/request_handling/pubsub_authentication_test.dart b/app_dart/test/request_handling/pubsub_authentication_test.dart index 0d8a6da2a..7bc3032a4 100644 --- a/app_dart/test/request_handling/pubsub_authentication_test.dart +++ b/app_dart/test/request_handling/pubsub_authentication_test.dart @@ -2,13 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; - import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; -import 'package:cocoon_service/src/request_handling/pubsub_authentication.dart'; -import 'package:cocoon_service/src/service/config.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; @@ -53,7 +51,7 @@ void main() { request.headers.add(HttpHeaders.authorizationHeader, 'Bearer token'); - final result = await auth.authenticate(request); + final result = await auth.authenticate(request.toRequest()); expect(result.clientContext, same(clientContext)); }); } @@ -76,7 +74,10 @@ void main() { request.headers.add(HttpHeaders.authorizationHeader, 'Bearer token'); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); test('auth fails with invalid token', () async { @@ -97,7 +98,10 @@ void main() { request.headers.add(HttpHeaders.authorizationHeader, 'Bearer token'); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); test('auth fails with expired token', () async { @@ -121,7 +125,10 @@ void main() { request.headers.add(HttpHeaders.authorizationHeader, 'Bearer token'); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); }); } diff --git a/app_dart/test/request_handling/request_handler_test.dart b/app_dart/test/request_handling/request_handler_test.dart index 711b7515d..1a6ed10e4 100644 --- a/app_dart/test/request_handling/request_handler_test.dart +++ b/app_dart/test/request_handling/request_handler_test.dart @@ -12,6 +12,7 @@ import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server/logging.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/request_handling/request_handler.dart'; import 'package:cocoon_service/src/request_handling/response.dart'; import 'package:gcloud/service_scope.dart' as ss; @@ -29,7 +30,7 @@ void main() { server.listen((HttpRequest request) { runZoned(() { return ss.fork(() { - return handler.service(request); + return handler.service(request.toRequest()); }); }); }); diff --git a/app_dart/test/request_handling/subscription_handler_test.dart b/app_dart/test/request_handling/subscription_handler_test.dart index e0f47a258..7021a7e01 100644 --- a/app_dart/test/request_handling/subscription_handler_test.dart +++ b/app_dart/test/request_handling/subscription_handler_test.dart @@ -10,6 +10,7 @@ import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/src/model/luci/pubsub_message.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/request_handling/response.dart'; import 'package:cocoon_service/src/request_handling/subscription_handler.dart'; import 'package:cocoon_service/src/service/cache_service.dart'; @@ -34,7 +35,7 @@ void main() { server.listen((HttpRequest request) { runZoned(() { return ss.fork(() { - return subscription.service(request); + return subscription.service(request.toRequest()); }); }); }); diff --git a/app_dart/test/request_handling/swarming_authentication_test.dart b/app_dart/test/request_handling/swarming_authentication_test.dart index 8556725ac..9d167f2ac 100644 --- a/app_dart/test/request_handling/swarming_authentication_test.dart +++ b/app_dart/test/request_handling/swarming_authentication_test.dart @@ -2,13 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; - import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server_test/test_logging.dart'; +import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; -import 'package:cocoon_service/src/request_handling/swarming_authentication.dart'; -import 'package:cocoon_service/src/service/config.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; @@ -34,7 +32,10 @@ void main() { test('fails for App Engine cronjobs', () async { request.headers.set('X-Appengine-Cron', 'true'); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); group('when access token is given', () { @@ -66,7 +67,7 @@ void main() { 'token', ); - final result = await auth.authenticate(request); + final result = await auth.authenticate(request.toRequest()); expect(result.clientContext, same(clientContext)); }); @@ -87,7 +88,7 @@ void main() { 'token', ); - final result = await auth.authenticate(request); + final result = await auth.authenticate(request.toRequest()); expect(result.clientContext, same(clientContext)); }); @@ -107,7 +108,10 @@ void main() { 'unauthenticated token', ); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); test('auth fails with unauthenticated service account token', () async { @@ -125,7 +129,10 @@ void main() { 'unauthenticated token', ); - expect(auth.authenticate(request), throwsA(isA())); + expect( + auth.authenticate(request.toRequest()), + throwsA(isA()), + ); }); }); }); diff --git a/app_dart/test/service/config_test.dart b/app_dart/test/service/config_test.dart index c0e581c4c..8fe7f3f0d 100644 --- a/app_dart/test/service/config_test.dart +++ b/app_dart/test/service/config_test.dart @@ -8,7 +8,6 @@ import 'dart:typed_data'; import 'package:cocoon_server_test/fake_secret_manager.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/cocoon_service.dart'; -import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; import 'package:github/github.dart'; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; diff --git a/app_dart/test/service/dynamic_config_updater_test.dart b/app_dart/test/service/dynamic_config_updater_test.dart index de3976e09..40eeacf4b 100644 --- a/app_dart/test/service/dynamic_config_updater_test.dart +++ b/app_dart/test/service/dynamic_config_updater_test.dart @@ -9,7 +9,6 @@ import 'package:cocoon_server/logging.dart' show log; import 'package:cocoon_server_test/fake_secret_manager.dart'; import 'package:cocoon_server_test/test_logging.dart'; import 'package:cocoon_service/cocoon_service.dart'; -import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; import 'package:cocoon_service/src/service/flags/dynamic_config_updater.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' show MockClient; diff --git a/app_dart/test/service/scheduler/ci_yaml_fetcher_test.dart b/app_dart/test/service/scheduler/ci_yaml_fetcher_test.dart index 6ec741067..7152c8fc7 100644 --- a/app_dart/test/service/scheduler/ci_yaml_fetcher_test.dart +++ b/app_dart/test/service/scheduler/ci_yaml_fetcher_test.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; import 'package:cocoon_integration_test/testing.dart'; import 'package:cocoon_server_test/test_logging.dart'; diff --git a/app_dart/test/service/scheduler/hash_workflow_test.dart b/app_dart/test/service/scheduler/hash_workflow_test.dart index 61be59063..807a4d1a2 100644 --- a/app_dart/test/service/scheduler/hash_workflow_test.dart +++ b/app_dart/test/service/scheduler/hash_workflow_test.dart @@ -12,7 +12,6 @@ import 'package:cocoon_service/src/model/firestore/content_aware_hash_builds.dar import 'package:cocoon_service/src/model/github/workflow_job.dart'; import 'package:cocoon_service/src/service/big_query.dart'; import 'package:cocoon_service/src/service/content_aware_hash_service.dart'; -import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; import 'package:github/github.dart'; import 'package:http/http.dart'; import 'package:mockito/mockito.dart'; @@ -127,12 +126,14 @@ void main() { }); test('only processes workflow events !waitOnContentHash', () async { - final job = workflowJobTemplate().toWorkflowJob(); - // fakeContentAwareHash.nextStatusReturn = MergeQueueHashStatus.build; + config.dynamicConfig = DynamicConfig.fromJson({ + ...config.dynamicConfig.toJson(), + 'contentAwareHashing': {'waitOnContentHash': false}, + }); + final job = workflowJobTemplate().toWorkflowJob(); await scheduler.processWorkflowJob(job); - // expect(fakeContentAwareHash.processWorkflowJobs, [job]); expect(firestore.documents, isNotEmpty); expect( firestore, diff --git a/app_dart/test/service/scheduler_test.dart b/app_dart/test/service/scheduler_test.dart index 837ad9a8b..8b42045ed 100644 --- a/app_dart/test/service/scheduler_test.dart +++ b/app_dart/test/service/scheduler_test.dart @@ -15,16 +15,12 @@ import 'package:cocoon_service/src/model/ci_yaml/ci_yaml.dart'; import 'package:cocoon_service/src/model/ci_yaml/target.dart'; import 'package:cocoon_service/src/model/commit_ref.dart'; import 'package:cocoon_service/src/model/common/presubmit_completed_check.dart'; -import 'package:cocoon_service/src/model/firestore/base.dart'; import 'package:cocoon_service/src/model/firestore/ci_staging.dart'; import 'package:cocoon_service/src/model/firestore/commit.dart' as fs; import 'package:cocoon_service/src/model/firestore/pr_check_runs.dart'; -import 'package:cocoon_service/src/model/firestore/presubmit_check.dart'; -import 'package:cocoon_service/src/model/firestore/presubmit_guard.dart'; import 'package:cocoon_service/src/model/firestore/task.dart' as fs; import 'package:cocoon_service/src/model/github/checks.dart' as cocoon_checks; import 'package:cocoon_service/src/service/big_query.dart'; -import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; import 'package:cocoon_service/src/service/flags/unified_check_run_flow_flags.dart'; import 'package:cocoon_service/src/service/luci_build_service/engine_artifacts.dart'; import 'package:cocoon_service/src/service/luci_build_service/pending_task.dart'; @@ -3202,6 +3198,9 @@ targets: // tabledataResource: tabledataResource, githubService: mockGithubService, githubClient: MockGitHub(), + dynamicConfig: DynamicConfig.fromJson({ + 'contentAwareHashing': {'waitOnContentHash': false}, + }), ), githubChecksService: GithubChecksService( config, @@ -3470,6 +3469,9 @@ targets: // tabledataResource: tabledataResource, githubService: mockGithubService, githubClient: MockGitHub(), + dynamicConfig: DynamicConfig.fromJson({ + 'contentAwareHashing': {'waitOnContentHash': false}, + }), ), githubChecksService: GithubChecksService( config, @@ -3590,6 +3592,9 @@ targets: // tabledataResource: tabledataResource, githubService: mockGithubService, githubClient: MockGitHub(), + dynamicConfig: DynamicConfig.fromJson({ + 'contentAwareHashing': {'waitOnContentHash': false}, + }), ), githubChecksService: GithubChecksService( config, diff --git a/app_dart/test/src/request_handling/request_handler_tester.dart b/app_dart/test/src/request_handling/request_handler_tester.dart index b3ba13127..04cc7cff6 100644 --- a/app_dart/test/src/request_handling/request_handler_tester.dart +++ b/app_dart/test/src/request_handling/request_handler_tester.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:cocoon_integration_test/testing.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/request_handling/request_handler.dart'; import 'package:cocoon_service/src/request_handling/response.dart'; import 'package:meta/meta.dart'; @@ -19,7 +20,7 @@ class RequestHandlerTester { Future get(RequestHandler handler) { return run(() { // ignore: invalid_use_of_protected_member - return handler.get(Request.fromHttpRequest(request)); + return handler.get(request.toRequest()); }); } @@ -27,7 +28,7 @@ class RequestHandlerTester { Future post(RequestHandler handler) { return run(() { // ignore: invalid_use_of_protected_member - return handler.post(Request.fromHttpRequest(request)); + return handler.post(request.toRequest()); }); } diff --git a/app_dart/tool/local_server.dart b/app_dart/tool/local_server.dart index 04a4c270d..5909911c9 100644 --- a/app_dart/tool/local_server.dart +++ b/app_dart/tool/local_server.dart @@ -11,11 +11,11 @@ import 'package:cocoon_server_test/fake_secret_manager.dart'; import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/server.dart'; import 'package:cocoon_service/src/foundation/providers.dart'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:cocoon_service/src/service/big_query.dart'; import 'package:cocoon_service/src/service/build_status_service.dart'; import 'package:cocoon_service/src/service/commit_service.dart'; import 'package:cocoon_service/src/service/firebase_jwt_validator.dart'; -import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; import 'package:cocoon_service/src/service/get_files_changed.dart'; import 'package:cocoon_service/src/service/scheduler/ci_yaml_fetcher.dart'; @@ -116,7 +116,9 @@ Future main() async { ); return runAppEngine( - server, + (HttpRequest request) async { + await server(request.toRequest()); + }, onAcceptingConnections: (InternetAddress address, int port) { final host = address.isLoopback ? 'localhost' : address.host; print('Serving requests at http://$host:$port/'); diff --git a/conductor/tracks/cocoon_integration_test_20260206/plan.md b/conductor/archive/cocoon_integration_test_20260206/plan.md similarity index 92% rename from conductor/tracks/cocoon_integration_test_20260206/plan.md rename to conductor/archive/cocoon_integration_test_20260206/plan.md index 491b41efc..f173ff8fb 100644 --- a/conductor/tracks/cocoon_integration_test_20260206/plan.md +++ b/conductor/archive/cocoon_integration_test_20260206/plan.md @@ -1,4 +1,4 @@ -# Implementation Plan: Cocoon Integration Test +# Implementation Plan: Cocoon Integration Test [checkpoint: 5d0d05c] ## Phase 1: Package Setup & Fake Consolidation - [x] Task: Create `packages/cocoon_integration_test` structure. @@ -22,4 +22,4 @@ - [x] Task: Write a "Smoke Test". - [x] Create `packages/cocoon_integration_test/test/server_test.dart`. - [x] Verify the server starts and responds to a simple health check or API call. -- [ ] Task: Conductor - User Manual Verification (Protocol in workflow.md) +- [x] Task: Conductor - User Manual Verification (Protocol in workflow.md) diff --git a/conductor/tracks/cocoon_integration_test_20260206/spec.md b/conductor/archive/cocoon_integration_test_20260206/spec.md similarity index 100% rename from conductor/tracks/cocoon_integration_test_20260206/spec.md rename to conductor/archive/cocoon_integration_test_20260206/spec.md diff --git a/conductor/tracks.md b/conductor/tracks.md index 34a1619e7..43048451d 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -2,21 +2,5 @@ --- -- [~] **Track: Cocoon Integration Test Environment** -*Link: [./tracks/cocoon_integration_test_20260206/](./tracks/cocoon_integration_test_20260206/)* - ---- - - [~] **Track: Build a Merge Queue Dashboard** *Link: [./tracks/merge_queue_dashboard_20260205/](./tracks/merge_queue_dashboard_20260205/)* - ---- - -- [x] **Track: Add sha to and show it on presubmit view of dashborad on a header along with PR and author** -*Link: [./archive/presubmit_header_sha_20260217/](./archive/presubmit_header_sha_20260217/)* - ---- - -- [x] **Track: For PreSubmitView move all state change logic to PresubmitState similarly to how BuildDashboardPage use BuildState** -*Link: [./archive/refactor_presubmit_state_20260223/](./archive/refactor_presubmit_state_20260223/)* - diff --git a/dashboard/lib/dashboard_navigation_drawer.dart b/dashboard/lib/dashboard_navigation_drawer.dart index fc8fbf5a1..9708e34f7 100644 --- a/dashboard/lib/dashboard_navigation_drawer.dart +++ b/dashboard/lib/dashboard_navigation_drawer.dart @@ -2,9 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'logic/links.dart'; +import 'service/scenarios.dart'; +import 'state/build.dart'; /// Sidebar for navigating the different pages of Cocoon. class DashboardNavigationDrawer extends StatelessWidget { @@ -34,9 +38,50 @@ class DashboardNavigationDrawer extends StatelessWidget { selected: currentRoute == link.route, ), ), + if (kDebugMode) ...[ + const Divider(), + ListTile( + leading: const Icon(Icons.bug_report), + title: const Text('Scenarios'), + onTap: () { + Navigator.pop(context); + _showScenarioDialog(context); + }, + ), + ], const AboutListTile(icon: FlutterLogo()), ], ), ); } + + void _showScenarioDialog(BuildContext context) { + final buildState = Provider.of(context, listen: false); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Dev Tools: Select Scenario'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: Scenario.values.map((scenario) { + return ListTile( + title: Text(scenario.name), + onTap: () { + buildState.resetScenario(scenario); + Navigator.of(context).pop(); + }, + ); + }).toList(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } } diff --git a/dashboard/lib/service/appengine_cocoon.dart b/dashboard/lib/service/appengine_cocoon.dart index 14610ec08..290cff2b2 100644 --- a/dashboard/lib/service/appengine_cocoon.dart +++ b/dashboard/lib/service/appengine_cocoon.dart @@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; import 'package:http/http.dart' as http; import 'cocoon.dart'; +import 'scenarios.dart'; /// CocoonService for interacting with flutter/flutter production build data. /// @@ -22,6 +23,9 @@ class AppEngineCocoonService implements CocoonService { AppEngineCocoonService({http.Client? client}) : _client = client ?? http.Client(); + @override + void resetScenario(Scenario scenario) {} + /// Branch on flutter/flutter to default requests for. final String _defaultBranch = 'master'; diff --git a/dashboard/lib/service/cocoon.dart b/dashboard/lib/service/cocoon.dart index 2f56e41ae..a1995e441 100644 --- a/dashboard/lib/service/cocoon.dart +++ b/dashboard/lib/service/cocoon.dart @@ -4,10 +4,12 @@ import 'package:cocoon_common/rpc_model.dart'; import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_integration_test/cocoon_integration_test.dart'; import 'package:flutter/foundation.dart'; import 'appengine_cocoon.dart'; -import 'dev_cocoon.dart'; +import 'integration_server_adapter.dart'; +import 'scenarios.dart'; /// Service class for interacting with flutter/flutter build data. /// @@ -23,10 +25,7 @@ abstract class CocoonService { if (useProductionService) { return AppEngineCocoonService(); } - return DevelopmentCocoonService( - DateTime.now(), - simulateLoadingDelays: true, - ); + return IntegrationServerAdapter(IntegrationServer(), now: DateTime.now()); } /// Gets build information on the most recent commits. @@ -134,6 +133,11 @@ abstract class CocoonService { /// Gets the presubmit guard summaries for a given [repo] and [pr]. Future>> fetchPresubmitGuardSummaries({required String repo, required String pr}); + + /// Resets the data scenario for fake implementations. + /// + /// No-op for production services. + void resetScenario(Scenario scenario) {} } /// Wrapper class for data this state serves. diff --git a/dashboard/lib/service/data_seeder.dart b/dashboard/lib/service/data_seeder.dart new file mode 100644 index 000000000..077d731f9 --- /dev/null +++ b/dashboard/lib/service/data_seeder.dart @@ -0,0 +1,756 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_integration_test/cocoon_integration_test.dart'; +import 'package:cocoon_service/cocoon_service.dart'; +import 'package:github/github.dart'; +import 'package:googleapis/firestore/v1.dart' as g; + +import 'cocoon.dart'; +import 'scenarios.dart'; + +/// Seeder to populate [IntegrationServer] with fake data. +class DataSeeder { + DataSeeder(this._server, {this.scenario = Scenario.realistic}); + + final IntegrationServer _server; + final Scenario scenario; + + /// Seeds the [IntegrationServer] with initial data. + void seed({DateTime? now}) { + if (scenario == Scenario.empty) { + return; + } + + // Use a fixed seed for reproducibility in tests, matching DevelopmentCocoonService + now ??= DateTime.now(); + final random = math.Random(now.millisecondsSinceEpoch); + + // Seed Commits and Tasks + for (final repo in ['flutter', 'cocoon']) { + final branch = defaultBranches[repo]!; + _seedCommitStatuses(now, random, repo, branch); + } + + // Seed Tree Status Changes + _seedTreeStatusChanges(now); + + // Seed Suppressed Tests + _seedSuppressedTests(now); + + // Seed Presubmit Data + _seedPresubmitData(now); + } + + void _seedPresubmitData(DateTime now) { + final guards = []; + final checks = []; + + // cafe5_1_mock_sha + guards.add( + _createPresubmitGuard( + commitSha: 'cafe5_1_mock_sha', + checkRunId: 456, + pullRequestId: 123, + author: _authors[0], + stage: CiStage.fusionTests, + creationTime: now.millisecondsSinceEpoch - 100000, + builds: { + 'Mac mac_host_engine': TaskStatus.infraFailure, + 'Mac mac_ios_engine': TaskStatus.cancelled, + 'Linux linux_android_aot_engine': TaskStatus.infraFailure, + }, + ), + ); + for (final buildName in [ + 'Mac mac_host_engine', + 'Mac mac_ios_engine', + 'Linux linux_android_aot_engine', + ]) { + checks.add( + _createPresubmitCheck( + checkRunId: 456, + buildName: buildName, + status: TaskStatus.infraFailure, + creationTime: now.millisecondsSinceEpoch - 100000, + ), + ); + } + + // face5_2_mock_sha + guards.add( + _createPresubmitGuard( + commitSha: 'face5_2_mock_sha', + checkRunId: 789, + pullRequestId: 123, + author: _authors[1], + stage: CiStage.fusionTests, + creationTime: now.millisecondsSinceEpoch - 50000, + builds: { + 'Mac mac_host_engine': TaskStatus.succeeded, + 'Mac mac_ios_engine': TaskStatus.cancelled, + 'Linux linux_android_aot_engine': TaskStatus.succeeded, + }, + ), + ); + guards.add( + _createPresubmitGuard( + commitSha: 'face5_2_mock_sha', + checkRunId: 789, + pullRequestId: 123, + author: _authors[1], + stage: CiStage.fusionEngineBuild, + creationTime: now.millisecondsSinceEpoch - 40000, + builds: { + 'Linux framework_tests': TaskStatus.succeeded, + 'Mac framework_tests': TaskStatus.failed, + 'Linux android framework_tests': TaskStatus.skipped, + 'Windows framework_tests': TaskStatus.failed, + }, + ), + ); + + // decaf_3_mock_sha + guards.add( + _createPresubmitGuard( + commitSha: 'decaf_3_mock_sha', + checkRunId: 1011, + pullRequestId: 123, + author: _authors[2], + stage: CiStage.fusionEngineBuild, + creationTime: now.millisecondsSinceEpoch, + builds: { + 'Mac mac_host_engine': TaskStatus.succeeded, + 'Mac mac_ios_engine': TaskStatus.cancelled, + 'Linux linux_android_aot_engine': TaskStatus.succeeded, + }, + ), + ); + guards.add( + _createPresubmitGuard( + commitSha: 'decaf_3_mock_sha', + checkRunId: 1011, + pullRequestId: 123, + author: _authors[2], + stage: CiStage.fusionTests, + creationTime: now.millisecondsSinceEpoch, + builds: { + 'Linux framework_tests': TaskStatus.succeeded, + 'Mac framework_tests': TaskStatus.waitingForBackfill, + 'Linux android framework_tests': TaskStatus.skipped, + 'Windows framework_tests': TaskStatus.inProgress, + }, + ), + ); + + // deafcab_mock_sha + guards.add( + _createPresubmitGuard( + commitSha: 'deafcab_mock_sha', + checkRunId: 369, + pullRequestId: 123, + author: _authors[3], + stage: CiStage.fusionEngineBuild, + creationTime: now.millisecondsSinceEpoch - 300000, + builds: { + 'Mac mac_host_engine': TaskStatus.succeeded, + 'Mac mac_ios_engine': TaskStatus.cancelled, + 'Linux linux_android_aot_engine': TaskStatus.succeeded, + }, + ), + ); + guards.add( + _createPresubmitGuard( + commitSha: 'deafcab_mock_sha', + checkRunId: 369, + pullRequestId: 123, + author: _authors[3], + stage: CiStage.fusionTests, + creationTime: now.millisecondsSinceEpoch - 300000, + builds: { + 'Linux framework_tests': TaskStatus.succeeded, + 'Mac framework_tests': TaskStatus.succeeded, + 'Linux android framework_tests': TaskStatus.skipped, + 'Windows framework_tests': TaskStatus.succeeded, + }, + ), + ); + + // Add some checks with multiple attempts for testing fetchPresubmitCheckDetails + checks.add( + PresubmitCheck( + checkRunId: 1234, + buildName: 'Test Multi Attempt', + status: TaskStatus.succeeded, + attemptNumber: 1, + creationTime: now.millisecondsSinceEpoch - 10000, + buildNumber: 12345, + summary: ''' +[INFO] Starting task Test Multi Attempt... +[SUCCESS] Dependencies installed. +[INFO] Running build script... +[SUCCESS] All tests passed (452/452) +''', + ), + ); + checks.add( + PresubmitCheck( + checkRunId: 1234, + buildName: 'Test Multi Attempt', + status: TaskStatus.failed, + attemptNumber: 2, + creationTime: now.millisecondsSinceEpoch, + buildNumber: 67890, + summary: + '[INFO] Starting task Test Multi Attempt...\n[ERROR] Test failed: Unit Tests', + ), + ); + + _server.firestore.putDocuments(guards); + _server.firestore.putDocuments(checks); + } + + PresubmitGuard _createPresubmitGuard({ + required String commitSha, + required int checkRunId, + required int pullRequestId, + required String author, + required CiStage stage, + required int creationTime, + required Map builds, + String repo = 'flutter', + }) { + final slug = RepositorySlug('flutter', repo); + final failedBuilds = builds.values + .where((status) => status.isFailure) + .length; + final remainingBuilds = builds.values + .where((status) => !status.isBuildCompleted) + .length; + + return PresubmitGuard( + checkRun: generateCheckRun(checkRunId), + commitSha: commitSha, + slug: slug, + pullRequestId: pullRequestId, + stage: stage, + creationTime: creationTime, + author: author, + remainingBuilds: remainingBuilds, + failedBuilds: failedBuilds, + builds: builds, + ); + } + + PresubmitCheck _createPresubmitCheck({ + required int checkRunId, + required String buildName, + required TaskStatus status, + required int creationTime, + int attemptNumber = 1, + }) { + return PresubmitCheck( + checkRunId: checkRunId, + buildName: buildName, + status: status, + attemptNumber: attemptNumber, + creationTime: creationTime, + ); + } + + void _seedTreeStatusChanges(DateTime now) { + final changes = []; + // Create a history of tree status changes + for (var i = 0; i < 10; i++) { + changes.add( + _createTreeStatusChange( + i, + created: now.subtract(Duration(hours: i)), + status: i.isEven ? TreeStatus.success : TreeStatus.failure, + reason: i.isEven ? null : 'Build failure on Linux', + ), + ); + } + _server.firestore.putDocuments(changes); + } + + void _seedSuppressedTests(DateTime now) { + final suppressed = []; + suppressed.add( + _createSuppressedTest( + name: 'Linux_android 0', + created: now.subtract(const Duration(days: 1)), + issueLink: 'https://github.com/flutter/flutter/issues/12345', + ), + ); + _server.firestore.putDocuments(suppressed); + } + + TreeStatusChange _createTreeStatusChange( + int i, { + DateTime? created, + TreeStatus status = TreeStatus.success, + String author = 'dash', + String? reason, + String repo = 'flutter', + }) { + final name = + 'projects/${Config.flutterGcpProjectId}/databases/${Config.flutterGcpFirestoreDatabase}/documents/${TreeStatusChange.metadata.collectionId}/$i'; + return TreeStatusChange.fromDocument( + g.Document( + name: name, + fields: { + 'createTimestamp': (created ?? DateTime.fromMillisecondsSinceEpoch(i)) + .toValue(), + 'status': status.name.toValue(), + 'author': author.toValue(), + 'repository': RepositorySlug('flutter', repo).fullName.toValue(), + if (reason != null) 'reason': reason.toValue(), + }, + ), + ); + } + + SuppressedTest _createSuppressedTest({ + String name = 'task', + String repository = 'flutter/flutter', + bool isSuppressed = true, + String issueLink = 'link', + DateTime? created, + }) { + final docName = + 'projects/${Config.flutterGcpProjectId}/databases/${Config.flutterGcpFirestoreDatabase}/documents/${SuppressedTest.kCollectionId}/$name'; + return SuppressedTest( + name: name, + repository: repository, + isSuppressed: isSuppressed, + issueLink: issueLink, + createTimestamp: created ?? DateTime.fromMillisecondsSinceEpoch(1), + )..name = docName; + } + + void _seedCommitStatuses( + DateTime now, + math.Random random, + String repo, + String branch, + ) { + const commitGap = 2 * 60 * 1000; // 2 minutes between commits + final baseTimestamp = now.millisecondsSinceEpoch; + + final commits = []; + final tasks = []; + + final commitCount = scenario == Scenario.highLoad ? 100 : 25; + + for (var index = 0; index < commitCount; index += 1) { + final commitTimestamp = baseTimestamp - ((index + 1) * commitGap); + // Use the same random sequence as DevelopmentCocoonService + final commitRandom = math.Random(commitTimestamp); + + // Generate a stable and unique SHA for each commit + final commitSha = _commitsSha[repo]![index]; + + final authorIndex = commitRandom.nextInt(_authors.length); + final messageSeed = commitTimestamp % 37 + authorIndex; + final messageInc = _messagePrimes[messageSeed % _messagePrimes.length]; + final message = List.generate( + 6, + (int i) => _words[(messageSeed + i * messageInc) % _words.length], + ).join(' '); + + final commit = generateFirestoreCommit( + index, + sha: commitSha, + repo: repo, + branch: branch, + createTimestamp: commitTimestamp, + author: _authors[authorIndex], + avatar: _avatars[authorIndex], + message: message, + ); + commits.add(commit); + + final taskCount = repo == 'flutter' ? 100 : 3; + for (var i = 0; i < taskCount; i++) { + tasks.add( + _createFakeTask( + now, + commitTimestamp, + index, + i, + commitRandom, + commitSha, + repo, + ), + ); + } + } + + _server.firestore.putDocuments(commits); + _server.firestore.putDocuments(tasks); + } + + Task _createFakeTask( + DateTime now, + int commitTimestamp, + int commitIndex, + int taskIndex, + math.Random random, + String commitSha, + String repo, + ) { + const commitGap = 2 * 60 * 1000; + final age = (now.millisecondsSinceEpoch - commitTimestamp) ~/ commitGap; + + TaskStatus status; + if (scenario == Scenario.allGreen) { + status = TaskStatus.succeeded; + } else if (scenario == Scenario.redTree && commitIndex == 0) { + status = TaskStatus.failed; + } else { + // The [statusesProbability] list is an list of proportional + // weights to give each of the values in _statuses when randomly + // determining the status. So e.g. if one is 150, another 50, and + // the rest 0, then the first has a 75% chance of being picked, + // the second a 25% chance, and the rest a 0% chance. + final statusesProbability = [ + // bigger = more probable + math.max(taskIndex % 2, 20 - age * 2), // TaskStatus.waitingForBackfill + math.max(0, 10 - age * 2), // TaskStatus.inProgress + math.min(10 + age * 2, 100), // TaskStatus.succeeded + math.min(1 + age ~/ 3, 30), // TaskStatus.failed + if (taskIndex % 15 == 0) // TaskStatus.infraFailure + 5 + else if (taskIndex % 25 == 0) + 15 + else + 1, + if (taskIndex % 20 == 0) 30, + 1, // TaskStatus.cancelled + ]; + + final maxProbability = statusesProbability.fold( + 0, + (int c, int p) => c + p, + ); + var weightedIndex = random.nextInt(maxProbability); + var statusIndex = 0; + while (weightedIndex > statusesProbability[statusIndex]) { + weightedIndex -= statusesProbability[statusIndex]; + statusIndex += 1; + } + status = _statuses[statusIndex]; + } + + final minAttempts = _minAttempts[status]!; + final maxAttempts = _maxAttempts[status]!; + final attempts = + minAttempts + random.nextInt(maxAttempts - minAttempts + 1); + + final buildNumber = attempts > 0 ? random.nextInt(1000) : null; + + return generateFirestoreTask( + taskIndex, + name: 'Linux_android $taskIndex', + status: status, + attempts: attempts, + buildNumber: buildNumber, + commitSha: commitSha, + created: DateTime.fromMillisecondsSinceEpoch(commitTimestamp + taskIndex), + started: DateTime.fromMillisecondsSinceEpoch( + commitTimestamp + (taskIndex * 1000 * 60), + ), + ended: DateTime.fromMillisecondsSinceEpoch( + commitTimestamp + (taskIndex * 1000 * 60) + (taskIndex * 1000 * 60), + ), + bringup: taskIndex == now.millisecondsSinceEpoch % 13, + testFlaky: attempts > 1, + ); + } + + static const _statuses = [ + TaskStatus.waitingForBackfill, + TaskStatus.inProgress, + TaskStatus.succeeded, + TaskStatus.failed, + TaskStatus.infraFailure, + TaskStatus.skipped, + TaskStatus.cancelled, + ]; + + static const _minAttempts = { + TaskStatus.waitingForBackfill: 1, + TaskStatus.inProgress: 1, + TaskStatus.succeeded: 1, + TaskStatus.failed: 1, + TaskStatus.infraFailure: 1, + TaskStatus.skipped: 1, + TaskStatus.cancelled: 1, + }; + + static const _maxAttempts = { + TaskStatus.waitingForBackfill: 1, + TaskStatus.inProgress: 2, + TaskStatus.succeeded: 1, + TaskStatus.failed: 2, + TaskStatus.infraFailure: 2, + TaskStatus.skipped: 1, + TaskStatus.cancelled: 1, + }; + + static const List _authors = [ + 'matan', + 'yegor', + 'john', + 'jenn', + 'kate', + 'stuart', + ]; + + static const List _avatars = [ + 'https://avatars.githubusercontent.com/u/168174?v=4', + 'https://avatars.githubusercontent.com/u/211513?v=4', + 'https://avatars.githubusercontent.com/u/1924313?v=4', + 'https://avatars.githubusercontent.com/u/682784?v=4', + 'https://avatars.githubusercontent.com/u/16964204?v=4', + 'https://avatars.githubusercontent.com/u/122189?v=4', + ]; + + static const List _messagePrimes = [ + 3, + 11, + 17, + 23, + 31, + 41, + 47, + 67, + 79, + ]; + static const List _words = [ + 'fixes', + 'issue', + 'crash', + 'developer', + 'blocker', + 'intermittent', + 'format', + ]; + + static const _commitsSha = { + 'cocoon': _commitsCocoon, + 'flutter': _commitsFlutter, + }; + + // These commits from from flutter/cocoon + static const List _commitsCocoon = [ + '2fd76f920a38e4384248173d05ee482d5aeaf4c5', + '2d22b5e85f986f3fa2cf1bfaf085905c2182c270', + '77238bc7bf35489df03bc00ce2b2231a1afe6b06', + '01d87b7a802e6ea388a066e773b1af3dace44053', + '754039ae0cc524db1052da0f22c9275e32fe4f54', + '1f5e006a398fa5d0e59f78cd5071e2532d2fe438', + 'a798b24044d567df62b8693b179932a8364c8dd8', + '4389a8a3dbe1ed4c6a643641e95d7759f2158d9e', + 'c05e886884ec9adff2f43b87dbcb02e3507d971b', + 'a2acc46447cdfcd6628a897dea27ff64849bfc99', + 'd31d67ffb38fbd09ecf0a11ad5f6fd433cec9c9f', + 'e303d2c71c956c9c1eb7bf81473aac20d756eb75', + '789f6bb335fe31de5d9c6adbab2fc169030a057f', + 'c97f0670658e04f096a3e57b20fa8241306ffcaa', + 'e8e31198861b5f53f04900bdf9a54e8bf6b7d597', + '434a0a7d3c4c3bc633e06de7707ef590c54c20c3', + 'e7fca29b3f0408c7c1726e270a5aba0e28e74090', + 'dd6f94d8573a77506a122899a0592a956ac57bec', + 'cb19ec23d79b9d422f577722e5a14253fdcaea71', + '4cb8c6498aedfd2ff0f89e34eb5da993a77392bd', + 'de892884aa089f22aced4d19a71b6a1d521c8db6', + '214470ceb026525fa225d52dfe7d27db2c4ddf31', + 'e0f4628b4379286c433bab020b9e193fdc437d05', + 'be5975cb0b7fcad7ab2c3122a8df3d541befdeab', + '06f7ab60d4914e00b342f098e1ef3e43e501b469', + 'e1005bea192673e54faa0c769d9f0fb7439a09b4', + 'e22d7f6bf2e1e1969dd963d39ad32c756fb0f20e', + 'f4b4b20bb27cbbd1c42eee7b17ce39c5819dc818', + 'd717ed969b6a477499eb3cf823c78dbe654ca709', + '6a8d6a42b4c9f4b72caf0a3b808e50909686a2b7', + 'afaa9bfa5d26ab419f791d5ca97d602ec52a30a5', + 'f553212d9bfbc6e70c0d6a4ac3fe71208bb77ca1', + 'c35f8b79f4a5103603ceaaa14d1df3857a166fa1', + '46447341838d480966926d0b32771e281af1c885', + '4e3330081d4e0a3e109cf1cfb514072a90b999d7', + '4199ef93c3184c29362520ea5292d854a8728494', + 'a0538938974c60cb9249acf8f0588c3df3c0e4b1', + '49cac0b6c1e0d7d1d04865c328c8cbe5c8e0cda2', + '97e3cc6295515f8292f5f57868506c70446594c3', + 'c94e577ef3b0ab48255633c63f0143d9e3eab6f8', + '17d9af726fb6e4a4a8cc4f1becfc11dc9d4db96c', + '4af6ec81ad538e3da23ee88cba65ed8687a72ea3', + '69cb45166d2f4f61069e1f1a975dab3f48bed832', + 'ae227082570f75b614bb29593911ada5137654ad', + '882b9fa44962df0b80d9a25b2553796bd4eba2ff', + 'e1f5c4c7178c34ce561c493b558df7450995a60c', + '56d7272cd497c73502ec5a09bdf69b4c7ecbfd74', + '36a99b302f7348848ec477ce867a8b78656d9c6c', + '48be7a01565289f44c2c9d4ff1436800c52acf75', + '422cb82e01ee0192256a05217102f45f2f74551d', + 'f8744d10460abae3d75e92336b3bb264bb78cc8c', + '5000f246c8c568547c551b11e4b72acd0179e73c', + '52c09522b288766a42900aa73f77216907d16b23', + '61adb59fc097335b45b23fc884a78362b71a3e9b', + 'b81c3c58e5a6c45bc8728024068291a7e5f19c1a', + 'dec394e5401b62024ed71253e900c25a06eb46f9', + 'fbe142d2cb60fb981521cfe72961642d69db8784', + '24a9c4557e0366945c179433fab8434c2c8ee59f', + 'ccc28f607ae11fe79c39c42d88798c19b1388af9', + 'd4d5ca665e56063fdd37d42d2192c64151915454', + '4273fc07c3e8a36726087becdf331f7d63fc7e66', + '5e0380895cc8e34a6a58f238b7fd33b9e0f027fe', + '146908e48a8a9f92f59fae86a7a4ff28c0ce9109', + '2779a449026423bc0f50a10eead2d9f3720b825c', + 'ff9b954908341360ffb39f5eee2857172a29c0e5', + 'b00b6d146c4f4af1d28260a3f28abd15f0105221', + '4ffd03df44741d73877a0848935eea0d6cbfc1e9', + 'c2c5b73aaae2583cc235319e53200e0e15cda438', + 'b7b370faf46f4c682827fbb3967e1ef117b3b0d1', + 'bc6c9ac81e5909408518568729c2c3a147402928', + 'ba1aa77c5bc5fe545b245f2b1883af3dfeeae6c0', + '97a7060a7e28ab1d13cb2f4ca90be624b417168e', + '3df1da69ffcb4e00cf13c65c9f0497a56413e5e9', + 'ebb13b4ed0cb41814839d4084c064e62526b2ec0', + '70b9d50722307f0f45ceed01d421d6a425e95291', + '3f81ca7b73f21ca9b449c147942fbe4fcd31450d', + '86d490939df232966dc6ba363ebc0baaac364701', + '6ba51f3f61025543d66e59c2c1ae3d1b37d5d8d6', + 'b728942e1c9a9046c1a5c8d8a25a644d0db7160c', + '920819252a398c0e3da9b6017b110ee047c1748e', + 'd7c114d803b1365ea3a975c9600fc8cfd9efb9d4', + '792aa82143bb12e97f396cb2a462ad617dbd22bc', + 'b69dda7e6e7c605aacb0ab973a21830c4401db20', + '90b5950051487c57c9120f6d0b98d61dfb05a197', + '177ac767fdfbe9799f91bfc0f22b44109f7b1b20', + '18d71ce8066b0653b5ecd1a235571fd806f420a0', + '39016691752550109065cb506393f9a88eeb5433', + '3477afbf65aac3fd3dfca54770803283d827222a', + 'ec17795bcac60741f0b4009fd60e654333173c0d', + '02d64e3e6b65d430adc8f28e76b65fef06112e81', + '6d378d876bb342380e5aaa2671d7c2687bc67df2', + 'b47732a57770a5a1780f25bb495cbcc2b61722b9', + '4b666b1f44634a5766220adc84e01c6f804cf552', + '642bbd51338f2ea19e97409e3abaa07391707df9', + '49935920c84157e8c0b981ac6b4eac9f92022613', + 'ce4105b9a777ed2b494ee61465584d58faa52cde', + '24aeb8dffb2e9a83161a89e88e9aacf5f01beb63', + '07280e78af25d07f1bd00c391fd0928f5d9aa543', + '13239ea26d46db5a3268592b88ba65f732fcca7a', + '213244f8ec1740c1db65cd02853de2e5736b26bd', + ]; + + static const _commitsFlutter = [ + '692b51763d45cc4d574a06cf3d7b9b36f69c5170', + '450478ad313f8291c3a48b00a7331e7d12e2bda0', + 'fb75b2b671c7702b549a80a420144097f4fab5a9', + '9020331a243162781b2c5a645cdca892b1d32b97', + '7672bd756ac34099907137e51f276ee5c0cfb582', + '6172ba6c553e7642bc58997c200f5dde488b64bc', + '1da41c5015a28e0025a00b7c189077e3872a358c', + 'fafe9c3c1e772019a4894f6c3092f3f633654b1f', + '8d3b8d8b7fa1267439e979803af8d41e237e9c95', + '113ea6ae72deb26444c3b561c65c9c6b2c0f3ef3', + 'f4e490131e338bf8b1a3bcab8dae68c56c4498cd', + 'b13a8b24362d7a0b2fafe2a404cd9852b27dc688', + 'a545549c4e33a548600a8045d1bd5388346cf187', + 'f76936bb858dbade620fc8710b5fd89dc1fab750', + '4c4f5e70b14e238ceebd03372765abb204070573', + '3b21872f9aea4d1cdf3a09c7d74c3970d9316330', + 'c5643e21933389ab21a45e38515494c53d2cc2f6', + 'ee7e28ec979562d0ab722fd24b5e145068ec6b63', + 'cbb7a618809ee9dfb525ca6ea28044e016718ae7', + '399a692f7db0bd5faba36003d67880776a3767d3', + 'c389cc0b790a10482a82a5ab2915e907aa442ad1', + 'd88a5099b202bd289c508d1dd5ef3e006ccf8144', + 'c71e0e31aeb6b855477dee84b391d64f14967616', + '88ef36199ddebf36b0596af73047b56ed0208fa3', + '4f5478cce38d837e14b7a032a12500d3fc0f1310', + '02f61b17f1aa91dca2df3108676bc13700f9efe7', + '7ff0723e08fd3f40bf866c164da8f1895ad41e71', + 'fbfe04e0c4e7311a92f9407abb6ec6f3c9543d8e', + 'dad6f9d4107a5332d15c126207882edbd8acdc97', + '95144d41fa78bde126c4c1460d4f9ec1a0c3daa5', + '1e1df36da3654f3bec977435cee56637ab77d398', + 'efb43ece1f40a0a5f04348951eb625f81318587a', + 'd2d2a7a0c18d7465dd12225f511d998135e0036d', + '8774a3b33cb281791933f1cd45f0e77ddd9d4bd1', + '3ff83f39d2fe9c228f4fa16709392c27ef1d7d32', + '3f0a7310dfb6ae1ce29c09b58167b2c16cff743a', + '2a4b712361668ae230fbf1077bf6a5ef07552641', + 'e6e36963d3bdbd3810f8986ab56811b9ff145a5c', + '9c8defed0245315b2dab8986ae66fc84b186ff3e', + '6d42ae50140884ec72886665cfb5d4bf39f30afe', + '075faff72b964ee4fb6f4e45f260d0223ff55f23', + '1af98a6bfc879852008bd2108242a64c07403b1d', + '5b5a69ff0a4288b1e53c3d1337c5ac69ca26ebfc', + '4c020ad5dad530afde20c2830a5a6f22c6e3b867', + '3109939de842eb6fe00a831e436963d4568bdc0e', + 'f38a3e07af777d51606cd9912bc28c274eb6f05b', + '5655697a23d54dadeb4f527424b3da3ffa1051a2', + '9dc7888929d9112dfed9deb16f3f20d6a341bb5b', + '91b2d41a66d1c540233b819525553afc0fa1f58d', + '2d7e80963b3ad3594b7fcab231f1bd08c13cf0d1', + '81c91517d55c15f9f3a10d5b88401c7e191a5179', + '6c9a881a59f6a2e79e0896e0b475ee9293983947', + '8034db0bd38b1c682146474cc5986e2e00dad96f', + 'd134331bf8f01c213b87cc611a76a0013ed3e860', + '34073f4f2a86acd23f4966acbb24d550b5f31563', + '56956c33ef102ac0b5fc46b62bd2dd9f50a86616', + '0ef66c7d1a4c3ed303fdd2aa0349d39955b0e0ef', + '935b8f70305b2d82d76b2a15d568d973e2835895', + '9af4145b8ecb5da13d053dd2a6702c73705a862a', + 'a8911cbac88ef8f73c083465d0cbc35a7537f35f', + 'd33442bedb88e1579c58f5dee14951712bdaf94d', + '7008d4acb2076798da0adf3c16dc08286dbea765', + '06df71c51446e96939c6a615b7c34ce9123806ba', + '230240c56880f2c19bf92d2c32203b064054f173', + '1fdf684e327544e93c39f1da5429c3e880d22d15', + '8a80222e4d09460d6615636df152adc6772cb9f6', + 'de1b130cf637ad91ebf17db0893b7e1ce5f2b064', + 'e1f11bd5aafda71a21854a0cc53022666c1c5449', + '76f70d21da9c58da773ca7ebb044b2bbb8808c28', + '1f506886e11c3cb59deb216b6139f718f5a2dd67', + 'b9e7e3ffe808a93f1e50d3b1729bf1f40e375de2', + '2c24f0f31223fbda063ad40e25219578742ac299', + 'e0e7a7d72db3fef30b221433e259e4e94a4d3aec', + '96d292fa0acd9d11e85fd04f7355a47cf3fb2723', + '246b62f221f3966a0fcfcecc3e8cb1ef7ce3e953', + '1e97fd38cf89f94f5159f4e9d0b598a0871103ab', + '6c90a6559480c7b46a3e6c767af614326fb8ca9b', + '2b2bdc6df9e061b0bdf0ff61e9c508d963e3ac64', + '9f2cb479c8aa66c82a4ae9f6ca825aee5949382b', + 'c023e5b2474f8ff1c146240dd685237cd8490f89', + '24ce716cfddfef201027c1a5fa2299a8aeffb03e', + '1887f3f8e0d4ebff550dcc08319e338290edf339', + '0c72661d44978a4e9374a14e882bbb26a46c0ac5', + '5e7b2a0bb3e58cbe4771ae3b7965941f9f38b84e', + '64866862f623ceeb45fd8be4782e8db8b58910c0', + '4c7144a4a890b98ed812be8648ff16c917216e8b', + '294aa14e763f7e8a3729f15eef72f50f16b48e05', + 'ec11254157f69d5d9260e12f53f82a6569b5c1fa', + 'f5825a22a47969ce9fec303dbefee035eadb1acc', + 'a58324d980b1e1d6805eac3e4a7748ae5965a242', + '8f61855dede30a55e9e00c9cb0caa62829798cd1', + '7ac16a276391e26e448e50447f2bb4f3e8146c87', + '70870ee8d3b6280e2366f95f067e075f11c08f58', + 'ecf688eb21e8e7d6e21456a2d2fbede4c4ab5b94', + '5b1c84cec15ce15283fe67cb202f37d3f9a911be', + '35e8dae34234cf605ab622ae5c91ff0c3676da55', + '6e4a481bdf2793a05a2569693d3b88b200159217', + '9292bb3a66ea6377899df5a7fc17857fa364477c', + '9fa7f81be038464d2aabef4752d2f50ea60ce561', + 'aba16bc2db714ba438f5480fd328c14ca92c42db', + ]; +} diff --git a/dashboard/lib/service/dev_cocoon.dart b/dashboard/lib/service/dev_cocoon.dart deleted file mode 100644 index abe056c10..000000000 --- a/dashboard/lib/service/dev_cocoon.dart +++ /dev/null @@ -1,773 +0,0 @@ -// Copyright 2019 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:math' as math; - -import 'package:cocoon_common/guard_status.dart'; -import 'package:cocoon_common/rpc_model.dart'; -import 'package:cocoon_common/task_status.dart'; - -import 'cocoon.dart'; - -class _PausedCommitStatus { - _PausedCommitStatus(CocoonResponse> status) - : _completer = Completer>>(), - _pausedStatus = status; - - final Completer>> _completer; - CocoonResponse>? _pausedStatus; - - bool get isComplete => _pausedStatus == null; - - Future>> get future => _completer.future; - - void update(CocoonResponse> newStatus) { - assert(_pausedStatus != null); - _pausedStatus = newStatus; - } - - void complete() { - assert(_pausedStatus != null); - _completer.complete(_pausedStatus!); - _pausedStatus = null; - } -} - -/// [CocoonService] for local development purposes. -/// -/// This creates fake data that mimicks what production will send. -class DevelopmentCocoonService implements CocoonService { - DevelopmentCocoonService(this.now, {this.simulateLoadingDelays = false}) - : _random = math.Random(now.millisecondsSinceEpoch); - - final math.Random _random; - - final DateTime now; - - final bool simulateLoadingDelays; - - _PausedCommitStatus? _pausedStatus; - bool _paused = false; - bool get paused => _paused; - set paused(bool pause) { - if (_paused == pause) { - return; - } - assert(_paused || _pausedStatus == null || _pausedStatus!.isComplete); - if (_pausedStatus != null && !_pausedStatus!.isComplete) { - _pausedStatus!.complete(); - _pausedStatus = null; - } - _paused = pause; - } - - @override - Future>> fetchCommitStatuses({ - CommitStatus? lastCommitStatus, - String? branch, - required String repo, - }) async { - final data = CocoonResponse>.data( - _createFakeCommitStatuses(lastCommitStatus, repo, branch: branch), - ); - if (_pausedStatus == null || _pausedStatus!.isComplete) { - _pausedStatus = _PausedCommitStatus(data); - } else { - _pausedStatus!.update(data); - } - - if (!_paused) { - if (simulateLoadingDelays) { - final delayedStatus = _pausedStatus; - Future.delayed(const Duration(seconds: 2), () { - if (!_paused && !delayedStatus!.isComplete) { - delayedStatus.complete(); - } - }); - } else { - _pausedStatus!.complete(); - } - } - - return _pausedStatus!.future; - } - - final _treeStatusChanges = >{}; - static final _defaultChanges = [ - TreeStatusChange( - createdOn: DateTime.now().subtract(const Duration(hours: 1)), - status: TreeStatus.success, - authoredBy: 'Joe Admin', - reason: 'Test', - ), - TreeStatusChange( - createdOn: DateTime.now().subtract(const Duration(hours: 2)), - status: TreeStatus.failure, - authoredBy: 'Joe Admin', - reason: 'Test', - ), - ]; - - @override - Future>> fetchTreeStatusChanges({ - required String idToken, - required String repo, - }) async { - return CocoonResponse>.data( - _treeStatusChanges.putIfAbsent(repo, () => [..._defaultChanges]), - ); - } - - final _suppressedTests = >{}; - - @override - Future>> fetchSuppressedTests({ - String? repo, - }) async { - final effectiveRepo = repo ?? 'flutter/flutter'; - // Initialize with a default suppressed test if the repo hasn't been accessed yet - if (!_suppressedTests.containsKey(effectiveRepo)) { - _suppressedTests[effectiveRepo] = [ - SuppressedTest( - name: 'Linux_android 0', - repository: effectiveRepo, - issueLink: 'https://github.com/flutter/flutter/issues/123', - createTimestamp: now.millisecondsSinceEpoch, - ), - ]; - } - return CocoonResponse.data(_suppressedTests[effectiveRepo]!); - } - - @override - Future> updateTestSuppression({ - required String idToken, - required String repo, - required String testName, - required bool suppress, - String? issueLink, - String? note, - }) async { - final list = _suppressedTests.putIfAbsent(repo, () => []); - if (suppress) { - if (!list.any((t) => t.name == testName)) { - list.add( - SuppressedTest( - name: testName, - repository: repo, - issueLink: - issueLink ?? 'https://github.com/flutter/flutter/issues/123', - createTimestamp: DateTime.now().millisecondsSinceEpoch, - updates: [ - SuppressionUpdate( - user: 'dev-user', - action: 'SUPPRESS', - updateTimestamp: DateTime.now().millisecondsSinceEpoch, - note: note, - ), - ], - ), - ); - } - } else { - list.removeWhere((t) => t.name == testName); - } - return const CocoonResponse.data(null); - } - - @override - Future> fetchPresubmitGuard({ - required String repo, - required String sha, - }) async { - if (sha == 'cafe5_1_mock_sha') { - return CocoonResponse.data( - PresubmitGuardResponse( - prNum: 123, - checkRunId: 456, - author: _authors[0], - guardStatus: GuardStatus.inProgress, - stages: [ - PresubmitGuardStage( - name: 'Engine', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Mac mac_host_engine': TaskStatus.infraFailure, - 'Mac mac_ios_engine': TaskStatus.cancelled, - 'Linux linux_android_aot_engine': TaskStatus.infraFailure, - }, - ), - ], - ), - ); - } else if (sha == 'face5_2_mock_sha') { - return CocoonResponse.data( - PresubmitGuardResponse( - prNum: 123, - checkRunId: 789, - author: _authors[1], - guardStatus: GuardStatus.failed, - stages: [ - PresubmitGuardStage( - name: 'Engine', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Mac mac_host_engine': TaskStatus.succeeded, - 'Mac mac_ios_engine': TaskStatus.cancelled, - 'Linux linux_android_aot_engine': TaskStatus.succeeded, - }, - ), - PresubmitGuardStage( - name: 'Framework', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Linux framework_tests': TaskStatus.succeeded, - 'Mac framework_tests': TaskStatus.failed, - 'Linux android framework_tests': TaskStatus.skipped, - 'Windows framework_tests': TaskStatus.failed, - }, - ), - ], - ), - ); - } else if (sha == 'decaf_3_mock_sha') { - return CocoonResponse.data( - PresubmitGuardResponse( - prNum: 123, - checkRunId: 1011, - author: _authors[2], - guardStatus: GuardStatus.failed, - stages: [ - PresubmitGuardStage( - name: 'Engine', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Mac mac_host_engine': TaskStatus.succeeded, - 'Mac mac_ios_engine': TaskStatus.cancelled, - 'Linux linux_android_aot_engine': TaskStatus.succeeded, - }, - ), - PresubmitGuardStage( - name: 'Framework', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Linux framework_tests': TaskStatus.succeeded, - 'Mac framework_tests': TaskStatus.waitingForBackfill, - 'Linux android framework_tests': TaskStatus.skipped, - 'Windows framework_tests': TaskStatus.inProgress, - }, - ), - ], - ), - ); - } else if (sha == 'deafcab_mock_sha') { - return CocoonResponse.data( - PresubmitGuardResponse( - prNum: 123, - checkRunId: 369, - author: _authors[3], - guardStatus: GuardStatus.succeeded, - stages: [ - PresubmitGuardStage( - name: 'Engine', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Mac mac_host_engine': TaskStatus.succeeded, - 'Mac mac_ios_engine': TaskStatus.cancelled, - 'Linux linux_android_aot_engine': TaskStatus.succeeded, - }, - ), - PresubmitGuardStage( - name: 'Framework', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Linux framework_tests': TaskStatus.succeeded, - 'Mac framework_tests': TaskStatus.succeeded, - 'Linux android framework_tests': TaskStatus.skipped, - 'Windows framework_tests': TaskStatus.succeeded, - }, - ), - ], - ), - ); - } - return CocoonResponse.error( - 'No presubmit guard data for sha $sha', - statusCode: 404, - ); - } - - @override - Future>> - fetchPresubmitCheckDetails({ - required int checkRunId, - required String buildName, - }) async { - return CocoonResponse.data([ - PresubmitCheckResponse( - attemptNumber: 1, - buildName: buildName, - creationTime: now.millisecondsSinceEpoch - 10000, - status: 'Succeeded', - buildNumber: 12345, - summary: - ''' -[INFO] Starting task $buildName... -[SUCCESS] Dependencies installed. -[INFO] Running build script... -[SUCCESS] All tests passed (452/452) -''', - ), - PresubmitCheckResponse( - attemptNumber: 2, - buildName: buildName, - creationTime: now.millisecondsSinceEpoch, - status: 'Failed', - buildNumber: 67890, - summary: - '[INFO] Starting task $buildName...\n[ERROR] Test failed: Unit Tests', - ), - ]); - } - - @override - Future>> - fetchPresubmitGuardSummaries({ - required String repo, - required String pr, - }) async { - return CocoonResponse.data([ - PresubmitGuardSummary( - commitSha: 'decaf_3_mock_sha', - creationTime: now.millisecondsSinceEpoch, - guardStatus: GuardStatus.inProgress, - ), - PresubmitGuardSummary( - commitSha: 'face5_2_mock_sha', - creationTime: now.millisecondsSinceEpoch - 100000, - guardStatus: GuardStatus.failed, - ), - PresubmitGuardSummary( - commitSha: 'cafe5_1_mock_sha', - creationTime: now.millisecondsSinceEpoch - 200000, - guardStatus: GuardStatus.failed, - ), - PresubmitGuardSummary( - commitSha: 'deafcab_mock_sha', - creationTime: now.millisecondsSinceEpoch - 300000, - guardStatus: GuardStatus.succeeded, - ), - ]); - } - - @override - Future> updateTreeStatus({ - required String idToken, - required String repo, - required TreeStatus status, - String? reason, - }) async { - // At most 10 per. - final list = _treeStatusChanges.putIfAbsent( - repo, - () => [..._defaultChanges], - ); - list.insert( - 0, - TreeStatusChange( - createdOn: DateTime.now(), - status: status, - authoredBy: 'Joe Widget', - reason: reason, - ), - ); - if (list.length > 10) { - list.removeLast(); - } - return const CocoonResponse.data(null); - } - - static const List _repos = ['flutter', 'cocoon']; - - @override - Future>> fetchRepos() async { - return const CocoonResponse>.data(_repos); - } - - @override - Future> fetchTreeBuildStatus({ - String? branch, - required String repo, - }) async { - final failed = _random.nextBool(); - final response = BuildStatusResponse( - buildStatus: failed ? BuildStatus.failure : BuildStatus.success, - failingTasks: [ - if (failed) ...['failed_task_1', 'failed_task_2'], - ], - ); - return CocoonResponse.data(response); - } - - @override - Future>> fetchFlutterBranches() async { - return CocoonResponse.data([ - Branch(channel: 'master', reference: 'master'), - Branch(channel: 'stable', reference: 'flutter-3.13-candidate.0'), - Branch(channel: 'beta', reference: 'flutter-3.15-candidate.5'), - Branch(channel: 'dev', reference: 'flutter-3.15-candidate.12'), - ]); - } - - @override - Future> vacuumGitHubCommits( - String idToken, { - required String repo, - required String branch, - }) async { - return const CocoonResponse.error( - 'Unable to vacuum against fake data. Try building the app to use prod data.', - statusCode: 501 /* Not implemented */, - ); - } - - @override - Future> rerunTask({ - required String? idToken, - required String taskName, - required String commitSha, - required String repo, - required String branch, - }) async { - return const CocoonResponse.error( - 'Unable to retry against fake data. Try building the app to use prod data.', - statusCode: 501 /* Not implemented */, - ); - } - - @override - Future> rerunCommit({ - required String? idToken, - required String commitSha, - required String repo, - required String branch, - Iterable? include, - }) async { - return const CocoonResponse.error( - 'Unable to schedule against fake data. Try building the app to use prod data.', - statusCode: 501 /* Not implemented */, - ); - } - - @override - Future>> fetchMergeQueueHooks({ - required String idToken, - }) async { - return const CocoonResponse>.data([]); - } - - @override - Future> replayGitHubWebhook({ - required String idToken, - required String id, - }) async { - return const CocoonResponse.data(null); - } - - static const int _commitGap = 2 * 60 * 1000; // 2 minutes between commits - - List _createFakeCommitStatuses( - CommitStatus? lastCommitStatus, - String repo, { - String? branch, - }) { - branch ??= defaultBranches[repo]!; - final baseTimestamp = lastCommitStatus != null - ? (lastCommitStatus.commit.timestamp.toInt()) - : now.millisecondsSinceEpoch; - final result = []; - for (var index = 0; index < 25; index += 1) { - final commitTimestamp = baseTimestamp - ((index + 1) * _commitGap); - final random = math.Random(commitTimestamp); - final commit = _createFakeCommit( - commitTimestamp, - random, - repo, - _commits[index], - branch, - ); - final status = CommitStatus( - commit: commit, - tasks: _createFakeTasks(commitTimestamp, commit, random), - ); - result.add(status); - } - return result; - } - - final List _authors = [ - 'matan', - 'yegor', - 'john', - 'jenn', - 'kate', - 'stuart', - ]; - final _avatars = [ - 'https://avatars.githubusercontent.com/u/168174?v=4', - 'https://avatars.githubusercontent.com/u/211513?v=4', - 'https://avatars.githubusercontent.com/u/1924313?v=4', - 'https://avatars.githubusercontent.com/u/682784?v=4', - 'https://avatars.githubusercontent.com/u/16964204?v=4', - 'https://avatars.githubusercontent.com/u/122189?v=4', - ]; - - final List _messagePrimes = [3, 11, 17, 23, 31, 41, 47, 67, 79]; - final List _words = [ - 'fixes', - 'issue', - 'crash', - 'developer', - 'blocker', - 'intermittent', - 'format', - ]; - final List _commits = [ - '2d22b5e85f986f3fa2cf1bfaf085905c2182c270', - '2fd76f920a38e4384248173d05ee482d5aeaf4c5', - '77238bc7bf35489df03bc00ce2b2231a1afe6b06', - '01d87b7a802e6ea388a066e773b1af3dace44053', - '754039ae0cc524db1052da0f22c9275e32fe4f54', - '1f5e006a398fa5d0e59f78cd5071e2532d2fe438', - 'a798b24044d567df62b8693b179932a8364c8dd8', - '4389a8a3dbe1ed4c6a643641e95d7759f2158d9e', - 'c05e886884ec9adff2f43b87dbcb02e3507d971b', - 'a2acc46447cdfcd6628a897dea27ff64849bfc99', - 'd31d67ffb38fbd09ecf0a11ad5f6fd433cec9c9f', - 'e303d2c71c956c9c1eb7bf81473aac20d756eb75', - '789f6bb335fe31de5d9c6adbab2fc169030a057f', - 'c97f0670658e04f096a3e57b20fa8241306ffcaa', - 'e8e31198861b5f53f04900bdf9a54e8bf6b7d597', - '434a0a7d3c4c3bc633e06de7707ef590c54c20c3', - 'e7fca29b3f0408c7c1726e270a5aba0e28e74090', - 'dd6f94d8573a77506a122899a0592a956ac57bec', - 'cb19ec23d79b9d422f577722e5a14253fdcaea71', - '4cb8c6498aedfd2ff0f89e34eb5da993a77392bd', - 'de892884aa089f22aced4d19a71b6a1d521c8db6', - '214470ceb026525fa225d52dfe7d27db2c4ddf31', - 'e0f4628b4379286c433bab020b9e193fdc437d05', - 'be5975cb0b7fcad7ab2c3122a8df3d541befdeab', - '06f7ab60d4914e00b342f098e1ef3e43e501b469', - 'e1005bea192673e54faa0c769d9f0fb7439a09b4', - 'e22d7f6bf2e1e1969dd963d39ad32c756fb0f20e', - 'f4b4b20bb27cbbd1c42eee7b17ce39c5819dc818', - 'd717ed969b6a477499eb3cf823c78dbe654ca709', - '6a8d6a42b4c9f4b72caf0a3b808e50909686a2b7', - 'afaa9bfa5d26ab419f791d5ca97d602ec52a30a5', - 'f553212d9bfbc6e70c0d6a4ac3fe71208bb77ca1', - 'c35f8b79f4a5103603ceaaa14d1df3857a166fa1', - '46447341838d480966926d0b32771e281af1c885', - '4e3330081d4e0a3e109cf1cfb514072a90b999d7', - '4199ef93c3184c29362520ea5292d854a8728494', - 'a0538938974c60cb9249acf8f0588c3df3c0e4b1', - '49cac0b6c1e0d7d1d04865c328c8cbe5c8e0cda2', - '97e3cc6295515f8292f5f57868506c70446594c3', - 'c94e577ef3b0ab48255633c63f0143d9e3eab6f8', - '17d9af726fb6e4a4a8cc4f1becfc11dc9d4db96c', - '4af6ec81ad538e3da23ee88cba65ed8687a72ea3', - '69cb45166d2f4f61069e1f1a975dab3f48bed832', - 'ae227082570f75b614bb29593911ada5137654ad', - '882b9fa44962df0b80d9a25b2553796bd4eba2ff', - 'e1f5c4c7178c34ce561c493b558df7450995a60c', - '56d7272cd497c73502ec5a09bdf69b4c7ecbfd74', - '36a99b302f7348848ec477ce867a8b78656d9c6c', - '48be7a01565289f44c2c9d4ff1436800c52acf75', - '422cb82e01ee0192256a05217102f45f2f74551d', - 'f8744d10460abae3d75e92336b3bb264bb78cc8c', - '5000f246c8c568547c551b11e4b72acd0179e73c', - '52c09522b288766a42900aa73f77216907d16b23', - '61adb59fc097335b45b23fc884a78362b71a3e9b', - 'b81c3c58e5a6c45bc8728024068291a7e5f19c1a', - 'dec394e5401b62024ed71253e900c25a06eb46f9', - 'fbe142d2cb60fb981521cfe72961642d69db8784', - '24a9c4557e0366945c179433fab8434c2c8ee59f', - 'ccc28f607ae11fe79c39c42d88798c19b1388af9', - 'd4d5ca665e56063fdd37d42d2192c64151915454', - '4273fc07c3e8a36726087becdf331f7d63fc7e66', - '5e0380895cc8e34a6a58f238b7fd33b9e0f027fe', - '146908e48a8a9f92f59fae86a7a4ff28c0ce9109', - '2779a449026423bc0f50a10eead2d9f3720b825c', - 'ff9b954908341360ffb39f5eee2857172a29c0e5', - 'b00b6d146c4f4af1d28260a3f28abd15f0105221', - '4ffd03df44741d73877a0848935eea0d6cbfc1e9', - 'c2c5b73aaae2583cc235319e53200e0e15cda438', - 'b7b370faf46f4c682827fbb3967e1ef117b3b0d1', - 'bc6c9ac81e5909408518568729c2c3a147402928', - 'ba1aa77c5bc5fe545b245f2b1883af3dfeeae6c0', - '97a7060a7e28ab1d13cb2f4ca90be624b417168e', - '3df1da69ffcb4e00cf13c65c9f0497a56413e5e9', - 'ebb13b4ed0cb41814839d4084c064e62526b2ec0', - '70b9d50722307f0f45ceed01d421d6a425e95291', - '3f81ca7b73f21ca9b449c147942fbe4fcd31450d', - '86d490939df232966dc6ba363ebc0baaac364701', - '6ba51f3f61025543d66e59c2c1ae3d1b37d5d8d6', - 'b728942e1c9a9046c1a5c8d8a25a644d0db7160c', - '920819252a398c0e3da9b6017b110ee047c1748e', - 'd7c114d803b1365ea3a975c9600fc8cfd9efb9d4', - '792aa82143bb12e97f396cb2a462ad617dbd22bc', - ]; - - Commit _createFakeCommit( - int commitTimestamp, - math.Random random, - String repo, - String commitSha, - String branch, - ) { - final author = random.nextInt(_authors.length); - final message = commitTimestamp % 37 + author; - final messageInc = _messagePrimes[message % _messagePrimes.length]; - return Commit( - author: CommitAuthor( - login: _authors[author], - avatarUrl: _avatars[author], - ), - message: List.generate( - 6, - (int i) => _words[(message + i * messageInc) % _words.length], - ).join(' '), - repository: 'flutter/$repo', - sha: commitSha, - timestamp: commitTimestamp, - branch: branch, - ); - } - - static const Map _repoTaskCount = { - 'flutter/cocoon': 3, - 'flutter/flutter': 100, - 'flutter/engine': 20, - }; - - List _createFakeTasks( - int commitTimestamp, - Commit commit, - math.Random random, - ) { - if (_repoTaskCount.containsKey(commit.repository) == false) { - throw Exception( - 'Add ${commit.repository} to _repoTaskCount in DevCocoonService', - ); - } - return List.generate( - _repoTaskCount[commit.repository]!, - (int i) => _createFakeTask(commitTimestamp, i, random), - ); - } - - static const _statuses = [ - TaskStatus.waitingForBackfill, - TaskStatus.inProgress, - TaskStatus.succeeded, - TaskStatus.failed, - TaskStatus.infraFailure, - TaskStatus.skipped, - TaskStatus.cancelled, - ]; - - static const _minAttempts = { - TaskStatus.waitingForBackfill: 0, - TaskStatus.inProgress: 1, - TaskStatus.succeeded: 1, - TaskStatus.failed: 1, - TaskStatus.infraFailure: 1, - TaskStatus.skipped: 0, - TaskStatus.cancelled: 1, - }; - - static const _maxAttempts = { - TaskStatus.waitingForBackfill: 0, - TaskStatus.inProgress: 2, - TaskStatus.succeeded: 1, - TaskStatus.failed: 2, - TaskStatus.infraFailure: 2, - TaskStatus.skipped: 0, - TaskStatus.cancelled: 1, - }; - - Task _createFakeTask(int commitTimestamp, int index, math.Random random) { - final age = (now.millisecondsSinceEpoch - commitTimestamp) ~/ _commitGap; - assert(age >= 0); - // The [statusesProbability] list is an list of proportional - // weights to give each of the values in _statuses when randomly - // determining the status. So e.g. if one is 150, another 50, and - // the rest 0, then the first has a 75% chance of being picked, - // the second a 25% chance, and the rest a 0% chance. - final statusesProbability = [ - // bigger = more probable - math.max(index % 2, 20 - age * 2), // TaskStatus.waitingForBackfill - math.max(0, 10 - age * 2), // TaskStatus.inProgress - math.min(10 + age * 2, 100), // TaskStatus.succeeded - math.min(1 + age ~/ 3, 30), // TaskStatus.failed - if (index % 15 == 0) // TaskStatus.infraFailure - 5 - else if (index % 25 == 0) - 15 - else - 1, - if (index % 20 == 0) 30, - 1, // TaskStatus.cancelled - ]; - // max is the sum of all the values in statusesProbability. - final max = statusesProbability.fold(0, (int c, int p) => c + p); - // weightedIndex is the random number in the range 0 <= weightedIndex < max. - var weightedIndex = random.nextInt(max); - // statusIndex is the actual index into _statuses that corresponds - // to the randomly selected weightedIndex. So if - // statusesProbability is 10,20,30 and weightedIndex is 15, then - // the statusIndex will be 1 (corresponding to the second entry, - // the one with weight 20, since lists are zero-indexed). - var statusIndex = 0; - while (weightedIndex > statusesProbability[statusIndex]) { - weightedIndex -= statusesProbability[statusIndex]; - statusIndex += 1; - } - // Finally we get the actual status using statusIndex as an index into _statuses. - final status = _statuses[statusIndex]; - final minAttempts = _minAttempts[status]!; - final maxAttempts = _maxAttempts[status]!; - final attempts = - minAttempts + random.nextInt(maxAttempts - minAttempts + 1); - - final buildNumberList = List.generate( - attempts > 1 - ? random.nextBool() - ? attempts - : attempts - 1 - : 1, - (i) => i, - ); - - return Task( - createTimestamp: commitTimestamp + index, - startTimestamp: commitTimestamp + (index * 1000 * 60), - endTimestamp: commitTimestamp + (index * 1000 * 60) + (index * 1000 * 60), - - builderName: 'Linux_android $index', - attempts: attempts, - currentBuildNumber: attempts == buildNumberList.length - ? buildNumberList.last - : null, - buildNumberList: buildNumberList, - isBringup: index == now.millisecondsSinceEpoch % 13, - - status: status, - - // Neither of these are strictly true from a domain perspective. - lastAttemptFailed: random.nextBool() ? attempts > 1 : false, - isFlaky: attempts > 1, - ); - } -} diff --git a/dashboard/lib/service/integration_server_adapter.dart b/dashboard/lib/service/integration_server_adapter.dart new file mode 100644 index 000000000..168037341 --- /dev/null +++ b/dashboard/lib/service/integration_server_adapter.dart @@ -0,0 +1,168 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cocoon_common/rpc_model.dart'; +import 'package:cocoon_integration_test/cocoon_integration_test.dart'; +import 'package:cocoon_service/cocoon_service.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'appengine_cocoon.dart'; +import 'cocoon.dart'; +import 'data_seeder.dart'; +import 'scenarios.dart'; + +/// Adapter to wrap the [IntegrationServer] and expose it as a [CocoonService]. +/// +/// This adapter intercepts HTTP requests and routes them to the in-memory +/// [IntegrationServer] instance. +class IntegrationServerAdapter extends AppEngineCocoonService { + final DateTime _now; + IntegrationServerAdapter(this._server, {bool seed = true, DateTime? now}) + : _now = now ?? DateTime.utc(2020), + super( + client: MockClient((http.Request request) async { + final fakeRequest = _FakeRequest( + uri: request.url, + method: request.method, + body: request.body, + headers: request.headers, + ); + + await _server.server(fakeRequest); + final fakeResponse = fakeRequest.response; + + return http.Response( + fakeResponse.bodyString, + fakeResponse.statusCode, + headers: fakeResponse.contentType != null + ? { + HttpHeaders.contentTypeHeader: fakeResponse.contentType + .toString(), + } + : {}, + ); + }), + ) { + if (seed) { + DataSeeder(_server).seed(now: _now); + } + } + + final IntegrationServer _server; + + bool _paused = false; + + /// Whether requests to this adapter are paused. + /// + /// When true, [fetchCommitStatuses] will not complete until [paused] is set + /// back to false. + bool get paused => _paused; + set paused(bool value) { + _paused = value; + if (!_paused) { + _pauseCompleter?.complete(); + _pauseCompleter = null; + } + } + + Completer? _pauseCompleter; + + @override + Future>> fetchCommitStatuses({ + CommitStatus? lastCommitStatus, + String? branch, + required String repo, + }) async { + if (_paused) { + _pauseCompleter ??= Completer(); + await _pauseCompleter!.future; + } + return super.fetchCommitStatuses( + lastCommitStatus: lastCommitStatus, + branch: branch, + repo: repo, + ); + } + + @override + void resetScenario(Scenario scenario) { + _server.firestore.reset(); + DataSeeder(_server, scenario: scenario).seed(now: _now); + } +} + +class _FakeRequest implements Request { + _FakeRequest({ + required this.uri, + required this.method, + required this.body, + required Map headers, + }) : _headers = headers.map((k, v) => MapEntry(k.toLowerCase(), v)), + response = _FakeRequestResponse(); + + @override + final Uri uri; + + @override + final String method; + + final String body; + + final Map _headers; + + @override + final _FakeRequestResponse response; + + @override + String? header(String name) => _headers[name.toLowerCase()]; + + @override + Future readBodyAsBytes() async => + Uint8List.fromList(utf8.encode(body)); + + @override + Future readBodyAsString() async => body; + + @override + Future> readBodyAsJson() async => + jsonDecode(body) as Map; +} + +class _FakeRequestResponse implements RequestResponse { + final List _chunks = []; + + @override + int statusCode = HttpStatus.ok; + + @override + MediaType? contentType; + + String get bodyString => utf8.decode(_chunks.expand((x) => x).toList()); + + @override + Future addStream(Stream stream) async { + await for (final chunk in stream) { + _chunks.add(chunk); + } + } + + @override + Future flush() async {} + + @override + Future close() async {} + + @override + Future redirect( + Uri location, { + int status = HttpStatus.movedTemporarily, + }) async { + statusCode = status; + } +} diff --git a/dashboard/lib/service/scenarios.dart b/dashboard/lib/service/scenarios.dart new file mode 100644 index 000000000..836388477 --- /dev/null +++ b/dashboard/lib/service/scenarios.dart @@ -0,0 +1,21 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Definitions of data scenarios for development and testing. +enum Scenario { + /// Realistic distribution of task statuses. + realistic, + + /// All tasks succeeded for all commits. + allGreen, + + /// All non-bringup tasks failed for the most recent commit. + redTree, + + /// Many commits with many tasks, to test performance. + highLoad, + + /// No commits found. + empty, +} diff --git a/dashboard/lib/state/build.dart b/dashboard/lib/state/build.dart index 5cf9e04e4..63ac034aa 100644 --- a/dashboard/lib/state/build.dart +++ b/dashboard/lib/state/build.dart @@ -12,6 +12,7 @@ import 'package:flutter_app_icons/flutter_app_icons.dart'; import '../logic/brooks.dart'; import '../service/cocoon.dart'; import '../service/firebase_auth.dart'; +import '../service/scenarios.dart'; /// State for the Flutter Build Dashboard. class BuildState extends ChangeNotifier { @@ -93,6 +94,18 @@ class BuildState extends ChangeNotifier { static const String errorMessageRefreshGitHubCommits = 'An error occurred refreshing GitHub commits.'; + /// Resets the data scenario for the dashboard. + /// + /// Only effective in debug mode when using fake data. + Future resetScenario(Scenario scenario) async { + cocoonService.resetScenario(scenario); + _moreStatusesExist = true; + _isTreeBuilding = null; + _failingTasks = []; + _statuses = []; + await _fetchStatusUpdates(); + } + /// How often to query the Cocoon backend for the current build state. @visibleForTesting final Duration? refreshRate = const Duration(seconds: 30); diff --git a/dashboard/pubspec.yaml b/dashboard/pubspec.yaml index 6d35c6afb..48acf7e5d 100644 --- a/dashboard/pubspec.yaml +++ b/dashboard/pubspec.yaml @@ -17,8 +17,11 @@ dependencies: cached_network_image_platform_interface: ^4.1.1 cocoon_common: path: ../packages/cocoon_common + cocoon_integration_test: + path: ../packages/cocoon_integration_test cocoon_service: ^0.0.0 collection: any # Match Flutter SDK + crypto: ^3.0.7 firebase_auth: ^6.0.0 firebase_core: ^4.4.0 # Rolled by dependabot firebase_crashlytics: 5.0.6 # Rolled by dependabot @@ -29,6 +32,7 @@ dependencies: google_sign_in: ^7.0.0 google_sign_in_platform_interface: ^3.0.0 google_sign_in_web: ^1.0.0 + googleapis: ^14.0.0 http: 1.6.0 # Rolled by dependabot intl: ^0.20.2 json_annotation: ^4.9.0 @@ -42,8 +46,7 @@ dependencies: dev_dependencies: build_runner: ^2.4.15 # Rolled by dependabot - cocoon_integration_test: - path: ../packages/cocoon_integration_test + cocoon_server_test: ^0.0.0 dart_flutter_team_lints: 3.5.2 flutter_test: sdk: flutter diff --git a/dashboard/test/build_dashboard_page_test.dart b/dashboard/test/build_dashboard_page_test.dart index 77cdc6b7f..3f18570bd 100644 --- a/dashboard/test/build_dashboard_page_test.dart +++ b/dashboard/test/build_dashboard_page_test.dart @@ -6,11 +6,12 @@ import 'dart:async'; import 'package:cocoon_common/rpc_model.dart'; import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_integration_test/cocoon_integration_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_app_icons/flutter_app_icons_platform_interface.dart'; import 'package:flutter_dashboard/service/cocoon.dart'; -import 'package:flutter_dashboard/service/dev_cocoon.dart'; import 'package:flutter_dashboard/service/firebase_auth.dart'; +import 'package:flutter_dashboard/service/integration_server_adapter.dart'; import 'package:flutter_dashboard/state/build.dart'; import 'package:flutter_dashboard/views/build_dashboard_page.dart'; import 'package:flutter_dashboard/widgets/commit_box.dart'; @@ -552,10 +553,9 @@ void main() { testWidgets('TaskGridContainer with default Settings property sheet', ( WidgetTester tester, ) async { - configureView(tester.view); await precacheTaskIcons(tester); final buildState = BuildState( - cocoonService: DevelopmentCocoonService(DateTime.utc(2020)), + cocoonService: IntegrationServerAdapter(IntegrationServer()), authService: fakeAuthService, ); void listener1() {} @@ -593,7 +593,7 @@ void main() { configureView(tester.view); await precacheTaskIcons(tester); final buildState = BuildState( - cocoonService: DevelopmentCocoonService(DateTime.utc(2020)), + cocoonService: IntegrationServerAdapter(IntegrationServer()), authService: fakeAuthService, ); void listener1() {} diff --git a/dashboard/test/goldens/build_dashboard.defaultPropertySheet.dark.png b/dashboard/test/goldens/build_dashboard.defaultPropertySheet.dark.png index 4508f09f2..55dd7cf1a 100644 Binary files a/dashboard/test/goldens/build_dashboard.defaultPropertySheet.dark.png and b/dashboard/test/goldens/build_dashboard.defaultPropertySheet.dark.png differ diff --git a/dashboard/test/goldens/build_dashboard.defaultPropertySheet.png b/dashboard/test/goldens/build_dashboard.defaultPropertySheet.png index c7f9e9756..1aeef59f3 100644 Binary files a/dashboard/test/goldens/build_dashboard.defaultPropertySheet.png and b/dashboard/test/goldens/build_dashboard.defaultPropertySheet.png differ diff --git a/dashboard/test/service/data_seeder_test.dart b/dashboard/test/service/data_seeder_test.dart new file mode 100644 index 000000000..0769c93d8 --- /dev/null +++ b/dashboard/test/service/data_seeder_test.dart @@ -0,0 +1,33 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:cocoon_integration_test/cocoon_integration_test.dart'; +import 'package:flutter_dashboard/service/data_seeder.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('DataSeeder seeds tree status changes', () async { + final server = IntegrationServer(); + final seeder = DataSeeder(server); + seeder.seed(); + + final firestore = server.firestore; + final changes = firestore.documents + .where((d) => d.name!.contains('tree_status_change')) + .toList(); + expect(changes, isNotEmpty); + }); + + test('DataSeeder seeds suppressed tests', () async { + final server = IntegrationServer(); + final seeder = DataSeeder(server); + seeder.seed(); + + final firestore = server.firestore; + final suppressed = firestore.documents + .where((d) => d.name!.contains('suppressed_tests')) + .toList(); + expect(suppressed, isNotEmpty); + }); +} diff --git a/dashboard/test/service/dev_cocoon_test.dart b/dashboard/test/service/dev_cocoon_test.dart deleted file mode 100644 index 232e652be..000000000 --- a/dashboard/test/service/dev_cocoon_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2019 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter_dashboard/service/dev_cocoon.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('DevelopmentCocoonService', () { - late DevelopmentCocoonService service; - - setUp(() { - service = DevelopmentCocoonService(DateTime.now()); - }); - - test('fetchMergeQueueHooks returns empty list', () async { - final response = await service.fetchMergeQueueHooks(idToken: 'token'); - expect(response.error, isNull); - expect(response.data, isEmpty); - }); - - test('replayGitHubWebhook returns success', () async { - final response = await service.replayGitHubWebhook( - idToken: 'token', - id: '1', - ); - expect(response.error, isNull); - }); - }); -} diff --git a/dashboard/test/service/integration_server_adapter_test.dart b/dashboard/test/service/integration_server_adapter_test.dart new file mode 100644 index 000000000..9f0fedbe2 --- /dev/null +++ b/dashboard/test/service/integration_server_adapter_test.dart @@ -0,0 +1,56 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_integration_test/cocoon_integration_test.dart'; +import 'package:cocoon_server_test/test_logging.dart'; +import 'package:flutter_dashboard/service/data_seeder.dart'; +import 'package:flutter_dashboard/service/integration_server_adapter.dart'; +import 'package:flutter_dashboard/service/scenarios.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + useTestLoggerPerTest(); + test('IntegrationServerAdapter fetches seeded data', () async { + final server = IntegrationServer(); + final adapter = IntegrationServerAdapter(server, seed: true); + final response = await adapter.fetchCommitStatuses(repo: 'flutter'); + + expect(response.error, isNull); + expect(response.data, isNotNull); + expect(response.data!.length, 25); + + final firstCommitStatus = response.data!.first; + expect(firstCommitStatus.commit.repository, 'flutter/flutter'); + expect(firstCommitStatus.tasks.length, 100); + }); + + test('IntegrationServerAdapter allGreen scenario', () async { + final server = IntegrationServer(); + final adapter = IntegrationServerAdapter(server, seed: false); + DataSeeder(server, scenario: Scenario.allGreen).seed(); + + final response = await adapter.fetchCommitStatuses(repo: 'flutter'); + final tasks = response.data!.first.tasks; + expect(tasks.every((t) => t.status == TaskStatus.succeeded), true); + }); + + test('IntegrationServerAdapter redTree scenario', () async { + final server = IntegrationServer(); + final adapter = IntegrationServerAdapter(server, seed: false); + DataSeeder(server, scenario: Scenario.redTree).seed(); + + final response = await adapter.fetchCommitStatuses(repo: 'flutter'); + final latestCommitTasks = response.data!.first.tasks; + // In redTree, the newest commit has all failed tasks + expect(latestCommitTasks.every((t) => t.status == TaskStatus.failed), true); + + // Older commits should have realistic distribution + final secondCommitTasks = response.data![1].tasks; + expect( + secondCommitTasks.any((t) => t.status == TaskStatus.succeeded), + true, + ); + }); +} diff --git a/dashboard/test/utils/fake_build.dart b/dashboard/test/utils/fake_build.dart index 6247e4258..ff23b82d6 100644 --- a/dashboard/test/utils/fake_build.dart +++ b/dashboard/test/utils/fake_build.dart @@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_dashboard/logic/brooks.dart'; import 'package:flutter_dashboard/service/cocoon.dart'; import 'package:flutter_dashboard/service/firebase_auth.dart'; +import 'package:flutter_dashboard/service/scenarios.dart'; import 'package:flutter_dashboard/state/build.dart'; import 'package:flutter_dashboard/widgets/task_overlay.dart'; @@ -45,6 +46,9 @@ class FakeBuildState extends ChangeNotifier implements BuildState { @override Future refreshGitHubCommits() async => false; + @override + Future resetScenario(Scenario scenario) async {} + @override Future rerunTask(Task task, Commit commit) async { if (!rerunTaskResult) { diff --git a/dashboard/test/widgets/goldens/commit_box_test.idle.png b/dashboard/test/widgets/goldens/commit_box_test.idle.png index b1c2287b7..3d3aae8ff 100644 Binary files a/dashboard/test/widgets/goldens/commit_box_test.idle.png and b/dashboard/test/widgets/goldens/commit_box_test.idle.png differ diff --git a/dashboard/test/widgets/goldens/commit_box_test.open.png b/dashboard/test/widgets/goldens/commit_box_test.open.png index 28df77466..0a4d94e88 100644 Binary files a/dashboard/test/widgets/goldens/commit_box_test.open.png and b/dashboard/test/widgets/goldens/commit_box_test.open.png differ diff --git a/dashboard/test/widgets/goldens/sign_in_button.not_authenticated.png b/dashboard/test/widgets/goldens/sign_in_button.not_authenticated.png index 6b734c8df..7f350ddef 100644 Binary files a/dashboard/test/widgets/goldens/sign_in_button.not_authenticated.png and b/dashboard/test/widgets/goldens/sign_in_button.not_authenticated.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_x.png b/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_x.png index 3973f8179..30e03fd94 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_x.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_x.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_y.png b/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_y.png index 59822b821..8df311007 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_y.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.mouse_scroll_y.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.origin.dark.png b/dashboard/test/widgets/goldens/task_grid_test.dev.origin.dark.png index 1329f739d..468fea021 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.origin.dark.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.origin.dark.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.origin.png b/dashboard/test/widgets/goldens/task_grid_test.dev.origin.png index 5bd59c615..a390e57ef 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.origin.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.origin.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.dark.png b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.dark.png index 84c46173a..b30c5fd41 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.dark.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.dark.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.png b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.png index 3973f8179..30e03fd94 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_x.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.dark.png b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.dark.png index e41870048..af3fbdbc1 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.dark.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.dark.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.png b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.png index 59822b821..8df311007 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.png and b/dashboard/test/widgets/goldens/task_grid_test.dev.scroll_y.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.filterDefault.differentTypes.png b/dashboard/test/widgets/goldens/task_grid_test.filterDefault.differentTypes.png index 6eb517712..19ece9f03 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.filterDefault.differentTypes.png and b/dashboard/test/widgets/goldens/task_grid_test.filterDefault.differentTypes.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.filterShowBringup.differentTypes.png b/dashboard/test/widgets/goldens/task_grid_test.filterShowBringup.differentTypes.png index c237f1fb4..c8cea7a3c 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.filterShowBringup.differentTypes.png and b/dashboard/test/widgets/goldens/task_grid_test.filterShowBringup.differentTypes.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.withL.png b/dashboard/test/widgets/goldens/task_grid_test.withL.png index 084a690c7..7f2dda47f 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.withL.png and b/dashboard/test/widgets/goldens/task_grid_test.withL.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.withSkips.png b/dashboard/test/widgets/goldens/task_grid_test.withSkips.png index db9e1bb75..2ce59b42d 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.withSkips.png and b/dashboard/test/widgets/goldens/task_grid_test.withSkips.png differ diff --git a/dashboard/test/widgets/goldens/task_grid_test.withoutL.png b/dashboard/test/widgets/goldens/task_grid_test.withoutL.png index a49f1cb34..dd49c249a 100644 Binary files a/dashboard/test/widgets/goldens/task_grid_test.withoutL.png and b/dashboard/test/widgets/goldens/task_grid_test.withoutL.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_closed.png b/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_closed.png index 28fc41a9e..05784bc4f 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_closed.png and b/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_closed.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_open.png b/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_open.png index 658677703..e9cf63624 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_open.png and b/dashboard/test/widgets/goldens/task_overlay_test.flaky_overlay_open.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_closed.png b/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_closed.png index f362f56bb..b77ccf9f2 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_closed.png and b/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_closed.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_open.png b/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_open.png index b91347fdd..126350408 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_open.png and b/dashboard/test/widgets/goldens/task_overlay_test.nondevicelab_open.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_closed.png b/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_closed.png index 52f84b7ee..873412df1 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_closed.png and b/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_closed.png differ diff --git a/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_open.png b/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_open.png index 194a90827..b3baf7969 100644 Binary files a/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_open.png and b/dashboard/test/widgets/goldens/task_overlay_test.normal_overlay_open.png differ diff --git a/dashboard/test/widgets/task_grid_test.dart b/dashboard/test/widgets/task_grid_test.dart index bb3b7b0a4..bef8a6171 100644 --- a/dashboard/test/widgets/task_grid_test.dart +++ b/dashboard/test/widgets/task_grid_test.dart @@ -6,12 +6,13 @@ import 'dart:typed_data'; import 'package:cocoon_common/rpc_model.dart'; import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_integration_test/cocoon_integration_test.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_app_icons/flutter_app_icons_platform_interface.dart'; import 'package:flutter_dashboard/logic/task_grid_filter.dart'; -import 'package:flutter_dashboard/service/dev_cocoon.dart'; +import 'package:flutter_dashboard/service/integration_server_adapter.dart'; import 'package:flutter_dashboard/state/build.dart'; import 'package:flutter_dashboard/widgets/commit_box.dart'; import 'package:flutter_dashboard/widgets/lattice.dart'; @@ -51,11 +52,11 @@ void main() { }, ); - testWidgets('TaskGridContainer with DevelopmentCocoonService', ( + testWidgets('TaskGridContainer with IntegrationServerAdapter', ( WidgetTester tester, ) async { await precacheTaskIcons(tester); - final service = DevelopmentCocoonService(DateTime.utc(2020)); + final service = IntegrationServerAdapter(IntegrationServer()); final buildState = BuildState( cocoonService: service, authService: MockFirebaseAuthService(), @@ -122,7 +123,7 @@ void main() { WidgetTester tester, ) async { await precacheTaskIcons(tester); - final service = DevelopmentCocoonService(DateTime.utc(2020)); + final service = IntegrationServerAdapter(IntegrationServer()); final buildState = BuildState( cocoonService: service, authService: MockFirebaseAuthService(), @@ -208,11 +209,11 @@ void main() { buildState.dispose(); }); - testWidgets('TaskGridContainer with DevelopmentCocoonService - dark', ( + testWidgets('TaskGridContainer with IntegrationServerAdapter - dark', ( WidgetTester tester, ) async { await precacheTaskIcons(tester); - final service = DevelopmentCocoonService(DateTime.utc(2020)); + final service = IntegrationServerAdapter(IntegrationServer()); final buildState = BuildState( cocoonService: service, authService: MockFirebaseAuthService(), @@ -282,7 +283,7 @@ void main() { int cols, ) async { final buildState = BuildState( - cocoonService: DevelopmentCocoonService(DateTime.utc(2020)), + cocoonService: IntegrationServerAdapter(IntegrationServer()), authService: MockFirebaseAuthService(), ); void listener1() {} @@ -334,7 +335,7 @@ void main() { await testGrid( tester, TaskGridFilter()..authorFilter = RegExp('yegor'), - 8, + 4, 100, ); await testGrid( @@ -346,8 +347,8 @@ void main() { await testGrid( tester, TaskGridFilter() - ..hashFilter = RegExp('2d22b5e85f986f3fa2cf1bfaf085905c2182c270'), - 4, + ..hashFilter = RegExp('fb75b2b671c7702b549a80a420144097f4fab5a9'), + 2, // codefu: these are magic numbers and this test is bad. 100, ); }); diff --git a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart index c3dca0682..391f2b544 100644 --- a/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart +++ b/packages/cocoon_integration_test/lib/src/fakes/fake_config.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:cocoon_service/cocoon_service.dart'; -import 'package:cocoon_service/src/service/flags/dynamic_config.dart'; import 'package:cocoon_service/src/service/github_service.dart'; import 'package:cocoon_service/src/service/luci_build_service/cipd_version.dart'; import 'package:github/github.dart' as gh; @@ -53,8 +52,7 @@ class FakeConfig implements Config { this.issueAndPRLimitValue, this.githubRequestDelayValue, DynamicConfig? dynamicConfig, - }) : dynamicConfig = - dynamicConfig ?? DynamicConfig.fromJson({}); + }) : dynamicConfig = dynamicConfig ?? DynamicConfig.fromLocalFileSystem(); gh.GitHub? githubClient; GraphQLClient? githubGraphQLClient; @@ -235,10 +233,13 @@ class FakeConfig implements Config { CipdVersion get defaultRecipeBundleRef => const CipdVersion(branch: 'main'); @override - List get releaseBranches => releaseBranchesValue!; + List get releaseBranches => + releaseBranchesValue ?? const ['beta', 'stable']; @override - String get releaseCandidateBranchPath => releaseCandidateBranchPathValue!; + String get releaseCandidateBranchPath => + releaseCandidateBranchPathValue ?? + 'bin/internal/release-candidate-branch.version'; @override Future> get releaseAccounts async => [ diff --git a/packages/cocoon_integration_test/lib/src/fakes/fake_dashboard_authentication.dart b/packages/cocoon_integration_test/lib/src/fakes/fake_dashboard_authentication.dart index 38d872127..cf44b4f91 100644 --- a/packages/cocoon_integration_test/lib/src/fakes/fake_dashboard_authentication.dart +++ b/packages/cocoon_integration_test/lib/src/fakes/fake_dashboard_authentication.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; - import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/src/request_handling/exceptions.dart'; @@ -18,7 +16,7 @@ class FakeDashboardAuthentication implements DashboardAuthentication { FakeClientContext clientContext; @override - Future authenticate(HttpRequest request) async { + Future authenticate(Request request) async { if (authenticated) { return FakeAuthenticatedContext(clientContext: clientContext); } else { diff --git a/packages/cocoon_integration_test/lib/src/fakes/fake_firestore_service.dart b/packages/cocoon_integration_test/lib/src/fakes/fake_firestore_service.dart index fd241fc3e..bcd6c4a1a 100644 --- a/packages/cocoon_integration_test/lib/src/fakes/fake_firestore_service.dart +++ b/packages/cocoon_integration_test/lib/src/fakes/fake_firestore_service.dart @@ -26,6 +26,14 @@ abstract base class _FakeInMemoryFirestoreService Iterable get documents => _documents.values; final _documents = {}; + void reset() { + _documents.clear(); + _transactions.clear(); + _failOnWriteDocument.clear(); + _failOnWriteCollection.clear(); + rollbacks.clear(); + } + @protected String get expectedProjectId => Config.flutterGcpProjectId; @@ -499,7 +507,11 @@ abstract base class _FakeInMemoryFirestoreService Transaction? transaction, }) async { var results = documents.where((document) { - final collection = p.basename(p.dirname(document.name!)); + // Manual parsing of the collection ID from the document name. + // Expected format: projects/.../databases/.../documents// + final parts = document.name!.split('/'); + if (parts.length < 2) return false; + final collection = parts[parts.length - 2]; return collectionId == collection; }); diff --git a/packages/cocoon_integration_test/lib/src/fakes/fake_http.dart b/packages/cocoon_integration_test/lib/src/fakes/fake_http.dart index f822f16b4..fc3f7fc39 100644 --- a/packages/cocoon_integration_test/lib/src/fakes/fake_http.dart +++ b/packages/cocoon_integration_test/lib/src/fakes/fake_http.dart @@ -61,7 +61,7 @@ abstract class FakeTransport { } // TODO(tvolkert): `implements Stream` once HttpClientResponse does the same -abstract class FakeInbound extends FakeTransport { +abstract class FakeInbound extends FakeTransport implements Stream { FakeInbound(String? body) : _body = body == null ? _Body.empty() : _Body.utf8(body); @@ -122,6 +122,7 @@ abstract class FakeInbound extends FakeTransport { _body = _Body.rawBytes(value); } + @override StreamSubscription listen( void Function(Uint8List event)? onData, { Function? onError, @@ -137,11 +138,13 @@ abstract class FakeInbound extends FakeTransport { ); } + @override Future any(bool Function(Uint8List element) test) { _isStreamExposed = true; return _body.stream.any(test); } + @override Stream asBroadcastStream({ void Function(StreamSubscription subscription)? onListen, void Function(StreamSubscription subscription)? onCancel, @@ -153,26 +156,31 @@ abstract class FakeInbound extends FakeTransport { ); } + @override Stream asyncExpand(Stream? Function(Uint8List event) convert) { _isStreamExposed = true; return _body.stream.asyncExpand(convert); } + @override Stream asyncMap(FutureOr Function(Uint8List event) convert) { _isStreamExposed = true; return _body.stream.asyncMap(convert); } + @override Stream cast() { _isStreamExposed = true; return _body.stream.cast(); } + @override Future contains(Object? needle) { _isStreamExposed = true; return _body.stream.contains(needle); } + @override Stream distinct([ bool Function(Uint8List previous, Uint8List next)? equals, ]) { @@ -180,42 +188,46 @@ abstract class FakeInbound extends FakeTransport { return _body.stream.distinct(equals); } + @override Future drain([E? futureValue]) { _isStreamExposed = true; return _body.stream.drain(futureValue); } + @override Future elementAt(int index) { _isStreamExposed = true; return _body.stream.elementAt(index); } + @override Future every(bool Function(Uint8List element) test) { _isStreamExposed = true; return _body.stream.every(test); } + @override Stream expand(Iterable Function(Uint8List element) convert) { _isStreamExposed = true; return _body.stream.expand(convert); } + @override Future get first { _isStreamExposed = true; return _body.stream.first; } + @override Future firstWhere( bool Function(Uint8List element) test, { - List Function()? orElse, + Uint8List Function()? orElse, }) { _isStreamExposed = true; - return _body.stream.firstWhere( - test, - orElse: () => Uint8List.fromList(orElse!()), - ); + return _body.stream.firstWhere(test, orElse: orElse); } + @override Future fold( S initialValue, S Function(S previous, Uint8List element) combine, @@ -224,11 +236,13 @@ abstract class FakeInbound extends FakeTransport { return _body.stream.fold(initialValue, combine); } + @override Future forEach(void Function(Uint8List element) action) { _isStreamExposed = true; return _body.stream.forEach(action); } + @override Stream handleError( Function onError, { bool Function(dynamic error)? test, @@ -237,100 +251,105 @@ abstract class FakeInbound extends FakeTransport { return _body.stream.handleError(onError, test: test); } + @override bool get isBroadcast { _isStreamExposed = true; return _body.stream.isBroadcast; } + @override Future get isEmpty { _isStreamExposed = true; return _body.stream.isEmpty; } + @override Future join([String separator = '']) { _isStreamExposed = true; return _body.stream.join(separator); } + @override Future get last { _isStreamExposed = true; return _body.stream.last; } + @override Future lastWhere( bool Function(Uint8List element) test, { Uint8List Function()? orElse, }) { _isStreamExposed = true; - return _body.stream.lastWhere( - test, - orElse: () => Uint8List.fromList(orElse!()), - ); + return _body.stream.lastWhere(test, orElse: orElse); } + @override Future get length { _isStreamExposed = true; return _body.stream.length; } + @override Stream map(S Function(Uint8List event) convert) { _isStreamExposed = true; return _body.stream.map(convert); } + @override Future pipe(StreamConsumer streamConsumer) { _isStreamExposed = true; - return _body.stream - .map((Uint8List list) => list.toList()) - .pipe(streamConsumer); + return _body.stream.pipe(streamConsumer); } + @override Future reduce( - List Function(Uint8List previous, Uint8List element) combine, + Uint8List Function(Uint8List previous, Uint8List element) combine, ) { _isStreamExposed = true; - return _body.stream.reduce( - (Uint8List previous, Uint8List element) => - Uint8List.fromList(combine(previous, element)), - ); + return _body.stream.reduce(combine); } + @override Future get single { _isStreamExposed = true; return _body.stream.single; } + @override Future singleWhere( bool Function(Uint8List element) test, { - List Function()? orElse, + Uint8List Function()? orElse, }) { _isStreamExposed = true; - return _body.stream.singleWhere( - test, - orElse: () => Uint8List.fromList(orElse!()), - ); + return _body.stream.singleWhere(test, orElse: orElse); } + @override Stream skip(int count) { _isStreamExposed = true; return _body.stream.skip(count); } + @override Stream skipWhile(bool Function(Uint8List element) test) { _isStreamExposed = true; return _body.stream.skipWhile(test); } + @override Stream take(int count) { _isStreamExposed = true; return _body.stream.take(count); } + @override Stream takeWhile(bool Function(Uint8List element) test) { _isStreamExposed = true; return _body.stream.takeWhile(test); } + @override Stream timeout( Duration timeLimit, { void Function(EventSink sink)? onTimeout, @@ -339,23 +358,25 @@ abstract class FakeInbound extends FakeTransport { return _body.stream.timeout(timeLimit, onTimeout: onTimeout); } + @override Future> toList() { _isStreamExposed = true; return _body.stream.toList(); } + @override Future> toSet() { _isStreamExposed = true; return _body.stream.toSet(); } - Stream transform(StreamTransformer, S> streamTransformer) { + @override + Stream transform(StreamTransformer streamTransformer) { _isStreamExposed = true; - return _body.stream - .map((Uint8List list) => list.toList()) - .transform(streamTransformer); + return _body.stream.transform(streamTransformer); } + @override Stream where(bool Function(Uint8List event) test) { _isStreamExposed = true; return _body.stream.where(test); diff --git a/packages/cocoon_integration_test/lib/src/integration_http_client.dart b/packages/cocoon_integration_test/lib/src/integration_http_client.dart index cf0373f31..b9a76d7f7 100644 --- a/packages/cocoon_integration_test/lib/src/integration_http_client.dart +++ b/packages/cocoon_integration_test/lib/src/integration_http_client.dart @@ -4,6 +4,7 @@ import 'dart:convert'; +import 'package:cocoon_service/src/request_handling/http_io.dart'; import 'package:http/http.dart' as http; import '../testing.dart'; @@ -32,7 +33,7 @@ class IntegrationHttpClient extends http.BaseClient { fakeRequest.headers.add(key, value); }); - await server.server(fakeRequest); + await server.server(fakeRequest.toRequest()); final responseHeaders = {}; fakeResponse.headers.forEach((name, values) { diff --git a/packages/cocoon_integration_test/lib/src/server.dart b/packages/cocoon_integration_test/lib/src/server.dart index db03e8fa8..786ae9166 100644 --- a/packages/cocoon_integration_test/lib/src/server.dart +++ b/packages/cocoon_integration_test/lib/src/server.dart @@ -4,7 +4,9 @@ import 'package:cocoon_service/cocoon_service.dart'; import 'package:cocoon_service/server.dart'; +import 'package:cocoon_service/src/service/build_status_service.dart'; import 'package:cocoon_service/src/service/commit_service.dart'; +import 'package:retry/retry.dart'; import '../testing.dart'; @@ -20,7 +22,7 @@ class IntegrationServer { FakeLuciBuildService? luciBuildService, FakeScheduler? scheduler, FakeCiYamlFetcher? ciYamlFetcher, - FakeBuildStatusService? buildStatusService, + BuildStatusService? buildStatusService, FakeContentAwareHashService? contentAwareHashService, CacheService? cache, }) { @@ -43,7 +45,9 @@ class IntegrationServer { bigQuery: this.bigQuery, ); this.ciYamlFetcher = ciYamlFetcher ?? FakeCiYamlFetcher(); - this.buildStatusService = buildStatusService ?? FakeBuildStatusService(); + this.buildStatusService = + buildStatusService ?? + BuildStatusService(firestore: this.firestore, config: this.config); this.contentAwareHashService = contentAwareHashService ?? FakeContentAwareHashService(config: this.config); @@ -59,6 +63,7 @@ class IntegrationServer { branchService: BranchService( config: this.config, gerritService: this.gerritService, + retryOptions: const RetryOptions(maxAttempts: 1), ), buildBucketClient: this.buildBucketClient, luciBuildService: this.luciBuildService, @@ -86,7 +91,7 @@ class IntegrationServer { late final FakeLuciBuildService luciBuildService; late final FakeScheduler scheduler; late final FakeCiYamlFetcher ciYamlFetcher; - late final FakeBuildStatusService buildStatusService; + late final BuildStatusService buildStatusService; late final FakeContentAwareHashService contentAwareHashService; late final CacheService cache; }