diff --git a/app_dart/lib/src/model/common/presubmit_completed_check.dart b/app_dart/lib/src/model/common/presubmit_completed_check.dart index 20970c11d6..240659901a 100644 --- a/app_dart/lib/src/model/common/presubmit_completed_check.dart +++ b/app_dart/lib/src/model/common/presubmit_completed_check.dart @@ -86,13 +86,14 @@ class PresubmitCompletedCheck { /// Creates a [PresubmitCompletedCheck] from a BuildBucket [Build]. factory PresubmitCompletedCheck.fromBuild( Build build, - PresubmitUserData userData, - ) { + PresubmitUserData userData, { + TaskStatus? status, + }) { return PresubmitCompletedCheck( name: build.builder.builder, sha: userData.commit.sha, slug: userData.commit.slug, - status: build.status.toTaskStatus(), + status: status ?? build.status.toTaskStatus(), isMergeGroup: _isMergeGroup(userData.commit.branch), checkRunId: userData.guardCheckRunId ?? userData.checkRunId!, checkSuiteId: userData.checkSuiteId, 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 4b85b5f86a..7177da42ad 100644 --- a/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart +++ b/app_dart/lib/src/request_handlers/presubmit_luci_subscription.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'package:archive/archive.dart'; import 'package:buildbucket/buildbucket_pb.dart' as bbv2; +import 'package:cocoon_common/task_status.dart'; import 'package:cocoon_server/logging.dart'; import 'package:github/github.dart'; @@ -133,9 +134,9 @@ final class PresubmitLuciSubscription extends SubscriptionHandler { ); } } + CheckRunConclusion? override; if (!isUnifiedCheckRun) { String? suppressedMessage; - CheckRunConclusion? override; if (build.status.isTaskFailed() && !rescheduled) { // If a test is suppressed; we avoid setting a failing status. final isSuppressed = await cache.isTestSuppressed( @@ -164,7 +165,13 @@ final class PresubmitLuciSubscription extends SubscriptionHandler { // Process to the check-run status in the merge queue document during // the LUCI callback. if (config.flags.closeMqGuardAfterPresubmit || isUnifiedCheckRun) { - final check = PresubmitCompletedCheck.fromBuild(build, userData); + final check = PresubmitCompletedCheck.fromBuild( + build, + userData, + status: override == CheckRunConclusion.neutral + ? TaskStatus.neutral + : null, + ); await _scheduler.processCheckRunCompleted(check); } } diff --git a/app_dart/test/request_handlers/presubmit_luci_subscription_neutral_test.dart b/app_dart/test/request_handlers/presubmit_luci_subscription_neutral_test.dart new file mode 100644 index 0000000000..6dddc0231b --- /dev/null +++ b/app_dart/test/request_handlers/presubmit_luci_subscription_neutral_test.dart @@ -0,0 +1,224 @@ +// 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:buildbucket/buildbucket_pb.dart' as bbv2; +import 'package:cocoon_common/task_status.dart'; +import 'package:cocoon_integration_test/testing.dart'; +import 'package:cocoon_server_test/mocks.dart'; +import 'package:cocoon_server_test/test_logging.dart'; +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/service/luci_build_service/user_data.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:github/github.dart' as github; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../src/request_handling/subscription_tester.dart'; + +void main() { + useTestLoggerPerTest(); + + late PresubmitLuciSubscription handler; + late FakeConfig config; + late MockGitHub mockGitHubClient; + late FakeHttpRequest request; + late SubscriptionTester tester; + late MockRepositoriesService mockRepositoriesService; + late MockGithubChecksService mockGithubChecksService; + late FakeCiYamlFetcher ciYamlFetcher; + late MockScheduler mockScheduler; + late FakeFirestoreService firestore; + + setUp(() async { + firestore = FakeFirestoreService(); + + config = FakeConfig( + dynamicConfig: DynamicConfig.fromJson({ + 'closeMqGuardAfterPresubmit': true, + }), + ); + mockGithubChecksService = MockGithubChecksService(); + mockScheduler = MockScheduler(); + + ciYamlFetcher = FakeCiYamlFetcher( + ciYaml: examplePresubmitRescheduleFusionConfig, + ); + + handler = PresubmitLuciSubscription( + cache: CacheService(inMemory: true), + config: config, + luciBuildService: FakeLuciBuildService( + config: config, + firestore: firestore, + ), + githubChecksService: mockGithubChecksService, + authProvider: FakeDashboardAuthentication(), + scheduler: mockScheduler, + ciYamlFetcher: ciYamlFetcher, + firestore: firestore, + ); + request = FakeHttpRequest(); + + tester = SubscriptionTester(request: request); + + mockGitHubClient = MockGitHub(); + mockRepositoriesService = MockRepositoriesService(); + when(mockGitHubClient.repositories).thenReturn(mockRepositoriesService); + config.githubClient = mockGitHubClient; + }); + + test( + 'Requests when task failed and is suppressed reports neutral to scheduler', + () async { + final userData = PresubmitUserData( + commit: CommitRef( + sha: 'abc', + branch: 'master', + slug: github.RepositorySlug('flutter', 'flutter'), + ), + checkRunId: 1, + checkSuiteId: 2, + ); + + // Setup Firestore to mark the test as suppressed + firestore.putDocument( + SuppressedTest( + name: 'Linux A', + repository: 'flutter/flutter', + issueLink: 'https://github.com/flutter/flutter/issues/123', + isSuppressed: true, + createTimestamp: DateTime.now(), + ) + ..name = firestore.resolveDocumentName( + SuppressedTest.kCollectionId, + 'suppressed_1', + ), + ); + + when( + mockGithubChecksService.updateCheckStatus( + build: anyNamed('build'), + checkRunId: anyNamed('checkRunId'), + luciBuildService: anyNamed('luciBuildService'), + slug: anyNamed('slug'), + conclusionOverride: github.CheckRunConclusion.neutral, + summaryPrepend: anyNamed('summaryPrepend'), + ), + ).thenAnswer((_) async => true); + + when( + mockScheduler.processCheckRunCompleted(any), + ).thenAnswer((_) async => true); + + tester.message = createPushMessage( + Int64(1), + status: bbv2.Status.FAILURE, + builder: 'Linux A', + userData: userData, + ); + + await tester.post(handler); + + // Verify that updateCheckStatus was called with neutral override + verify( + mockGithubChecksService.updateCheckStatus( + build: anyNamed('build'), + checkRunId: 1, + luciBuildService: anyNamed('luciBuildService'), + slug: github.RepositorySlug('flutter', 'flutter'), + conclusionOverride: github.CheckRunConclusion.neutral, + summaryPrepend: argThat( + contains( + '### ⚠️ Test failed but marked as suppressed on dashboard', + ), + named: 'summaryPrepend', + ), + ), + ).called(1); + + // Verify that processCheckRunCompleted was called with TaskStatus.neutral + final captured = verify( + mockScheduler.processCheckRunCompleted(captureAny), + ).captured; + expect(captured, hasLength(1)); + expect( + captured[0], + isA().having( + (e) => e.status, + 'status', + TaskStatus.neutral, + ), + ); + }, + ); + + test( + 'Requests when task failed and is NOT suppressed reports failure to scheduler', + () async { + final userData = PresubmitUserData( + commit: CommitRef( + sha: 'abc', + branch: 'master', + slug: github.RepositorySlug('flutter', 'flutter'), + ), + checkRunId: 1, + checkSuiteId: 2, + ); + + // Suppression is NOT set up in Firestore + + when( + mockGithubChecksService.updateCheckStatus( + build: anyNamed('build'), + checkRunId: anyNamed('checkRunId'), + luciBuildService: anyNamed('luciBuildService'), + slug: anyNamed('slug'), + conclusionOverride: null, + summaryPrepend: null, + ), + ).thenAnswer((_) async => true); + + when( + mockScheduler.processCheckRunCompleted(any), + ).thenAnswer((_) async => true); + + tester.message = createPushMessage( + Int64(1), + status: bbv2.Status.FAILURE, + builder: 'Linux A', + userData: userData, + ); + + await tester.post(handler); + + // Verify that updateCheckStatus was called without neutral override + verify( + mockGithubChecksService.updateCheckStatus( + build: anyNamed('build'), + checkRunId: 1, + luciBuildService: anyNamed('luciBuildService'), + slug: github.RepositorySlug('flutter', 'flutter'), + conclusionOverride: null, + summaryPrepend: null, + ), + ).called(1); + + // Verify that processCheckRunCompleted was called with TaskStatus.failed + final captured = verify( + mockScheduler.processCheckRunCompleted(captureAny), + ).captured; + expect(captured, hasLength(1)); + expect( + captured[0], + isA().having( + (e) => e.status, + 'status', + TaskStatus.failed, + ), + ); + }, + ); +} diff --git a/dashboard/lib/logic/task_sorting.dart b/dashboard/lib/logic/task_sorting.dart index 5d840bc7de..d37264b7c7 100644 --- a/dashboard/lib/logic/task_sorting.dart +++ b/dashboard/lib/logic/task_sorting.dart @@ -44,7 +44,9 @@ int _statusPriority(TaskStatus status) { return 5; case TaskStatus.skipped: return 6; - case TaskStatus.succeeded: + case TaskStatus.neutral: return 7; + case TaskStatus.succeeded: + return 8; } } diff --git a/dashboard/lib/service/data_seeder.dart b/dashboard/lib/service/data_seeder.dart index 7658cf174c..67b78f8f6a 100644 --- a/dashboard/lib/service/data_seeder.dart +++ b/dashboard/lib/service/data_seeder.dart @@ -460,6 +460,8 @@ class DataSeeder { TaskStatus.waitingForBackfill => null, TaskStatus.skipped => '[INFO] Starting task $buildName...\n[INFO] Test skipped: Dummy Tests', + TaskStatus.neutral => + '[INFO] Starting task $buildName...\n[INFO] Test status neutral: Dummy Tests', }, startTime: creationTime + 30000, endTime: creationTime + 60000, diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart index ed53a5e28a..84113f45b2 100644 --- a/dashboard/lib/views/presubmit_view.dart +++ b/dashboard/lib/views/presubmit_view.dart @@ -747,6 +747,8 @@ class _CheckItem extends StatelessWidget { color: TaskBox.statusColor[status], size: 18, ); + case TaskStatus.neutral: + return Icon(Icons.flaky, color: TaskBox.statusColor[status], size: 18); case TaskStatus.cancelled: return Icon( Icons.block_outlined, diff --git a/dashboard/lib/widgets/task_overlay.dart b/dashboard/lib/widgets/task_overlay.dart index ad61643244..ba1665dbd4 100644 --- a/dashboard/lib/widgets/task_overlay.dart +++ b/dashboard/lib/widgets/task_overlay.dart @@ -257,6 +257,8 @@ class TaskOverlayContents extends StatelessWidget { buffer.write('Running for ${ranFor.inMinutes} minutes'); case TaskStatus.skipped: buffer.write('Skipped'); + case TaskStatus.neutral: + buffer.write('Neutral'); case TaskStatus.cancelled: buffer.write('Cancelled'); case TaskStatus.succeeded: diff --git a/dashboard/test/logic/task_sorting_test.dart b/dashboard/test/logic/task_sorting_test.dart index d21f73d544..b3af973d61 100644 --- a/dashboard/test/logic/task_sorting_test.dart +++ b/dashboard/test/logic/task_sorting_test.dart @@ -17,6 +17,7 @@ void main() { ('e', TaskStatus.waitingForBackfill), ('f', TaskStatus.cancelled), ('g', TaskStatus.skipped), + ('h', TaskStatus.neutral), ]; tasks.sort((a, b) => compareTasks(a.$1, a.$2, b.$1, b.$2)); @@ -28,6 +29,7 @@ void main() { ('e', TaskStatus.waitingForBackfill), ('f', TaskStatus.cancelled), ('g', TaskStatus.skipped), + ('h', TaskStatus.neutral), ('a', TaskStatus.succeeded), ]); }); @@ -58,6 +60,8 @@ void main() { ('w', TaskStatus.inProgress), ('v', TaskStatus.failed), ('u', TaskStatus.succeeded), + ('0', TaskStatus.neutral), + ('t', TaskStatus.neutral), ]; tasks.sort((a, b) => compareTasks(a.$1, a.$2, b.$1, b.$2)); @@ -67,6 +71,8 @@ void main() { ('y', TaskStatus.failed), ('x', TaskStatus.infraFailure), ('w', TaskStatus.inProgress), + ('0', TaskStatus.neutral), + ('t', TaskStatus.neutral), ('u', TaskStatus.succeeded), ('z', TaskStatus.succeeded), ]); diff --git a/dashboard/test/utils/generate_task_for_tests.dart b/dashboard/test/utils/generate_task_for_tests.dart index 8aa9146a9b..ae12cc1cce 100644 --- a/dashboard/test/utils/generate_task_for_tests.dart +++ b/dashboard/test/utils/generate_task_for_tests.dart @@ -68,6 +68,7 @@ Task generateTaskForTest({ case TaskStatus.failed: case TaskStatus.infraFailure: case TaskStatus.succeeded: + case TaskStatus.neutral: started = true; completed = true; case TaskStatus.inProgress: diff --git a/packages/cocoon_common/lib/task_status.dart b/packages/cocoon_common/lib/task_status.dart index 36ff18a5d7..a40451b383 100644 --- a/packages/cocoon_common/lib/task_status.dart +++ b/packages/cocoon_common/lib/task_status.dart @@ -27,6 +27,9 @@ enum TaskStatus { /// The task ran successfully. succeeded('Succeeded'), + /// The task ran but the status is ignored. + neutral('Neutral'), + /// The task was skipped instead of being executed. skipped('Skipped');