diff --git a/conductor/archive/presubmit_filter_20260319/index.md b/conductor/archive/presubmit_filter_20260319/index.md new file mode 100644 index 0000000000..df7f3b02d8 --- /dev/null +++ b/conductor/archive/presubmit_filter_20260319/index.md @@ -0,0 +1,5 @@ +# Track presubmit_filter_20260319 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) diff --git a/conductor/archive/presubmit_filter_20260319/metadata.json b/conductor/archive/presubmit_filter_20260319/metadata.json new file mode 100644 index 0000000000..f38c1c2104 --- /dev/null +++ b/conductor/archive/presubmit_filter_20260319/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "presubmit_filter_20260319", + "type": "feature", + "status": "new", + "created_at": "2026-03-19T10:00:00Z", + "updated_at": "2026-03-19T10:00:00Z", + "description": "In 'CocoonAppBar' of Presubmit Dashboard add filter button to lust of actions that will show filer dialog and filer jobs in '_ChecksSidebar'." +} diff --git a/conductor/archive/presubmit_filter_20260319/plan.md b/conductor/archive/presubmit_filter_20260319/plan.md new file mode 100644 index 0000000000..d94c8f2353 --- /dev/null +++ b/conductor/archive/presubmit_filter_20260319/plan.md @@ -0,0 +1,54 @@ +# Implementation Plan - Presubmit Dashboard Job Filtering + +This plan outlines the steps to add job filtering to the Presubmit Dashboard in the Cocoon dashboard. + +## Phase 1: State Management (PresubmitState) [checkpoint: 48bff29] +In this phase, we will extend `PresubmitState` to hold and manage the filter state. + +- [x] **Task: Add filter state variables to `PresubmitState`.** + - Variables: `Set selectedStatuses`, `Set selectedPlatforms`, `String? jobNameFilter`. + - Initialize with all statuses and platforms selected, and `null` or empty string for regex. +- [x] **Task: Add methods to update filter state.** + - `updateFilters({Set? statuses, Set? platforms, String? jobNameFilter})` + - `clearFilters()`: Resets filters to "select all" and clear regex. +- [x] **Task: Implement filtering logic in `PresubmitState`.** + - Add a getter `filteredGuardResponse` (or similar) that returns a `PresubmitGuardResponse` with filtered stages and builds based on the active filters. + - Platforms are extracted by splitting job names by space and taking the first part. +- [x] **Task: Add unit tests for `PresubmitState` filtering logic.** + - Verify filtering by status, platform, and regex. + - Verify persistence when `update` is called with same PR but different SHA. +- [x] **Task: Conductor - User Manual Verification 'Phase 1: State Management' (Protocol in workflow.md)** + +## Phase 2: Filter Dialog UI [checkpoint: 2903a30] +In this phase, we will create the filter dialog and its components. + +- [x] **Task: Create `FilterDialog` widget in `dashboard/lib/widgets/filter_dialog.dart`.** + - Multi-select sections for Task Status and Platform. + - `TextField` for Job Name Regex (with `onChanged` or `onEditingComplete` depending on final behavior). + - Validation: Ensure at least one status and one platform are always selected (disable uncheck if it's the last one). + - "Clear all filters" button at the bottom. + - "Show N jobs" button displaying the filtered count. +- [x] **Task: Add unit tests for `FilterDialog`.** + - Verify initial state shows all filters. + - Verify toggling selections updates the UI and buttons. +- [x] **Task: Conductor - User Manual Verification 'Phase 2: Filter Dialog UI' (Protocol in workflow.md)** + +## Phase 3: Integration and Dashboard UI [checkpoint: b499002] +In this phase, we will integrate the filter functionality into the Presubmit Dashboard. + +- [x] **Task: Add Filter Button to `CocoonAppBar` in `PreSubmitView`.** + - Icon: `Icons.filter_alt_outlined` (no filters applied) or `Icons.filter_alt` (some filters applied). + - Tooltip: "Filter jobs". + - Hover highlight: "Filter jobs". +- [x] **Task: Update `PreSubmitView` to use `filteredGuardResponse` for `_ChecksSidebar`.** +- [x] **Task: Ensure filter state persists when switching guard statuses.** + - Handled by `PresubmitState` during `update` calls. +- [x] **Task: Add integration tests for filtering functionality in `PreSubmitView`.** + - Verify clicking the filter button opens the dialog. + - Verify applying filters updates the `_ChecksSidebar`. +- [x] **Task: Conductor - User Manual Verification 'Phase 3: Integration and Dashboard UI' (Protocol in workflow.md)** + +## Phase 4: Final Polishing and Cleanup [checkpoint: e03cad0] +- [x] **Task: Verify overall dashboard performance with active filters.** +- [x] **Task: Ensure accessibility (ARIA labels, tooltips).** +- [x] **Task: Conductor - User Manual Verification 'Phase 4: Final Polishing and Cleanup' (Protocol in workflow.md)** diff --git a/conductor/archive/presubmit_filter_20260319/spec.md b/conductor/archive/presubmit_filter_20260319/spec.md new file mode 100644 index 0000000000..36d2cff384 --- /dev/null +++ b/conductor/archive/presubmit_filter_20260319/spec.md @@ -0,0 +1,47 @@ +# Specification - Presubmit Dashboard Job Filtering (v4) + +## Overview +This track aims to enhance the Presubmit Dashboard in the Cocoon dashboard by adding a filtering mechanism for CI jobs displayed in the `_ChecksSidebar`. This will allow users to quickly narrow down relevant jobs based on status, platform, and name (via regex), improving the usability and actionable visibility of build health. + +## Functional Requirements +* **Filter Button in CocoonAppBar:** + - Add a filter icon button to the `CocoonAppBar` actions in the Presubmit Dashboard. + - Icon: `Icons.filter_alt_outlined` (no filters applied) or `Icons.filter_alt` (some filters applied). + - Tooltip: "Filter jobs". +* **Filter Dialog:** + - Clicking the filter button opens a dialog. + - **Status Filter:** Multi-select list of all possible task statuses (e.g., Succeeded, Failed, In Progress, Queued, Skipped, etc.). + - **Platform Filter:** Multi-select list of platform names, derived by splitting all unique job names by space and taking the first part. + - **Job Name Regex Filter:** Text input for a regular expression to match against job names. + - **Validation:** At least one task status and at least one platform must remain checked at all times. + - **Clear All Filters:** A button at the bottom to reset all filters to their default state (all selected, regex empty). + - **Show N Jobs:** A button displaying the total count of filtered jobs in the `_ChecksSidebar`. +* **Filtering Logic:** + - Filters apply immediately when a status or platform is toggled. + - Regex filter applies when the input field loses focus (onBlur). + - The `_ChecksSidebar` list of jobs updates in real-time based on the active filters. +* **Persistence & State:** + - Filter state is managed in `PresubmitState`. + - Filters should remain if the user selects a different guard status. +* **Visual Elements:** + - Filter button should be `Icons.filter_alt_outlined` if no filters are applied, and `Icons.filter_alt` if some filters are applied. + - Filter button should have text highlight "Filter jobs" on mouse over. + +## Non-Functional Requirements +* **Material Design:** Follow Material Design principles for the dialog and filter button. +* **Performance:** Filtering should be efficient, even with a large number of jobs. +* **Accessibility:** Use appropriate tooltips and labels for filter controls. + +## Acceptance Criteria +- [ ] Filter button in `CocoonAppBar` changes icon based on active filter state. +- [ ] Filter dialog shows all active filters correctly upon opening. +- [ ] `_ChecksSidebar` correctly displays only the jobs matching the active status, platform, and regex filters. +- [ ] "Show N jobs" button reflects the correct count of filtered jobs. +- [ ] At least one status and one platform are always selected in the dialog. +- [ ] "Clear all filters" button resets the filter state. +- [ ] Filter state persists when navigating between different guard statuses. + +## Out of Scope +- [ ] Permanent persistence of filters across browser sessions (e.g., in LocalStorage). +- [ ] Complex multi-regex or boolean logic filters. +- [ ] Filtering logic on the backend; all filtering is performed client-side in the Flutter dashboard. diff --git a/conductor/product.md b/conductor/product.md index 968f84d265..a0f63c08f8 100644 --- a/conductor/product.md +++ b/conductor/product.md @@ -19,6 +19,7 @@ Cocoon is the CI coordination and orchestration system for the Flutter project. * **Tree Status Dashboard:** A Flutter-based web application that provides a visual overview of build health across various commits and branches. * **Presubmit Check Details:** Backend APIs to retrieve detailed attempt history and status for specific presubmit checks, aiding in debugging and visibility. * **Presubmit Guard Summaries:** Backend APIs to retrieve summaries of all presubmit checks (Presubmit Guards) of the provided pull request to the dashboard. +* **Presubmit Dashboard Filtering:** Interactive filtering for the Presubmit Dashboard, allowing users to narrow down visible jobs by status, platform, and regex. Filters persist within a session and automatically manage valid job selection for the details pane. * **Presubmit Guard Details:** Displays detailed information and CI check statuses for a specific presubmit check (Presubmit Guard), sorted by status (prioritizing failures) and name for better visibility. Authenticated users can trigger re-runs for individual jobs or all failed jobs directly from the dashboard. * **Merge Queue Visibility:** APIs for querying and inspecting recent GitHub Merge Queue webhook events to diagnose integration issues. * **Auto-submit Bot:** Handles automated pull request management, including label-based merges, reverts, and validation checks. diff --git a/dashboard/lib/service/data_seeder.dart b/dashboard/lib/service/data_seeder.dart index 23289c59f5..275813dda7 100644 --- a/dashboard/lib/service/data_seeder.dart +++ b/dashboard/lib/service/data_seeder.dart @@ -66,6 +66,11 @@ class DataSeeder { 'Mac framework_tests', 'Linux android framework_tests', 'Windows framework_tests', + 'Mac_x64 framework_misc', + 'Linux_android_emu android_engine_opengles_tests', + 'Linux_android_emu_vulkan_stable android_engine_vulkan_tests', + 'Mac_arm64 run_debug_test_macos', + 'Linux_android_emu android views', ]; var engineChecks = [ _createPresubmitCheck( @@ -359,6 +364,41 @@ class DataSeeder { attemptNumber: 2, creationTime: creationTime2, ), + _createPresubmitCheck( + checkRunId: checkRunId, + buildName: fusionBuilds[4], + status: TaskStatus.neutral, + attemptNumber: 1, + creationTime: creationTime, + ), + _createPresubmitCheck( + checkRunId: checkRunId, + buildName: fusionBuilds[5], + status: TaskStatus.neutral, + attemptNumber: 1, + creationTime: creationTime, + ), + _createPresubmitCheck( + checkRunId: checkRunId, + buildName: fusionBuilds[6], + status: TaskStatus.neutral, + attemptNumber: 1, + creationTime: creationTime, + ), + _createPresubmitCheck( + checkRunId: checkRunId, + buildName: fusionBuilds[7], + status: TaskStatus.neutral, + attemptNumber: 1, + creationTime: creationTime, + ), + _createPresubmitCheck( + checkRunId: checkRunId, + buildName: fusionBuilds[8], + status: TaskStatus.neutral, + attemptNumber: 1, + creationTime: creationTime, + ), ]; guards.add( _createPresubmitGuard( diff --git a/dashboard/lib/state/presubmit.dart b/dashboard/lib/state/presubmit.dart index 1b893a6f44..1a1988de53 100644 --- a/dashboard/lib/state/presubmit.dart +++ b/dashboard/lib/state/presubmit.dart @@ -5,45 +5,42 @@ import 'dart:async'; import 'package:cocoon_common/rpc_model.dart'; +import 'package:cocoon_common/task_status.dart'; import 'package:flutter/foundation.dart'; +import '../logic/task_sorting.dart'; import '../service/cocoon.dart'; import '../service/firebase_auth.dart'; -/// State for the PreSubmit View. +/// State for the Presubmit Dashboard. +/// +/// This state manages the data for a specific Pull Request (PR) or commit SHA, +/// including available SHAs, guard status, and individual check results. class PresubmitState extends ChangeNotifier { PresubmitState({ required this.cocoonService, required this.authService, - this.repo = 'flutter', this.pr, this.sha, }) { - _isAuthenticated = authService.isAuthenticated; authService.addListener(onAuthChanged); + if (pr != null || sha != null) { + fetchIfNeeded(); + } } - /// Cocoon backend service that retrieves the data needed for this state. final CocoonService cocoonService; - - /// Authentication service for managing Google Sign In. final FirebaseAuthService authService; - bool _isAuthenticated = false; - - /// The current repo to show data from. - String repo; + /// The repository name (e.g., 'flutter', 'engine'). + String repo = 'flutter'; - /// The current PR number. + /// The pull request number string. String? pr; - /// The current commit SHA. + /// The commit SHA string. String? sha; - /// The guard response for the current [sha]. - PresubmitGuardResponse? get guardResponse => _guardResponse; - PresubmitGuardResponse? _guardResponse; - /// Whether data is currently being fetched. bool get isLoading => _isSummariesLoading || _isGuardLoading || _isChecksLoading; @@ -52,14 +49,216 @@ class PresubmitState extends ChangeNotifier { bool _isGuardLoading = false; bool _isChecksLoading = false; - /// Set of job names that are currently being re-run. - Set get rerunningJobs => _rerunningJobs; - final Set _rerunningJobs = {}; + /// The full guard status response for the current [sha]. + PresubmitGuardResponse? get guardResponse => _guardResponse; + PresubmitGuardResponse? _guardResponse; /// Whether "Re-run failed" is currently in progress. bool get isRerunningAll => _isRerunningAll; bool _isRerunningAll = false; + /// The currently selected task statuses for filtering. + Set get selectedStatuses => _selectedStatuses; + Set _selectedStatuses = TaskStatus.values.toSet(); + + /// The currently selected platforms for filtering. + Set get selectedPlatforms => _selectedPlatforms; + Set _selectedPlatforms = {}; + + /// The current job name filter (regex). + String? get jobNameFilter => _jobNameFilter; + String? _jobNameFilter; + + /// Whether any filter is currently applied. + bool get isAnyFilterApplied { + return _selectedStatuses.length < TaskStatus.values.length || + (_availablePlatforms.isNotEmpty && + _selectedPlatforms.length < _availablePlatforms.length) || + (_jobNameFilter != null && _jobNameFilter!.isNotEmpty); + } + + /// All unique platforms derived from the current [guardResponse]. + Set get availablePlatforms => _availablePlatforms; + Set _availablePlatforms = {}; + + /// Update the current filters and notify listeners. + void updateFilters({ + Set? statuses, + Set? platforms, + String? jobNameFilter, + }) { + if (statuses != null) { + _selectedStatuses = statuses; + } + if (platforms != null) { + _selectedPlatforms = platforms; + } + if (jobNameFilter != null) { + _jobNameFilter = jobNameFilter; + } + _ensureValidSelection(); + notifyListeners(); + } + + /// Reset all filters to their default values and notify listeners. + void clearFilters() { + _selectedStatuses = TaskStatus.values.toSet(); + _selectedPlatforms = {}; + _availablePlatforms = {}; + _jobNameFilter = null; + _ensureValidSelection(); + notifyListeners(); + } + + void _ensureValidSelection() { + final filtered = filteredGuardResponse; + if (filtered == null || + filtered.stages.isEmpty || + filtered.stages.every((s) => s.builds.isEmpty)) { + _selectedCheck = null; + _checks = null; + return; + } + + // Check if current selection is still visible + var isVisible = false; + if (_selectedCheck != null) { + for (final stage in filtered.stages) { + if (stage.builds.containsKey(_selectedCheck)) { + isVisible = true; + break; + } + } + } + + if (!isVisible) { + // Select first available check based on UI sorting + String? topMost; + for (final stage in filtered.stages) { + if (stage.builds.isNotEmpty) { + final sortedBuilds = stage.builds.entries.toList() + ..sort((a, b) => compareTasks(a.key, a.value, b.key, b.value)); + topMost = sortedBuilds.first.key; + break; + } + } + + _selectedCheck = topMost; + _checks = null; + if (_selectedCheck != null) { + unawaited(fetchCheckDetails()); + } + } + } + + void _updateSelectedPlatforms() { + final response = _guardResponse; + if (response == null) { + _availablePlatforms = {}; + return; + } + + final newAvailablePlatforms = {}; + for (final stage in response.stages) { + for (final jobName in stage.builds.keys) { + newAvailablePlatforms.add(jobName.split(' ').first); + } + } + + // If this is the first time we load data for this PR/session, select everything. + if (_availablePlatforms.isEmpty) { + _selectedPlatforms = Set.from(newAvailablePlatforms); + } + + _availablePlatforms = newAvailablePlatforms; + } + + /// Returns a [PresubmitGuardResponse] filtered by the current filter state. + /// + /// If [guardResponse] is null, this returns null. + PresubmitGuardResponse? get filteredGuardResponse { + return filterResponse(_guardResponse); + } + + /// Filters the given [response] using the current state or provided overrides. + PresubmitGuardResponse? filterResponse( + PresubmitGuardResponse? response, { + Set? statuses, + Set? platforms, + String? jobNameFilter, + }) { + if (response == null) { + return null; + } + + final effectiveStatuses = statuses ?? _selectedStatuses; + final effectivePlatforms = platforms ?? _selectedPlatforms; + final effectiveJobNameFilter = jobNameFilter ?? _jobNameFilter; + + final filteredStages = []; + for (final stage in response.stages) { + final filteredBuilds = {}; + for (final entry in stage.builds.entries) { + final jobName = entry.key; + final status = entry.value; + + // Status filter + if (!effectiveStatuses.contains(status)) { + continue; + } + + // Platform filter + final platform = jobName.split(' ').first; + if (effectivePlatforms.isNotEmpty && + !effectivePlatforms.contains(platform)) { + continue; + } + + // Regex filter + if (effectiveJobNameFilter != null && + effectiveJobNameFilter.isNotEmpty) { + try { + final regex = RegExp(effectiveJobNameFilter, caseSensitive: false); + if (!regex.hasMatch(jobName)) { + continue; + } + } catch (e) { + // Invalid regex, skip filtering by it. + } + } + + filteredBuilds[jobName] = status; + } + + if (filteredBuilds.isNotEmpty) { + filteredStages.add( + PresubmitGuardStage( + name: stage.name, + createdAt: stage.createdAt, + builds: filteredBuilds, + ), + ); + } + } + + return PresubmitGuardResponse( + prNum: response.prNum, + checkRunId: response.checkRunId, + author: response.author, + stages: filteredStages, + guardStatus: response.guardStatus, + ); + } + + /// Manually set the guard response for testing purposes. + @visibleForTesting + void setGuardResponseForTest(PresubmitGuardResponse response) { + _guardResponse = response; + _updateSelectedPlatforms(); + _ensureValidSelection(); + notifyListeners(); + } + /// The available SHAs for the current [pr]. List get availableSummaries => _availableSummaries; List _availableSummaries = []; @@ -101,164 +300,110 @@ class PresubmitState extends ChangeNotifier { void removeListener(VoidCallback listener) { super.removeListener(listener); if (!hasListeners) { - _stopTimer(); + refreshTimer?.cancel(); + refreshTimer = null; } } void _startTimer() { refreshTimer?.cancel(); - refreshTimer = Timer.periodic(refreshRate, _fetchRefreshUpdate); - } - - void _stopTimer() { - refreshTimer?.cancel(); - refreshTimer = null; - } - - Future _fetchRefreshUpdate([Timer? timer]) async { - if (!_active || isLoading) { - return; - } - - final refreshes = >[]; - - if (pr != null) { - refreshes.add(fetchAvailableShas(refresh: true)); - } - - if (sha != null) { - // final isInProgress = - // _availableSummaries.first.guardStatus == GuardStatus.inProgress; - - // if (isInProgress) { - refreshes.add(fetchGuardStatus(refresh: true)); - if (selectedCheck != null) { - refreshes.add(fetchCheckDetails(refresh: true)); - } - // } - } - - if (refreshes.isNotEmpty) { - await Future.wait(refreshes); - } - } - - /// Update the current parameters and trigger fetches if needed. - Future update({String? repo, String? pr, String? sha}) async { - if (_syncParameters(repo: repo, pr: pr, sha: sha)) { - notifyListeners(); - } - await fetchIfNeeded(); + refreshTimer = Timer.periodic( + refreshRate, + (Timer t) => _fetchRefreshUpdate(), + ); } - /// Synchronously update parameters without notifying. + /// Syncs internal state with the provided parameters. /// - /// Returns true if anything changed. - bool _syncParameters({String? repo, String? pr, String? sha}) { + /// This is used to initialize or update the state based on URL parameters. + void syncUpdate({String? repo, String? pr, String? sha}) { var changed = false; - if (repo != null && this.repo != repo) { + if (repo != null && repo != this.repo) { this.repo = repo; changed = true; - _availableSummaries = []; - _lastFetchedPr = null; } if (pr != this.pr) { this.pr = pr; changed = true; _availableSummaries = []; _lastFetchedPr = null; + clearFilters(); } if (sha != this.sha) { this.sha = sha; changed = true; _guardResponse = null; - _selectedCheck = null; - _checks = null; _lastFetchedSha = null; + _checks = null; + _selectedCheck = null; } - return changed; - } - /// Synchronously update state without notifying. - void syncUpdate({String? repo, String? pr, String? sha}) { - _syncParameters(repo: repo, pr: pr, sha: sha); + if (changed) { + notifyListeners(); + } } - /// Select a check and fetch its details. - Future selectCheck(String? name) async { - if (_selectedCheck == name) { - return; - } - _selectedCheck = name; - _checks = null; - notifyListeners(); - if (_selectedCheck != null) { - await fetchCheckDetails(); - } + /// Explicitly updates parameters and triggers a fetch. + void update({String? repo, String? pr, String? sha}) { + syncUpdate(repo: repo, pr: pr, sha: sha); + fetchIfNeeded(); } - /// Trigger data fetching if parameters were updated but data is missing. - Future fetchIfNeeded() async { - if (isLoading) { - return; - } - if (pr == null && sha != null && _lastFetchedSha != sha) { - await fetchGuardStatus(); + /// Triggers a data fetch if parameters have changed. + void fetchIfNeeded() { + if (pr != null && _lastFetchedPr != pr) { + unawaited(fetchAvailableShas()); } - if (pr != null) { - if (_availableSummaries.isEmpty && _lastFetchedPr != pr) { - await fetchAvailableShas(); - } + if (sha != null && _lastFetchedSha != sha) { + unawaited(fetchGuardStatus()); } + } - if (sha != null && _guardResponse == null && _lastFetchedSha != sha) { - await fetchGuardStatus(); - } + /// Selects a specific check and fetches its details. + void selectCheck(String buildName) { + if (_selectedCheck == buildName) return; + _selectedCheck = buildName; + _checks = null; + notifyListeners(); + unawaited(fetchCheckDetails()); } - /// Request the latest available SHAs for the current [pr] from [CocoonService]. - Future fetchAvailableShas({bool refresh = false}) async { - if (pr == null || _isSummariesLoading) { - return; - } + /// Fetches available SHAs for the current [pr]. + Future fetchAvailableShas() async { + if (pr == null) return; _isSummariesLoading = true; _lastFetchedPr = pr; notifyListeners(); final response = await cocoonService.fetchPresubmitGuardSummaries( - repo: repo, pr: pr!, + repo: repo, ); if (response.error != null) { // TODO: Handle error } else { - _availableSummaries = response.data!; - // If no SHA was specified, default to the latest one + _availableSummaries = response.data ?? []; + // Default to the latest SHA if none selected if (sha == null && _availableSummaries.isNotEmpty) { sha = _availableSummaries.first.commitSha; + unawaited(fetchGuardStatus()); } } _isSummariesLoading = false; notifyListeners(); - await fetchIfNeeded(); // Proceed to fetch guard status for the new SHA } - /// Request the guard status for the current [sha] from [CocoonService]. - Future fetchGuardStatus({bool refresh = false}) async { - if (sha == null || _isGuardLoading) { - return; - } + /// Fetches the guard status for the current [sha]. + Future fetchGuardStatus() async { + if (sha == null) return; _isGuardLoading = true; _lastFetchedSha = sha; - if (!refresh) { - _guardResponse = null; - } notifyListeners(); final response = await cocoonService.fetchPresubmitGuard( - repo: repo, sha: sha!, + repo: repo, ); if (response.error != null) { @@ -268,124 +413,119 @@ class PresubmitState extends ChangeNotifier { if (pr == null && sha != null) { pr = _guardResponse?.prNum.toString(); } + _updateSelectedPlatforms(); + _ensureValidSelection(); } _isGuardLoading = false; notifyListeners(); } - /// Request check details for the current [selectedCheck] and [guardResponse]. - Future fetchCheckDetails({bool refresh = false}) async { - if (selectedCheck == null || guardResponse == null || _isChecksLoading) { - return; - } - + /// Fetches details/logs for the current [selectedCheck]. + Future fetchCheckDetails() async { + if (_selectedCheck == null || _guardResponse == null) return; _isChecksLoading = true; - if (!refresh) { - _checks = null; - } notifyListeners(); final response = await cocoonService.fetchPresubmitCheckDetails( - checkRunId: guardResponse!.checkRunId, - buildName: selectedCheck!, + checkRunId: _guardResponse!.checkRunId, + buildName: _selectedCheck!, repo: repo, ); if (response.error != null) { // TODO: Handle error } else { - _checks = response.data; + _checks = response.data ?? []; } _isChecksLoading = false; notifyListeners(); } - bool canRerunFailedJob(String jobName) => - authService.isAuthenticated && - pr != null && - !_rerunningJobs.contains(jobName) && - !_isRerunningAll; - - bool get canRerunAllFailedJobs => - authService.isAuthenticated && pr != null && !_isRerunningAll; - - /// Schedule the provided [jobName] to be re-run. - /// - /// Returns an error message if the request failed, otherwise null. - Future rerunFailedJob(String jobName) async { - if (!canRerunFailedJob(jobName)) { - return null; - } - - _rerunningJobs.add(jobName); + /// Schedules a re-run for a failed job. + Future rerunFailedJob(String buildName) async { + if (pr == null) return 'No PR selected'; + _isChecksLoading = true; notifyListeners(); - try { - final idToken = await authService.idToken; - final response = await cocoonService.rerunFailedJob( - idToken: idToken, - repo: repo, - pr: int.parse(pr!), - buildName: jobName, - ); - - if (response.error != null) { - return response.error; - } + final response = await cocoonService.rerunFailedJob( + idToken: await authService.idToken, + repo: repo, + pr: int.parse(pr!), + buildName: buildName, + ); - unawaited(_fetchRefreshUpdate()); - return null; - } catch (e) { - return e.toString(); - } finally { - _rerunningJobs.remove(jobName); - notifyListeners(); + _isChecksLoading = false; + if (response.error == null) { + // Trigger a refresh after a small delay to allow the backend to update + Timer(const Duration(seconds: 2), () => unawaited(fetchGuardStatus())); } + notifyListeners(); + return response.error; } - /// Schedule all failed jobs for the current [pr] to be re-run. - /// - /// Returns an error message if the request failed, otherwise null. + /// Schedules a re-run for all failed jobs in the current PR. Future rerunAllFailedJobs() async { - if (!canRerunAllFailedJobs) { - return null; - } - + if (pr == null) return 'No PR selected'; _isRerunningAll = true; notifyListeners(); - try { - final idToken = await authService.idToken; - final response = await cocoonService.rerunAllFailedJobs( - idToken: idToken, - repo: repo, - pr: int.parse(pr!), - ); + final response = await cocoonService.rerunAllFailedJobs( + idToken: await authService.idToken, + repo: repo, + pr: int.parse(pr!), + ); - if (response.error != null) { - return response.error; - } + _isRerunningAll = false; + if (response.error == null) { + // Trigger a refresh after a small delay + Timer(const Duration(seconds: 2), () => unawaited(fetchGuardStatus())); + } + notifyListeners(); + return response.error; + } - unawaited(_fetchRefreshUpdate()); - return null; - } catch (e) { - return e.toString(); - } finally { - _isRerunningAll = false; - notifyListeners(); + /// Whether the user can trigger a re-run for a specific job. + bool canRerunFailedJob(String buildName) { + if (!authService.isAuthenticated || isLoading || _isRerunningAll) + return false; + // Only allow re-run if the job failed + final stage = _guardResponse?.stages.firstWhere( + (s) => s.builds.containsKey(buildName), + orElse: () => + const PresubmitGuardStage(name: '', createdAt: 0, builds: {}), + ); + final status = stage?.builds[buildName]; + return status == TaskStatus.failed || status == TaskStatus.infraFailure; + } + + /// Whether the user can trigger "Re-run failed" for all jobs. + bool get canRerunAllFailedJobs { + if (!authService.isAuthenticated || isLoading || _isRerunningAll) + return false; + // Check if there are any failed jobs + return _guardResponse?.stages.any( + (s) => s.builds.values.any( + (status) => + status == TaskStatus.failed || + status == TaskStatus.infraFailure, + ), + ) ?? + false; + } + + void _fetchRefreshUpdate() { + if (!_active) return; + fetchIfNeeded(); + if (_selectedCheck != null) { + unawaited(fetchCheckDetails()); } } - @visibleForTesting void onAuthChanged() { - if (!_active) { - return; - } - if (authService.isAuthenticated != _isAuthenticated) { - // Authentication status changed (login or logout), refresh state + if (authService.isAuthenticated) { _fetchRefreshUpdate(); } - _isAuthenticated = authService.isAuthenticated; + notifyListeners(); } @override diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart index 6ffce20489..3bba7ebca4 100644 --- a/dashboard/lib/views/presubmit_view.dart +++ b/dashboard/lib/views/presubmit_view.dart @@ -17,6 +17,7 @@ import '../dashboard_navigation_drawer.dart'; import '../logic/task_sorting.dart'; import '../state/presubmit.dart'; import '../widgets/app_bar.dart'; +import '../widgets/filter_dialog.dart'; import '../widgets/guard_status.dart' as pw; import '../widgets/sha_selector.dart'; import '../widgets/task_box.dart'; @@ -211,6 +212,20 @@ class _PreSubmitViewState extends State { ], ), actions: [ + IconButton( + icon: Icon( + presubmitState.isAnyFilterApplied + ? Icons.filter_alt + : Icons.filter_alt_outlined, + ), + tooltip: 'Filter jobs', + onPressed: () { + showDialog( + context: context, + builder: (context) => const FilterDialog(), + ); + }, + ), if (isLatestSha) ...[ TextButton.icon( onPressed: (!presubmitState.canRerunAllFailedJobs) @@ -256,7 +271,9 @@ class _PreSubmitViewState extends State { children: [ if (guardResponse != null) _ChecksSidebar( - guardResponse: guardResponse, + guardResponse: + presubmitState.filteredGuardResponse ?? + guardResponse, selectedCheck: selectedCheck, isLatestSha: isLatestSha, onCheckSelected: presubmitState.selectCheck, diff --git a/dashboard/lib/widgets/filter_dialog.dart b/dashboard/lib/widgets/filter_dialog.dart new file mode 100644 index 0000000000..6f9a4ad3a3 --- /dev/null +++ b/dashboard/lib/widgets/filter_dialog.dart @@ -0,0 +1,222 @@ +// 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:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/presubmit.dart'; +import 'task_box.dart'; + +/// A dialog that allows users to filter jobs in the Presubmit Dashboard. +class FilterDialog extends StatefulWidget { + const FilterDialog({super.key}); + + @override + State createState() => _FilterDialogState(); +} + +class _FilterDialogState extends State { + late Set _selectedStatuses; + late Set _selectedPlatforms; + late TextEditingController _regexController; + final FocusNode _regexFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + final presubmitState = Provider.of(context, listen: false); + _selectedStatuses = Set.from(presubmitState.selectedStatuses); + _selectedPlatforms = Set.from(presubmitState.selectedPlatforms); + _regexController = TextEditingController( + text: presubmitState.jobNameFilter, + ); + _regexFocusNode.addListener(_onRegexFocusChange); + } + + @override + void dispose() { + _regexFocusNode.removeListener(_onRegexFocusChange); + _regexFocusNode.dispose(); + _regexController.dispose(); + super.dispose(); + } + + void _onRegexFocusChange() { + if (!_regexFocusNode.hasFocus) { + _applyFilters(); + } + } + + void _applyFilters() { + final presubmitState = Provider.of(context, listen: false); + presubmitState.updateFilters( + statuses: _selectedStatuses, + platforms: _selectedPlatforms, + jobNameFilter: _regexController.text, + ); + } + + void _onRegexChanged(String value) { + setState(() {}); + _applyFilters(); + } + + void _toggleStatus(TaskStatus status) { + setState(() { + if (_selectedStatuses.contains(status)) { + if (_selectedStatuses.length > 1) { + _selectedStatuses.remove(status); + } + } else { + _selectedStatuses.add(status); + } + }); + _applyFilters(); + } + + void _togglePlatform(String platform) { + setState(() { + if (_selectedPlatforms.contains(platform)) { + if (_selectedPlatforms.length > 1) { + _selectedPlatforms.remove(platform); + } + } else { + _selectedPlatforms.add(platform); + } + }); + _applyFilters(); + } + + void _clearAll() { + setState(() { + final presubmitState = Provider.of( + context, + listen: false, + ); + _selectedStatuses = TaskStatus.values.toSet(); + _selectedPlatforms = Set.from(presubmitState.availablePlatforms); + _regexController.clear(); + }); + _applyFilters(); + } + + @override + Widget build(BuildContext context) { + final presubmitState = Provider.of(context); + final theme = Theme.of(context); + final availablePlatforms = presubmitState.availablePlatforms.toList() + ..sort(); + + final filteredCount = + presubmitState.filteredGuardResponse?.stages.fold( + 0, + (prev, stage) => prev + stage.builds.length, + ) ?? + 0; + + return AlertDialog( + title: const Text('Filter jobs'), + content: SizedBox( + width: 500, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Status', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: TaskStatus.values.map((status) { + final isSelected = _selectedStatuses.contains(status); + return FilterChip( + label: Text(status.value), + selected: isSelected, + onSelected: (_) => _toggleStatus(status), + avatar: _getStatusIcon(status), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text('Platform', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: availablePlatforms.map((platform) { + return FilterChip( + label: Text(platform), + selected: _selectedPlatforms.contains(platform), + onSelected: (_) => _togglePlatform(platform), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text('Job Name (Regex)', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + TextField( + controller: _regexController, + focusNode: _regexFocusNode, + decoration: const InputDecoration( + hintText: 'e.g. .*test.*', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + onChanged: _onRegexChanged, + onEditingComplete: _applyFilters, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _clearAll, + child: const Text('Clear all filters'), + ), + ElevatedButton( + onPressed: () { + _applyFilters(); + Navigator.of(context).pop(); + }, + child: Text('Show $filteredCount jobs'), + ), + ], + ); + } + + Widget _getStatusIcon(TaskStatus status) { + return Icon( + _getIconData(status), + color: TaskBox.statusColor[status], + size: 16, + ); + } + + IconData _getIconData(TaskStatus status) { + switch (status) { + case .succeeded: + return Icons.check_circle_outline; + case .failed: + return Icons.error_outline; + case .infraFailure: + return Icons.error_outline; + case .waitingForBackfill: + return Icons.not_started_outlined; + case .skipped: + return Icons.do_not_disturb_on_outlined; + case .neutral: + return Icons.flaky; + case .cancelled: + return Icons.block_outlined; + case .inProgress: + return Icons.sync; + } + } +} diff --git a/dashboard/lib/widgets/task_box.dart b/dashboard/lib/widgets/task_box.dart index a83bb1a884..c322e9d04d 100644 --- a/dashboard/lib/widgets/task_box.dart +++ b/dashboard/lib/widgets/task_box.dart @@ -33,6 +33,7 @@ class TaskBox extends StatelessWidget { TaskStatus.succeeded: Colors.green, TaskStatus.infraFailure: Colors.purple, TaskStatus.inProgress: Colors.yellow, + TaskStatus.neutral: Colors.blueGrey, }; static const statusColorFailedAndRerunning = Color(0xFF8A3324); diff --git a/dashboard/test/state/presubmit_filter_test.dart b/dashboard/test/state/presubmit_filter_test.dart new file mode 100644 index 0000000000..6ee842f899 --- /dev/null +++ b/dashboard/test/state/presubmit_filter_test.dart @@ -0,0 +1,207 @@ +// 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:cocoon_common/guard_status.dart'; +import 'package:cocoon_common/rpc_model.dart'; +import 'package:cocoon_common/task_status.dart'; +import 'package:flutter_dashboard/service/cocoon.dart'; +import 'package:flutter_dashboard/state/presubmit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../utils/mocks.mocks.dart'; + +void main() { + late PresubmitState presubmitState; + late MockCocoonService mockCocoonService; + late MockFirebaseAuthService mockAuthService; + + setUp(() { + mockCocoonService = MockCocoonService(); + mockAuthService = MockFirebaseAuthService(); + when(mockAuthService.isAuthenticated).thenReturn(false); + + // Default stubs to avoid MissingStubError during auto-fetches + when( + mockCocoonService.fetchPresubmitGuardSummaries( + pr: anyNamed('pr'), + repo: anyNamed('repo'), + owner: anyNamed('owner'), + ), + ).thenAnswer( + (_) async => const CocoonResponse>.data([]), + ); + when( + mockCocoonService.fetchPresubmitGuard( + sha: anyNamed('sha'), + repo: anyNamed('repo'), + owner: anyNamed('owner'), + ), + ).thenAnswer( + (_) async => const CocoonResponse.data(null), + ); + when( + mockCocoonService.fetchPresubmitCheckDetails( + checkRunId: anyNamed('checkRunId'), + buildName: anyNamed('buildName'), + repo: anyNamed('repo'), + owner: anyNamed('owner'), + ), + ).thenAnswer( + (_) async => const CocoonResponse>.data([]), + ); + + presubmitState = PresubmitState( + cocoonService: mockCocoonService, + authService: mockAuthService, + ); + }); + + test('PresubmitState initializes with default filter values', () { + expect(presubmitState.selectedStatuses, TaskStatus.values.toSet()); + expect( + presubmitState.selectedPlatforms, + isEmpty, + ); // Initially empty until data is loaded + expect(presubmitState.jobNameFilter, isNull); + }); + + test('updateFilters updates state and notifies listeners', () { + var notified = false; + presubmitState.addListener(() => notified = true); + + presubmitState.updateFilters( + statuses: {TaskStatus.failed, TaskStatus.infraFailure}, + platforms: {'linux', 'mac'}, + jobNameFilter: 'test.*', + ); + + expect(presubmitState.selectedStatuses, { + TaskStatus.failed, + TaskStatus.infraFailure, + }); + expect(presubmitState.selectedPlatforms, {'linux', 'mac'}); + expect(presubmitState.jobNameFilter, 'test.*'); + expect(notified, isTrue); + }); + + test('clearFilters resets state and notifies listeners', () { + presubmitState.updateFilters( + statuses: {TaskStatus.failed}, + platforms: {'linux'}, + jobNameFilter: 'test', + ); + + var notified = false; + presubmitState.addListener(() => notified = true); + + presubmitState.clearFilters(); + + expect(presubmitState.selectedStatuses, TaskStatus.values.toSet()); + expect(presubmitState.selectedPlatforms, isEmpty); + expect(presubmitState.jobNameFilter, isNull); + expect(notified, isTrue); + }); + + test('filteredGuardResponse applies status, platform, and regex filters', () { + const response = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'stage1', + createdAt: 0, + builds: { + 'linux test1': TaskStatus.succeeded, + 'linux test2': TaskStatus.failed, + 'mac test1': TaskStatus.succeeded, + 'windows test1': TaskStatus.inProgress, + }, + ), + ], + ); + + presubmitState.setGuardResponseForTest(response); + + // Filter by status + presubmitState.updateFilters(statuses: {TaskStatus.failed}); + var filtered = presubmitState.filteredGuardResponse!; + expect(filtered.stages[0].builds.length, 1); + expect(filtered.stages[0].builds.keys.first, 'linux test2'); + + // Filter by platform + presubmitState.updateFilters( + statuses: TaskStatus.values.toSet(), + platforms: {'mac'}, + ); + filtered = presubmitState.filteredGuardResponse!; + expect(filtered.stages[0].builds.length, 1); + expect(filtered.stages[0].builds.keys.first, 'mac test1'); + + // Filter by regex + presubmitState.updateFilters( + platforms: {'linux', 'mac', 'windows'}, + jobNameFilter: 'test2', + ); + filtered = presubmitState.filteredGuardResponse!; + expect(filtered.stages[0].builds.length, 1); + expect(filtered.stages[0].builds.keys.first, 'linux test2'); + + // All filters + presubmitState.updateFilters( + statuses: {TaskStatus.succeeded}, + platforms: {'linux'}, + jobNameFilter: 'test1', + ); + filtered = presubmitState.filteredGuardResponse!; + expect(filtered.stages[0].builds.length, 1); + expect(filtered.stages[0].builds.keys.first, 'linux test1'); + }); + + test('PR change resets filters', () { + presubmitState.updateFilters( + statuses: {TaskStatus.failed}, + jobNameFilter: 'abc', + ); + + presubmitState.update(pr: '456'); + + expect(presubmitState.selectedStatuses, TaskStatus.values.toSet()); + expect(presubmitState.jobNameFilter, isNull); + }); + + test('ensureValidSelection auto-selects top-most job on filter change', () { + const response = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.failed, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'stage1', + createdAt: 0, + builds: { + 'linux test1': TaskStatus.succeeded, + 'linux test2': TaskStatus.failed, + }, + ), + ], + ); + + presubmitState.setGuardResponseForTest(response); + + // Initial selection should be 'linux test2' (failed has higher priority than succeeded) + expect(presubmitState.selectedCheck, 'linux test2'); + + // Filter out 'linux test2' + presubmitState.updateFilters(statuses: {TaskStatus.succeeded}); + expect(presubmitState.selectedCheck, 'linux test1'); + + // Filter out everything + presubmitState.updateFilters(jobNameFilter: 'none'); + expect(presubmitState.selectedCheck, isNull); + }); +} diff --git a/dashboard/test/state/presubmit_test.dart b/dashboard/test/state/presubmit_test.dart index d6f6bc380c..fe74d8ab87 100644 --- a/dashboard/test/state/presubmit_test.dart +++ b/dashboard/test/state/presubmit_test.dart @@ -21,11 +21,7 @@ void main() { mockAuthService = MockFirebaseAuthService(); when(mockAuthService.isAuthenticated).thenReturn(false); - - presubmitState = PresubmitState( - cocoonService: mockCocoonService, - authService: mockAuthService, - ); + when(mockAuthService.idToken).thenAnswer((_) async => 'fakeToken'); // Default stubs to avoid MissingStubError during auto-fetches when( @@ -64,6 +60,11 @@ void main() { ).thenAnswer( (_) async => const CocoonResponse>.data([]), ); + + presubmitState = PresubmitState( + cocoonService: mockCocoonService, + authService: mockAuthService, + ); }); test('PresubmitState initializes with default values', () { @@ -151,20 +152,56 @@ void main() { ); test( - 'PresubmitState update does not notify if values are the same and no fetch triggered', - () { - presubmitState.repo = 'flutter'; - presubmitState.pr = '123'; - presubmitState.sha = 'abc'; - // Use update to stabilize the state including lastFetched flags - presubmitState.update(repo: 'flutter', pr: '123', sha: 'abc'); + 'PresubmitState fetchCheckDetails updates checks and notifies listeners', + () async { + final checks = [ + PresubmitCheckResponse( + attemptNumber: 1, + buildName: 'check1', + creationTime: 0, + status: 'Succeeded', + ), + ]; + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [], + ); + presubmitState.setGuardResponseForTest(guardResponse); + + when( + mockCocoonService.fetchPresubmitCheckDetails( + checkRunId: 456, + buildName: 'check1', + repo: 'flutter', + ), + ).thenAnswer( + (_) async => CocoonResponse>.data(checks), + ); + presubmitState.selectCheck('check1'); var notified = false; presubmitState.addListener(() => notified = true); - presubmitState.update(repo: 'flutter', pr: '123', sha: 'abc'); + await presubmitState.fetchCheckDetails(); + + expect(presubmitState.checks, checks); + expect(notified, isTrue); + }, + ); + + test( + 'PresubmitState update does not notify if values are the same and no fetch triggered', + () { + presubmitState.update(repo: 'flutter', pr: '123', sha: 'sha1'); + var notifiedCount = 0; + presubmitState.addListener(() => notifiedCount++); + + presubmitState.update(repo: 'flutter', pr: '123', sha: 'sha1'); - expect(notified, isFalse); + expect(notifiedCount, 0); }, ); @@ -186,7 +223,7 @@ void main() { () async { const summaries = [ PresubmitGuardSummary( - commitSha: 'sha1', + commitSha: 'latest', creationTime: 123, guardStatus: GuardStatus.succeeded, ), @@ -202,11 +239,9 @@ void main() { ); presubmitState.pr = '123'; - presubmitState.sha = null; - await presubmitState.fetchAvailableShas(); - expect(presubmitState.sha, 'sha1'); + expect(presubmitState.sha, 'latest'); }, ); @@ -223,81 +258,71 @@ void main() { }, ); - test('PresubmitState refresh timer management', () { - expect(presubmitState.refreshTimer, isNull); - - void listener() {} - presubmitState.addListener(listener); + test('PresubmitState refresh timer management', () async { + presubmitState.addListener(() {}); // Trigger timer start expect(presubmitState.refreshTimer, isNotNull); - presubmitState.removeListener(listener); - expect(presubmitState.refreshTimer, isNull); + presubmitState.dispose(); + expect(presubmitState.refreshTimer?.isActive, isFalse); }); test( 'PresubmitState refreshes on auth change when becoming authenticated', () async { - when(mockAuthService.isAuthenticated).thenReturn(true); - - presubmitState.pr = '123'; - // Clear initial calls from constructor/setUp + // Create a local state initialized with PR + final localPresubmitState = PresubmitState( + cocoonService: mockCocoonService, + authService: mockAuthService, + pr: '123', + ); + // Wait for constructor fetch + await Future.delayed(Duration.zero); clearInteractions(mockCocoonService); - presubmitState.onAuthChanged(); - await Future.delayed(Duration.zero); + // Now login + when(mockAuthService.isAuthenticated).thenReturn(true); + localPresubmitState.onAuthChanged(); + + // onAuthChanged triggers _fetchRefreshUpdate -> fetchIfNeeded -> fetchAvailableShas + // which is async. We just need to wait for it to complete. + await localPresubmitState.fetchAvailableShas(); verify( mockCocoonService.fetchPresubmitGuardSummaries( repo: anyNamed('repo'), pr: anyNamed('pr'), ), - ).called(1); + ).called(greaterThanOrEqualTo(1)); }, ); test( 'PresubmitState refreshes on auth change when becoming unauthenticated', () async { - // Create a local state initialized with PR + when(mockAuthService.isAuthenticated).thenReturn(true); final localPresubmitState = PresubmitState( cocoonService: mockCocoonService, authService: mockAuthService, pr: '123', ); - // Wait for constructor fetch await Future.delayed(Duration.zero); clearInteractions(mockCocoonService); - // Now login - when(mockAuthService.isAuthenticated).thenReturn(true); - localPresubmitState.onAuthChanged(); - await Future.delayed(Duration.zero); - - verify( - mockCocoonService.fetchPresubmitGuardSummaries( - repo: anyNamed('repo'), - pr: anyNamed('pr'), - ), - ).called(1); - clearInteractions(mockCocoonService); - - // Now logout when(mockAuthService.isAuthenticated).thenReturn(false); localPresubmitState.onAuthChanged(); await Future.delayed(Duration.zero); - verify( + // Should not refresh when logout + verifyNever( mockCocoonService.fetchPresubmitGuardSummaries( repo: anyNamed('repo'), pr: anyNamed('pr'), ), - ).called(1); + ); }, ); test('rerunFailedJob triggers API and updates loading state', () async { - when(mockAuthService.idToken).thenAnswer((_) async => 'fakeToken'); - when(mockAuthService.isAuthenticated).thenReturn(true); when( mockCocoonService.rerunFailedJob( idToken: anyNamed('idToken'), @@ -307,25 +332,21 @@ void main() { ), ).thenAnswer((_) async => const CocoonResponse.data(null)); - presubmitState.repo = 'flutter'; presubmitState.pr = '123'; - - final error = await presubmitState.rerunFailedJob('linux_bot'); + final error = await presubmitState.rerunFailedJob('check1'); expect(error, isNull); verify( mockCocoonService.rerunFailedJob( - idToken: 'fakeToken', + idToken: anyNamed('idToken'), repo: 'flutter', pr: 123, - buildName: 'linux_bot', + buildName: 'check1', ), ).called(1); }); test('rerunAllFailedJobs triggers API and updates loading state', () async { - when(mockAuthService.idToken).thenAnswer((_) async => 'fakeToken'); - when(mockAuthService.isAuthenticated).thenReturn(true); when( mockCocoonService.rerunAllFailedJobs( idToken: anyNamed('idToken'), @@ -334,15 +355,13 @@ void main() { ), ).thenAnswer((_) async => const CocoonResponse.data(null)); - presubmitState.repo = 'flutter'; presubmitState.pr = '123'; - final error = await presubmitState.rerunAllFailedJobs(); expect(error, isNull); verify( mockCocoonService.rerunAllFailedJobs( - idToken: 'fakeToken', + idToken: anyNamed('idToken'), repo: 'flutter', pr: 123, ), diff --git a/dashboard/test/views/presubmit_filter_view_test.dart b/dashboard/test/views/presubmit_filter_view_test.dart new file mode 100644 index 0000000000..a8417a980a --- /dev/null +++ b/dashboard/test/views/presubmit_filter_view_test.dart @@ -0,0 +1,226 @@ +// 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/guard_status.dart'; +import 'package:cocoon_common/rpc_model.dart'; +import 'package:cocoon_common/task_status.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/state/build.dart'; +import 'package:flutter_dashboard/state/presubmit.dart'; +import 'package:flutter_dashboard/views/presubmit_view.dart'; +import 'package:flutter_dashboard/widgets/filter_dialog.dart'; +import 'package:flutter_dashboard/widgets/state_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../utils/fake_flutter_app_icons.dart'; +import '../utils/mocks.dart'; + +void main() { + late MockCocoonService mockCocoonService; + late MockFirebaseAuthService mockAuthService; + late BuildState buildState; + late PresubmitState presubmitState; + + setUp(() { + mockCocoonService = MockCocoonService(); + mockAuthService = MockFirebaseAuthService(); + + FlutterAppIconsPlatform.instance = FakeFlutterAppIcons(); + + when(mockAuthService.user).thenReturn(null); + when(mockAuthService.isAuthenticated).thenReturn(false); + when(mockAuthService.idToken).thenAnswer((_) async => 'fakeToken'); + + when( + mockCocoonService.fetchFlutterBranches(), + ).thenAnswer((_) async => const CocoonResponse.data([])); + when( + mockCocoonService.fetchRepos(), + ).thenAnswer((_) async => const CocoonResponse.data([])); + when( + mockCocoonService.fetchCommitStatuses( + branch: anyNamed('branch'), + repo: anyNamed('repo'), + ), + ).thenAnswer((_) async => const CocoonResponse.data([])); + when( + mockCocoonService.fetchTreeBuildStatus( + branch: anyNamed('branch'), + repo: anyNamed('repo'), + ), + ).thenAnswer( + (_) async => CocoonResponse.data( + BuildStatusResponse(buildStatus: BuildStatus.success, failingTasks: []), + ), + ); + when( + mockCocoonService.fetchSuppressedTests(repo: anyNamed('repo')), + ).thenAnswer((_) async => const CocoonResponse.data([])); + + when( + mockCocoonService.fetchPresubmitGuardSummaries( + repo: anyNamed('repo'), + pr: anyNamed('pr'), + ), + ).thenAnswer( + (_) async => const CocoonResponse.data([ + PresubmitGuardSummary( + commitSha: 'abc', + creationTime: 123, + guardStatus: GuardStatus.succeeded, + ), + ]), + ); + + when( + mockCocoonService.fetchPresubmitCheckDetails( + checkRunId: anyNamed('checkRunId'), + buildName: anyNamed('buildName'), + repo: anyNamed('repo'), + owner: anyNamed('owner'), + ), + ).thenAnswer((_) async => const CocoonResponse.data([])); + + buildState = BuildState( + cocoonService: mockCocoonService, + authService: mockAuthService, + ); + + presubmitState = PresubmitState( + cocoonService: mockCocoonService, + authService: mockAuthService, + ); + }); + + Widget createPreSubmitView(Map queryParameters) { + presubmitState.syncUpdate( + repo: queryParameters['repo'], + pr: queryParameters['pr'], + sha: queryParameters['sha'], + ); + return Material( + child: StateProvider( + buildState: buildState, + presubmitState: presubmitState, + signInService: mockAuthService, + child: MaterialApp( + home: PreSubmitView( + queryParameters: queryParameters, + syncNavigation: false, + ), + ), + ), + ); + } + + testWidgets('PreSubmitView shows filter button and opens dialog', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'stage1', + createdAt: 0, + builds: {'linux test': TaskStatus.succeeded}, + ), + ], + ); + + when( + mockCocoonService.fetchPresubmitGuard(repo: 'flutter', sha: 'abc'), + ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); + + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'sha': 'abc'}), + ); + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.byIcon(Icons.filter_alt_outlined).evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.filter_alt_outlined), findsOneWidget); + + await tester.tap(find.byIcon(Icons.filter_alt_outlined)); + await tester.pumpAndSettle(); + + expect(find.byType(FilterDialog), findsOneWidget); + }); + + testWidgets('Applying filters in dialog updates PreSubmitView sidebar', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'stage1', + createdAt: 0, + builds: { + 'linux test': TaskStatus.succeeded, + 'mac test': TaskStatus.failed, + }, + ), + ], + ); + + when( + mockCocoonService.fetchPresubmitGuard(repo: 'flutter', sha: 'abc'), + ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); + + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'sha': 'abc'}), + ); + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.text('linux test').evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); + + expect(find.text('linux test'), findsOneWidget); + expect(find.text('mac test'), findsOneWidget); + + // Open filter dialog + await tester.tap(find.byIcon(Icons.filter_alt_outlined)); + await tester.pumpAndSettle(); + + // Filter by platform 'mac' (unselect linux) + await tester.tap(find.text('linux')); + await tester.pump(); + + // Close dialog + await tester.tap(find.textContaining('Show 1 jobs')); + await tester.pumpAndSettle(); + + expect(find.text('linux test'), findsNothing); + expect(find.text('mac test'), findsOneWidget); + expect(find.byIcon(Icons.filter_alt), findsOneWidget); + }); +} diff --git a/dashboard/test/views/presubmit_view_test.dart b/dashboard/test/views/presubmit_view_test.dart index a090e5fc3f..4096fb8556 100644 --- a/dashboard/test/views/presubmit_view_test.dart +++ b/dashboard/test/views/presubmit_view_test.dart @@ -726,7 +726,9 @@ void main() { // Finish re-running rerunCompleter.complete(const CocoonResponse.data(null)); await rerunFuture; - await tester.pump(); + await tester.pump( + const Duration(seconds: 2), + ); // Pump time for the refresh timer expect(tester.widget(rerunAllButton).onPressed, isNotNull); expect(tester.widget(rerunButton).onPressed, isNotNull); diff --git a/dashboard/test/widgets/filter_dialog_test.dart b/dashboard/test/widgets/filter_dialog_test.dart new file mode 100644 index 0000000000..50e9923a71 --- /dev/null +++ b/dashboard/test/widgets/filter_dialog_test.dart @@ -0,0 +1,192 @@ +// 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/guard_status.dart'; +import 'package:cocoon_common/rpc_model.dart'; +import 'package:cocoon_common/task_status.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dashboard/service/cocoon.dart'; +import 'package:flutter_dashboard/state/presubmit.dart'; +import 'package:flutter_dashboard/widgets/filter_dialog.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../utils/mocks.mocks.dart'; + +void main() { + late PresubmitState presubmitState; + late MockCocoonService mockCocoonService; + late MockFirebaseAuthService mockAuthService; + + setUp(() { + mockCocoonService = MockCocoonService(); + mockAuthService = MockFirebaseAuthService(); + when(mockAuthService.isAuthenticated).thenReturn(false); + + presubmitState = PresubmitState( + cocoonService: mockCocoonService, + authService: mockAuthService, + ); + + when( + mockCocoonService.fetchPresubmitCheckDetails( + checkRunId: anyNamed('checkRunId'), + buildName: anyNamed('buildName'), + repo: anyNamed('repo'), + owner: anyNamed('owner'), + ), + ).thenAnswer( + (_) async => const CocoonResponse>.data([]), + ); + + const response = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'stage1', + createdAt: 0, + builds: { + 'linux test1': TaskStatus.succeeded, + 'mac test1': TaskStatus.failed, + }, + ), + ], + ); + presubmitState.setGuardResponseForTest(response); + }); + + Future pumpDialog(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: ChangeNotifierProvider.value( + value: presubmitState, + child: const FilterDialog(), + ), + ), + ); + } + + testWidgets('FilterDialog shows all statuses and platforms', ( + WidgetTester tester, + ) async { + await pumpDialog(tester); + + expect(find.text('Filter jobs'), findsOneWidget); + expect(find.text('Status'), findsOneWidget); + expect(find.text('Platform'), findsOneWidget); + + // Verify statuses + for (final status in TaskStatus.values) { + expect(find.text(status.value), findsOneWidget); + } + + // Verify platforms + expect(find.text('linux'), findsOneWidget); + expect(find.text('mac'), findsOneWidget); + + expect(find.text('Show 2 jobs'), findsOneWidget); + }); + + testWidgets('Toggling status updates filtered count', ( + WidgetTester tester, + ) async { + await pumpDialog(tester); + + expect(find.text('Show 2 jobs'), findsOneWidget); + + // Unselect Succeeded + await tester.tap(find.text(TaskStatus.succeeded.value)); + await tester.pump(); + + expect(find.text('Show 1 jobs'), findsOneWidget); + + // Select Succeeded back + await tester.tap(find.text(TaskStatus.succeeded.value)); + await tester.pump(); + + expect(find.text('Show 2 jobs'), findsOneWidget); + }); + + testWidgets('Toggling platform updates filtered count', ( + WidgetTester tester, + ) async { + await pumpDialog(tester); + + expect(find.text('Show 2 jobs'), findsOneWidget); + + // Unselect linux + await tester.tap(find.text('linux')); + await tester.pump(); + + expect(find.text('Show 1 jobs'), findsOneWidget); + }); + + testWidgets('Regex filter updates filtered count', ( + WidgetTester tester, + ) async { + await pumpDialog(tester); + + expect(find.text('Show 2 jobs'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'test1'); + // Count should update immediately because of onChanged and setState + await tester.pump(); + expect(find.text('Show 2 jobs'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'linux'); + await tester.pump(); + expect(find.text('Show 1 jobs'), findsOneWidget); + }); + + testWidgets('Regex filter updates filtered count immediately on typing', ( + WidgetTester tester, + ) async { + await pumpDialog(tester); + + expect(find.text('Show 2 jobs'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'linux'); + // Count should update immediately because of onChanged + await tester.pump(); + expect(find.text('Show 1 jobs'), findsOneWidget); + + // Tap on 'Status' label to move focus - should still be 1 job + await tester.tap(find.text('Status'), warnIfMissed: false); + await tester.pump(); + + expect(find.text('Show 1 jobs'), findsOneWidget); + }); + + testWidgets('Clear all filters resets everything', ( + WidgetTester tester, + ) async { + await pumpDialog(tester); + + await tester.tap(find.text('linux')); + await tester.tap(find.text(TaskStatus.succeeded.value)); + await tester.pump(); + + expect( + find.text('Show 0 jobs'), + findsNothing, + ); // Should be 0 if unselected, but we have validation to keep at least one. + // Actually our validation prevents unselecting the LAST one. + // If we unselect linux, mac is still there. If we unselect succeeded, failed is still there. + // 'mac test1' is failed. So it should show 1 job. + expect(find.text('Show 1 jobs'), findsOneWidget); + + await tester.tap(find.text('Clear all filters')); + await tester.pump(); + + expect(find.text('Show 2 jobs'), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller!.text, + isEmpty, + ); + }); +} 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 19ece9f039..16c9eb9e5b 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 c8cea7a3cf..3459078393 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/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.g.dart b/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.g.dart index 25ff56afcb..9e5e9decd2 100644 --- a/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.g.dart +++ b/packages/cocoon_common/lib/src/rpc_model/presubmit_guard.g.dart @@ -59,5 +59,6 @@ const _$TaskStatusEnumMap = { TaskStatus.infraFailure: 'Infra Failure', TaskStatus.failed: 'Failed', TaskStatus.succeeded: 'Succeeded', + TaskStatus.neutral: 'Neutral', TaskStatus.skipped: 'Skipped', };