Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app_dart/lib/src/model/common/presubmit_completed_check.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PresubmitCompletedCheck>().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<PresubmitCompletedCheck>().having(
(e) => e.status,
'status',
TaskStatus.failed,
),
);
},
);
}
4 changes: 3 additions & 1 deletion dashboard/lib/logic/task_sorting.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 2 additions & 0 deletions dashboard/lib/service/data_seeder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions dashboard/lib/views/presubmit_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions dashboard/lib/widgets/task_overlay.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions dashboard/test/logic/task_sorting_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -28,6 +29,7 @@ void main() {
('e', TaskStatus.waitingForBackfill),
('f', TaskStatus.cancelled),
('g', TaskStatus.skipped),
('h', TaskStatus.neutral),
('a', TaskStatus.succeeded),
]);
});
Expand Down Expand Up @@ -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));
Expand All @@ -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),
]);
Expand Down
1 change: 1 addition & 0 deletions dashboard/test/utils/generate_task_for_tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions packages/cocoon_common/lib/task_status.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ enum TaskStatus {
/// The task ran successfully.
succeeded('Succeeded'),

/// The task ran but the status is ignored.
neutral('Neutral'),
Comment thread
jtmcdole marked this conversation as resolved.

/// The task was skipped instead of being executed.
skipped('Skipped');

Expand Down
Loading