diff --git a/conductor/archive/refactor_presubmit_state_20260223/index.md b/conductor/archive/refactor_presubmit_state_20260223/index.md new file mode 100644 index 0000000000..1f3e7e86c8 --- /dev/null +++ b/conductor/archive/refactor_presubmit_state_20260223/index.md @@ -0,0 +1,5 @@ +# Track refactor_presubmit_state_20260223 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) diff --git a/conductor/archive/refactor_presubmit_state_20260223/metadata.json b/conductor/archive/refactor_presubmit_state_20260223/metadata.json new file mode 100644 index 0000000000..2ba51c6361 --- /dev/null +++ b/conductor/archive/refactor_presubmit_state_20260223/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "refactor_presubmit_state_20260223", + "type": "refactor", + "status": "new", + "created_at": "2026-02-23T10:00:00Z", + "updated_at": "2026-02-23T10:00:00Z", + "description": "For PreSubmitView move all state change logic to PresubmitState similarly to how BuildDashboardPage use BuildState" +} diff --git a/conductor/archive/refactor_presubmit_state_20260223/plan.md b/conductor/archive/refactor_presubmit_state_20260223/plan.md new file mode 100644 index 0000000000..96c21c5c6c --- /dev/null +++ b/conductor/archive/refactor_presubmit_state_20260223/plan.md @@ -0,0 +1,26 @@ +# Implementation Plan - Refactor PreSubmitView State Management + +## Phase 1: Foundation - Create PresubmitState [checkpoint: 584afaa] +This phase focuses on creating the new `PresubmitState` class and migrating the core data fetching logic. + +- [x] Task: Create `dashboard/lib/state/presubmit.dart` with `PresubmitState` class, including `repo`, `pr`, and `sha` properties. +- [x] Task: Implement initialization and update methods for `repo`, `pr`, and `sha` in `PresubmitState`. +- [x] Task: Implement `fetchAvailableShas` and `fetchGuardStatus` in `PresubmitState`. +- [x] Task: Write unit tests for `PresubmitState` in `dashboard/test/state/presubmit_test.dart`. +- [x] Task: Conductor - User Manual Verification 'Phase 1: Foundation' (Protocol in workflow.md) + +## Phase 2: Refactor PreSubmitView +This phase integrates `PresubmitState` into the main `PreSubmitView` widget and removes its local state. + +- [x] Task: Update `dashboard/lib/main.dart` or the relevant state provider to instantiate and provide `PresubmitState`. +- [~] Task: Refactor `_PreSubmitViewState` to use `PresubmitState` for context management (repo, pr, sha) and data fetching. +- [ ] Task: Update `dashboard/test/views/presubmit_view_test.dart` to ensure compatibility with the new state management. +- [ ] Task: Conductor - User Manual Verification 'Phase 2: Refactor PreSubmitView' (Protocol in workflow.md) + +## Phase 3: Refactor LogViewerPane and Finalize +This phase completes the migration by moving the check details logic and performing final verification. + +- [ ] Task: Implement `fetchCheckDetails` and associated state (`selectedCheck`, `checks`) in `PresubmitState`. +- [ ] Task: Refactor `_LogViewerPaneState` in `dashboard/lib/views/presubmit_view.dart` to use `PresubmitState`. +- [ ] Task: Final verification of `PreSubmitView` functionality and test coverage. +- [ ] Task: Conductor - User Manual Verification 'Phase 3: Refactor LogViewerPane and Finalize' (Protocol in workflow.md) diff --git a/conductor/archive/refactor_presubmit_state_20260223/spec.md b/conductor/archive/refactor_presubmit_state_20260223/spec.md new file mode 100644 index 0000000000..ad168023f6 --- /dev/null +++ b/conductor/archive/refactor_presubmit_state_20260223/spec.md @@ -0,0 +1,38 @@ +# Specification - Refactor PreSubmitView State Management + +## Overview +This track involves refactoring the `PreSubmitView` to move its state and logic into a dedicated `PresubmitState` class, following the pattern used by `BuildDashboardPage` and `BuildState`. This will improve code organization, testability, and consistency across the Cocoon dashboard. + +## Goals +- Extract all data fetching and processing logic from `PreSubmitView` into `PresubmitState`. +- Centralize state management for PR summaries, guard statuses, and check details. +- Align the architecture of `PreSubmitView` with the project's established patterns. + +## Functional Requirements +- **PresubmitState Class:** + - Inherit from `ChangeNotifier`. + - Hold context properties: `repo`, `pr`, and `sha`. + - Handle fetching available SHAs for a PR (`fetchAvailableShas`). + - Handle fetching guard status for a specific SHA (`fetchGuardStatus`). + - Handle fetching check details/logs (`fetchCheckDetails`). + - Expose state properties: `repo`, `pr`, `sha`, `guardResponse`, `isLoading`, `availableSummaries`, `selectedCheck`, `checks`. +- **PreSubmitView Integration:** + - Use `Provider.of` to access state and trigger actions. + - Remove local state variables and direct `CocoonService` calls from `_PreSubmitViewState`. +- **LogViewerPane Integration:** + - Use `PresubmitState` for fetching and displaying check details. + - Remove local state variables from `_LogViewerPaneState`. + +## Non-Functional Requirements +- **Consistency:** Follow the adaptive alignment with `BuildState`. +- **Testability:** The new `PresubmitState` should be easily testable in isolation. + +## Acceptance Criteria +- `PreSubmitView` functions identically to its current implementation from a user perspective. +- `PreSubmitView` and its sub-widgets do not hold local state for data fetched from the backend. +- Existing tests for `PreSubmitView` pass after the refactor. +- New unit tests for `PresubmitState` cover the migrated logic. + +## Out of Scope +- Changing the UI layout or design of `PreSubmitView`. +- Implementing new features or fixing unrelated bugs in `PreSubmitView`. diff --git a/conductor/tracks.md b/conductor/tracks.md index e84f939bb5..34a1619e79 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -15,3 +15,8 @@ - [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/main.dart b/dashboard/lib/main.dart index 7631259aed..3db5028389 100644 --- a/dashboard/lib/main.dart +++ b/dashboard/lib/main.dart @@ -14,6 +14,7 @@ import 'firebase_options.dart'; import 'service/cocoon.dart'; import 'service/firebase_auth.dart'; import 'state/build.dart'; +import 'state/presubmit.dart'; import 'views/build_dashboard_page.dart'; import 'views/presubmit_view.dart'; import 'views/tree_status_page.dart'; @@ -73,6 +74,10 @@ void main([List args = const []]) async { authService: authService, cocoonService: cocoonService, ), + presubmitState: PresubmitState( + authService: authService, + cocoonService: cocoonService, + ), child: Now(child: const MyApp()), ), ); diff --git a/dashboard/lib/service/dev_cocoon.dart b/dashboard/lib/service/dev_cocoon.dart index 6a7c62670d..abe056c10e 100644 --- a/dashboard/lib/service/dev_cocoon.dart +++ b/dashboard/lib/service/dev_cocoon.dart @@ -182,46 +182,120 @@ class DevelopmentCocoonService implements CocoonService { required String repo, required String sha, }) async { - // Extract a number from the SHA if it's a mock SHA to provide varied data. - // Format: ..._#_mock_sha - final parts = sha.split('_'); - final num = (sha.endsWith('_mock_sha') && parts.length > 2) - ? parts[parts.length - 3] - : '1'; - final prNum = int.tryParse(num) ?? 123; - - return CocoonResponse.data( - PresubmitGuardResponse( - prNum: prNum, - checkRunId: 456, - author: _authors[prNum % _authors.length], - guardStatus: switch (num) { - '1' => GuardStatus.succeeded, - '2' => GuardStatus.failed, - _ => GuardStatus.inProgress, - }, - stages: [ - PresubmitGuardStage( - name: 'Engine', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Mac mac_host_engine $num': TaskStatus.failed, - 'Mac mac_ios_engine $num': TaskStatus.waitingForBackfill, - 'Linux linux_android_aot_engine $num': TaskStatus.succeeded, - }, - ), - PresubmitGuardStage( - name: 'Framework', - createdAt: now.millisecondsSinceEpoch, - builds: { - 'Linux framework_tests $num': TaskStatus.inProgress, - 'Mac framework_tests $num': TaskStatus.cancelled, - 'Linux android framework_tests $num': TaskStatus.skipped, - 'Windows framework_tests $num': TaskStatus.infraFailure, - }, - ), - ], - ), + 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, ); } @@ -237,14 +311,21 @@ class DevelopmentCocoonService implements CocoonService { buildName: buildName, creationTime: now.millisecondsSinceEpoch - 10000, status: 'Succeeded', + buildNumber: 12345, summary: - '[INFO] Starting task $buildName...\n[SUCCESS] Dependencies installed.\n[INFO] Running build script...\n[SUCCESS] All tests passed (452/452)', + ''' +[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', ), @@ -259,9 +340,9 @@ class DevelopmentCocoonService implements CocoonService { }) async { return CocoonResponse.data([ PresubmitGuardSummary( - commitSha: 'decaf_1_mock_sha', + commitSha: 'decaf_3_mock_sha', creationTime: now.millisecondsSinceEpoch, - guardStatus: GuardStatus.succeeded, + guardStatus: GuardStatus.inProgress, ), PresubmitGuardSummary( commitSha: 'face5_2_mock_sha', @@ -269,9 +350,14 @@ class DevelopmentCocoonService implements CocoonService { guardStatus: GuardStatus.failed, ), PresubmitGuardSummary( - commitSha: 'cafe5_3_mock_sha', + commitSha: 'cafe5_1_mock_sha', creationTime: now.millisecondsSinceEpoch - 200000, - guardStatus: GuardStatus.inProgress, + guardStatus: GuardStatus.failed, + ), + PresubmitGuardSummary( + commitSha: 'deafcab_mock_sha', + creationTime: now.millisecondsSinceEpoch - 300000, + guardStatus: GuardStatus.succeeded, ), ]); } diff --git a/dashboard/lib/state/build.dart b/dashboard/lib/state/build.dart index be506454ae..5cf9e04e40 100644 --- a/dashboard/lib/state/build.dart +++ b/dashboard/lib/state/build.dart @@ -420,22 +420,6 @@ class BuildState extends ChangeNotifier { return true; } - /// Gets the presubmit guard summaries for a given [repo] and [pr]. - Future?> fetchPresubmitGuardSummaries({ - required String repo, - required String pr, - }) async { - final response = await cocoonService.fetchPresubmitGuardSummaries( - repo: repo, - pr: pr, - ); - if (response.error != null) { - _errors.send('Failed to fetch guard summaries: ${response.error}'); - return null; - } - return response.data; - } - /// Updates the suppression status of a test. /// /// Returns true if the update was successful. diff --git a/dashboard/lib/state/presubmit.dart b/dashboard/lib/state/presubmit.dart new file mode 100644 index 0000000000..6896a74634 --- /dev/null +++ b/dashboard/lib/state/presubmit.dart @@ -0,0 +1,325 @@ +// 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 'package:cocoon_common/guard_status.dart'; +import 'package:cocoon_common/rpc_model.dart'; +import 'package:flutter/foundation.dart'; + +import '../service/cocoon.dart'; +import '../service/firebase_auth.dart'; + +/// State for the PreSubmit View. +class PresubmitState extends ChangeNotifier { + PresubmitState({ + required this.cocoonService, + required this.authService, + this.repo = 'flutter', + this.pr, + this.sha, + }); + + /// Cocoon backend service that retrieves the data needed for this state. + final CocoonService cocoonService; + + /// Authentication service for managing Google Sign In. + final FirebaseAuthService authService; + + /// The current repo to show data from. + String repo; + + /// The current PR number. + String? pr; + + /// The current commit SHA. + 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; + + bool _isSummariesLoading = false; + bool _isGuardLoading = false; + bool _isChecksLoading = false; + + /// The available SHAs for the current [pr]. + List get availableSummaries => _availableSummaries; + List _availableSummaries = []; + + /// The currently selected check name. + String? get selectedCheck => _selectedCheck; + String? _selectedCheck; + + /// The checks/logs for the current [selectedCheck]. + List? get checks => _checks; + List? _checks; + + /// Track if we have already attempted to fetch summaries for the current [pr]. + String? _lastFetchedPr; + + /// Track if we have already attempted to fetch guard status for the current [sha]. + String? _lastFetchedSha; + + /// How often to query the Cocoon backend for updates. + @visibleForTesting + final Duration refreshRate = const Duration(seconds: 30); + + /// Timer that calls [_fetchRefreshUpdate] on a set interval. + @visibleForTesting + Timer? refreshTimer; + + bool _active = true; + + @override + void addListener(VoidCallback listener) { + if (!hasListeners) { + _startTimer(); + assert(refreshTimer != null); + } + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (!hasListeners) { + _stopTimer(); + } + } + + 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)); + } else { + refreshes.add(fetchRecentCommits(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. + void update({String? repo, String? pr, String? sha}) { + if (_syncParameters(repo: repo, pr: pr, sha: sha)) { + notifyListeners(); + } + fetchIfNeeded(); + } + + /// Synchronously update parameters without notifying. + /// + /// Returns true if anything changed. + bool _syncParameters({String? repo, String? pr, String? sha}) { + var changed = false; + if (repo != null && this.repo != repo) { + this.repo = repo; + changed = true; + _availableSummaries = []; + _lastFetchedPr = null; + } + if (pr != this.pr) { + this.pr = pr; + changed = true; + _availableSummaries = []; + _lastFetchedPr = null; + } + if (sha != this.sha) { + this.sha = sha; + changed = true; + _guardResponse = null; + _selectedCheck = null; + _checks = null; + _lastFetchedSha = null; + } + return changed; + } + + /// Synchronously update state without notifying. + void syncUpdate({String? repo, String? pr, String? sha}) { + _syncParameters(repo: repo, pr: pr, sha: sha); + } + + /// Select a check and fetch its details. + void selectCheck(String? name) { + if (_selectedCheck == name) { + return; + } + _selectedCheck = name; + _checks = null; + notifyListeners(); + if (_selectedCheck != null) { + fetchCheckDetails(); + } + } + + /// Trigger data fetching if parameters were updated but data is missing. + void fetchIfNeeded() { + if (isLoading) { + return; + } + if (pr != null) { + if (_availableSummaries.isEmpty && _lastFetchedPr != pr) { + fetchAvailableShas(); + } + } else { + if (_availableSummaries.isEmpty && _lastFetchedPr != 'NO_PR') { + fetchRecentCommits(); + } + } + + if (sha != null && _guardResponse == null && _lastFetchedSha != sha) { + fetchGuardStatus(); + } + } + + /// Request the latest available SHAs for the current [pr] from [CocoonService]. + Future fetchAvailableShas({bool refresh = false}) async { + if (pr == null || _isSummariesLoading) { + return; + } + _isSummariesLoading = true; + _lastFetchedPr = pr; + notifyListeners(); + + final response = await cocoonService.fetchPresubmitGuardSummaries( + repo: repo, + pr: pr!, + ); + + if (response.error != null) { + // TODO: Handle error + } else { + _availableSummaries = response.data!; + // If no SHA was specified, default to the latest one + if (sha == null && _availableSummaries.isNotEmpty) { + sha = _availableSummaries.first.commitSha; + } + } + _isSummariesLoading = false; + notifyListeners(); + fetchIfNeeded(); // Proceed to fetch guard status for the new SHA + } + + /// Request recent commits for the current [repo] from [CocoonService]. + Future fetchRecentCommits({bool refresh = false}) async { + if (_isSummariesLoading) { + return; + } + _isSummariesLoading = true; + _lastFetchedPr = 'NO_PR'; // Special value for "no PR" + if (!refresh) { + _availableSummaries = []; + } + notifyListeners(); + + final response = await cocoonService.fetchCommitStatuses(repo: repo); + + if (response.error != null) { + // TODO: Handle error + } else { + _availableSummaries = response.data!.map((s) { + return PresubmitGuardSummary( + commitSha: s.commit.sha, + creationTime: s.commit.timestamp.toInt(), + guardStatus: GuardStatus.waitingForBackfill, + ); + }).toList(); + } + _isSummariesLoading = false; + notifyListeners(); + } + + /// Request the guard status for the current [sha] from [CocoonService]. + Future fetchGuardStatus({bool refresh = false}) async { + if (sha == null || _isGuardLoading) { + return; + } + _isGuardLoading = true; + _lastFetchedSha = sha; + if (!refresh) { + _guardResponse = null; + } + notifyListeners(); + + final response = await cocoonService.fetchPresubmitGuard( + repo: repo, + sha: sha!, + ); + + if (response.error != null) { + // TODO: Handle error + } else { + _guardResponse = response.data; + } + _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; + } + + _isChecksLoading = true; + if (!refresh) { + _checks = null; + } + notifyListeners(); + + final response = await cocoonService.fetchPresubmitCheckDetails( + checkRunId: guardResponse!.checkRunId, + buildName: selectedCheck!, + ); + + if (response.error != null) { + // TODO: Handle error + } else { + _checks = response.data; + } + _isChecksLoading = false; + notifyListeners(); + } + + @override + void dispose() { + _active = false; + refreshTimer?.cancel(); + super.dispose(); + } +} diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart index c6455c36da..a838712b32 100644 --- a/dashboard/lib/views/presubmit_view.dart +++ b/dashboard/lib/views/presubmit_view.dart @@ -4,14 +4,17 @@ import 'dart:async'; +import 'package:cocoon_common/build_log_url.dart'; 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/services.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../dashboard_navigation_drawer.dart'; -import '../state/build.dart'; +import '../state/presubmit.dart'; import '../widgets/app_bar.dart'; import '../widgets/guard_status.dart' as pw; import '../widgets/sha_selector.dart'; @@ -21,497 +24,447 @@ import '../widgets/task_box.dart'; /// /// This view displays CI check statuses and execution logs. final class PreSubmitView extends StatefulWidget { - const PreSubmitView({super.key, this.queryParameters}); + const PreSubmitView({ + super.key, + this.queryParameters, + this.syncNavigation = true, + }); static const String routeSegment = 'presubmit'; static const String routeName = '/$routeSegment'; final Map? queryParameters; + final bool syncNavigation; @override State createState() => _PreSubmitViewState(); } class _PreSubmitViewState extends State { - late String repo; - String? sha; - String? pr; - PresubmitGuardResponse? _guardResponse; - bool _isLoading = false; - String? _selectedCheck; - List _availableSummaries = []; + PresubmitState? _presubmitState; @override - void initState() { - super.initState(); - final params = widget.queryParameters ?? {}; - repo = params['repo'] ?? 'flutter'; - sha = params['sha']; - pr = params['pr']; + void didChangeDependencies() { + super.didChangeDependencies(); + final newState = Provider.of(context); + if (_presubmitState != newState) { + _presubmitState?.removeListener(_onStateChanged); + _presubmitState = newState; + _presubmitState?.addListener(_onStateChanged); + } + _triggerUpdate(); } @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (pr != null && _availableSummaries.isEmpty && !_isLoading) { - unawaited(_fetchAvailableShas()); - } else if (sha != null && _guardResponse == null && !_isLoading) { - unawaited(_fetchGuardStatus()); + void dispose() { + _presubmitState?.removeListener(_onStateChanged); + super.dispose(); + } + + @override + void didUpdateWidget(PreSubmitView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.queryParameters != oldWidget.queryParameters) { + _triggerUpdate(); } } - Future _fetchAvailableShas() async { - setState(() { - _isLoading = true; - }); - final buildState = Provider.of(context, listen: false); - final summaries = await buildState.fetchPresubmitGuardSummaries( - repo: repo, - pr: pr!, - ); - if (mounted && summaries != null) { - setState(() { - _availableSummaries = summaries; - // If no SHA was specified, default to the latest one - if (sha == null && _availableSummaries.isNotEmpty) { - sha = _availableSummaries.first.commitSha; - } - _isLoading = false; - }); - if (sha != null) { - unawaited(_fetchGuardStatus()); - } - } else if (mounted) { - setState(() { - _isLoading = false; - }); + void _onStateChanged() { + if (!mounted || !widget.syncNavigation) return; + final state = _presubmitState!; + final params = widget.queryParameters ?? {}; + + // If the state has a SHA that is different from the URL, update the URL. + if (state.sha != null && state.sha != params['sha']) { + _updateNavigation(); } } - Future _fetchGuardStatus() async { - setState(() { - _isLoading = true; - _selectedCheck = null; - }); - final buildState = Provider.of(context, listen: false); + void _updateNavigation() { + final state = _presubmitState!; + final params = Map.from(widget.queryParameters ?? {}); + params['repo'] = state.repo; + if (state.pr != null) params['pr'] = state.pr!; + if (state.sha != null) params['sha'] = state.sha!; - // For mock SHAs, we don't need a real API call if we're in Development mode, - // but PreSubmitView currently unifies everything through cocoonService. - // If cocoonService is DevelopmentCocoonService, it will return mock data. + final uri = Uri(path: PreSubmitView.routeName, queryParameters: params); - final response = await buildState.cocoonService.fetchPresubmitGuard( - repo: repo, - sha: sha!, - ); - if (mounted) { - setState(() { - _guardResponse = response.data; - _isLoading = false; - }); - } + // Update the URL without triggering a full navigation/rebuild cycle. + SystemNavigator.routeInformationUpdated(uri: uri, replace: true); + } + + void _triggerUpdate() { + final params = widget.queryParameters ?? {}; + final repo = params['repo'] ?? 'flutter'; + final sha = params['sha']; + final pr = params['pr']; + + final state = Provider.of(context, listen: false); + state.syncUpdate(repo: repo, pr: pr, sha: sha); + + // Schedule fetch outside of build phase to avoid notify-during-build + Future.microtask(() { + if (!mounted) return; + state.fetchIfNeeded(); + }); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - final buildState = Provider.of(context); - - var availableSummaries = pr != null - ? _availableSummaries - : buildState.statuses.map((s) { - return PresubmitGuardSummary( - commitSha: s.commit.sha, - creationTime: s.commit.timestamp.toInt(), - guardStatus: GuardStatus - .waitingForBackfill, // Commits don't have guard status in status view - ); - }).toList(); - - // Ensure current sha is in the list - if (sha != null && !availableSummaries.any((s) => s.commitSha == sha)) { - availableSummaries = [ - PresubmitGuardSummary( - commitSha: sha!, - creationTime: 0, - guardStatus: GuardStatus.waitingForBackfill, - ), - ...availableSummaries, - ]; - } + final presubmitState = Provider.of(context); + + return AnimatedBuilder( + animation: presubmitState, + builder: (context, _) { + final params = widget.queryParameters ?? {}; + final pr = presubmitState.pr ?? params['pr']; + final sha = presubmitState.sha ?? params['sha']; + final repo = presubmitState.repo; + + final guardResponse = presubmitState.guardResponse; + final isLoading = presubmitState.isLoading; + final selectedCheck = presubmitState.selectedCheck; + + var availableSummaries = presubmitState.availableSummaries; + + if (sha != null && !availableSummaries.any((s) => s.commitSha == sha)) { + availableSummaries = [ + PresubmitGuardSummary( + commitSha: sha, + creationTime: 0, + guardStatus: GuardStatus.waitingForBackfill, + ), + ...availableSummaries, + ]; + } - final shortSha = (sha != null && sha!.length > 7) - ? sha!.substring(0, 7) - : sha; - final title = _guardResponse != null - ? 'PR #${_guardResponse!.prNum} by ${_guardResponse!.author} ($shortSha)' - : (pr != null ? 'PR #$pr' : (sha != null ? '($shortSha)' : '')); - - // Use the guard status from the summary if available, otherwise fallback to "Pending" or "Loading..." - var statusText = (pr != null ? 'Pending' : 'Loading...'); - if (_guardResponse != null) { - statusText = _guardResponse!.guardStatus.value; - } else if (sha != null) { - final summary = _availableSummaries.firstWhere( - (s) => s.commitSha == sha, - orElse: () => const PresubmitGuardSummary( - commitSha: '', - creationTime: 0, - guardStatus: GuardStatus.waitingForBackfill, - ), - ); - if (summary.commitSha.isNotEmpty) { - statusText = summary.guardStatus.value; - } - } + final shortSha = (sha != null && sha.length > 7) + ? sha.substring(0, 7) + : sha; + final title = guardResponse != null + ? 'PR #${guardResponse.prNum} by ${guardResponse.author} ($shortSha)' + : (pr != null ? 'PR #$pr' : (sha != null ? '($shortSha)' : '')); + + var statusText = (pr != null ? 'Pending' : 'Loading...'); + if (guardResponse != null) { + statusText = guardResponse.guardStatus.value; + } else if (sha != null) { + final summary = presubmitState.availableSummaries.firstWhere( + (s) => s.commitSha == sha, + orElse: () => const PresubmitGuardSummary( + commitSha: '', + creationTime: 0, + guardStatus: GuardStatus.waitingForBackfill, + ), + ); + if (summary.commitSha.isNotEmpty) { + statusText = summary.guardStatus.value; + } + } - final isLatestSha = - pr != null && - _availableSummaries.isNotEmpty && - sha == _availableSummaries.first.commitSha; + final isLatestSha = + pr != null && + presubmitState.availableSummaries.isNotEmpty && + sha == presubmitState.availableSummaries.first.commitSha; - return Scaffold( - appBar: CocoonAppBar( - title: Row( - children: [ - Flexible( - child: SelectionArea( - child: Text( - title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, + return Scaffold( + appBar: CocoonAppBar( + title: Row( + children: [ + Flexible( + child: SelectionArea( + child: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + overflow: TextOverflow.ellipsis, + ), ), - overflow: TextOverflow.ellipsis, ), - ), + const SizedBox(width: 16), + pw.GuardStatus(status: statusText), + ], ), - const SizedBox(width: 16), - pw.GuardStatus(status: statusText), - ], - ), - actions: [ - if (isLatestSha) ...[ - TextButton.icon( - onPressed: () {}, - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Re-run failed'), - style: TextButton.styleFrom( - foregroundColor: isDark ? Colors.white : Colors.black87, + actions: [ + if (isLatestSha) ...[ + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Re-run failed'), + style: TextButton.styleFrom( + foregroundColor: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(width: 8), + ], + SizedBox( + width: 300, + child: ShaSelector( + availableShas: availableSummaries, + selectedSha: sha, + onShaSelected: (newSha) { + presubmitState.update(repo: repo, pr: pr, sha: newSha); + }, + ), ), - ), - const SizedBox(width: 8), - ], - SizedBox( - width: 300, - child: ShaSelector( - availableShas: availableSummaries, - selectedSha: sha, - onShaSelected: (newSha) { - setState(() { - sha = newSha; - _guardResponse = null; - }); - unawaited(_fetchGuardStatus()); - }, - ), + const SizedBox(width: 8), + ], ), - const SizedBox(width: 8), - ], - ), - drawer: const DashboardNavigationDrawer(), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - const Divider(height: 1, thickness: 1), - Expanded( - child: SelectionArea( - child: Row( - children: [ - if (_guardResponse != null) - _ChecksSidebar( - guardResponse: _guardResponse!, - selectedCheck: _selectedCheck, - isLatestSha: isLatestSha, - onCheckSelected: (name) { - setState(() { - _selectedCheck = name; - }); - }, - ), - const VerticalDivider(width: 1, thickness: 1), - Expanded( - child: _selectedCheck == null - ? const Center( - child: Text('Select a check to view logs'), - ) - : _LogViewerPane( - repo: repo, - checkRunId: _guardResponse!.checkRunId, - buildName: _selectedCheck!, - isMocked: sha!.startsWith('mock_sha_'), - ), + drawer: const DashboardNavigationDrawer(), + body: isLoading && guardResponse == null + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + const Divider(height: 1, thickness: 1), + Expanded( + child: SelectionArea( + child: Row( + children: [ + if (guardResponse != null) + _ChecksSidebar( + guardResponse: guardResponse, + selectedCheck: selectedCheck, + isLatestSha: isLatestSha, + onCheckSelected: presubmitState.selectCheck, + ), + const VerticalDivider(width: 1, thickness: 1), + Expanded( + child: + (selectedCheck == null || + guardResponse == null) + ? const Center( + child: Text( + 'Select a check to view logs', + ), + ) + : const _LogViewerPane(), + ), + ], ), - ], + ), ), - ), + ], ), - ], - ), + ); + }, ); } } class _LogViewerPane extends StatefulWidget { - const _LogViewerPane({ - required this.repo, - required this.checkRunId, - required this.buildName, - this.isMocked = false, - }); - - final String repo; - final int checkRunId; - final String buildName; - final bool isMocked; + const _LogViewerPane(); @override State<_LogViewerPane> createState() => _LogViewerPaneState(); } class _LogViewerPaneState extends State<_LogViewerPane> { - List? _checks; - bool _isLoading = false; int _selectedAttemptIndex = 0; - @override - void initState() { - super.initState(); - _fetchCheckDetails(); - } - - @override - void didUpdateWidget(_LogViewerPane oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.buildName != widget.buildName || - oldWidget.checkRunId != widget.checkRunId) { - _selectedAttemptIndex = 0; - _fetchCheckDetails(); - } - } - - Future _fetchCheckDetails() async { - if (widget.isMocked) { - setState(() { - _checks = [ - PresubmitCheckResponse( - attemptNumber: 1, - buildName: widget.buildName, - creationTime: 0, - status: 'Succeeded', - summary: - '[INFO] Starting task ${widget.buildName}...\n[SUCCESS] Dependencies installed.\n[INFO] Running build script...\n[SUCCESS] All tests passed (452/452)', - ), - PresubmitCheckResponse( - attemptNumber: 2, - buildName: widget.buildName, - creationTime: 0, - status: 'Failed', - summary: - '[INFO] Starting task ${widget.buildName}...\n[ERROR] Test failed: Unit Tests', - ), - ]; - }); - return; - } - - setState(() { - _isLoading = true; - }); - final buildState = Provider.of(context, listen: false); - final response = await buildState.cocoonService.fetchPresubmitCheckDetails( - checkRunId: widget.checkRunId, - buildName: widget.buildName, - ); - if (mounted) { - setState(() { - _checks = response.data; - _isLoading = false; - }); - } - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - final borderColor = isDark - ? const Color(0xFF333333) - : const Color(0xFFD1D5DB); + final presubmitState = Provider.of(context); + + return AnimatedBuilder( + animation: presubmitState, + builder: (context, _) { + final repo = presubmitState.repo; + final buildName = presubmitState.selectedCheck; + final checks = presubmitState.checks; + final isLoading = presubmitState.isLoading; + + final borderColor = isDark + ? const Color(0xFF333333) + : const Color(0xFFD1D5DB); + + if (isLoading && (checks == null || checks.isEmpty)) { + return const Center(child: CircularProgressIndicator()); + } - if (_isLoading) { - return const Center(child: CircularProgressIndicator()); - } + if (checks == null || checks.isEmpty) { + return const Center(child: Text('No details found for this check')); + } - if (_checks == null || _checks!.isEmpty) { - return const Center(child: Text('No details found for this check')); - } + if (_selectedAttemptIndex >= checks.length) { + _selectedAttemptIndex = 0; + } - final selectedCheck = - _checks![_selectedAttemptIndex < _checks!.length - ? _selectedAttemptIndex - : 0]; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${widget.repo} / ${widget.buildName}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - ), + final selectedCheck = checks[_selectedAttemptIndex]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$repo / $buildName', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'Status: ${selectedCheck.status}', + style: TextStyle( + fontSize: 14, + color: isDark + ? const Color(0xFF8B949E) + : const Color(0xFF6B7280), + ), + ), + ], ), - const SizedBox(height: 4), - Text( - 'Status: ${selectedCheck.status}', - style: TextStyle( - fontSize: 14, - color: isDark - ? const Color(0xFF8B949E) - : const Color(0xFF6B7280), - ), + ), + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + border: Border(bottom: BorderSide(color: borderColor)), ), - ], - ), - ), - Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 24), - decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor, - border: Border(bottom: BorderSide(color: borderColor)), - ), - child: Row( - children: [ - ..._checks!.asMap().entries.map((entry) { - final index = entry.key; - final check = entry.value; - final isSelected = _selectedAttemptIndex == index; - return InkWell( - onTap: () => setState(() => _selectedAttemptIndex = index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - alignment: Alignment.center, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isSelected - ? const Color(0xFF3B82F6) - : Colors.transparent, - width: 2, + child: Row( + children: [ + ...checks.asMap().entries.map((entry) { + final index = entry.key; + final check = entry.value; + final isSelected = _selectedAttemptIndex == index; + return InkWell( + onTap: () => + setState(() => _selectedAttemptIndex = index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected + ? const Color(0xFF3B82F6) + : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + '#${check.attemptNumber}', + style: TextStyle( + fontSize: 13, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? (isDark ? Colors.white : Colors.black) + : (isDark + ? const Color(0xFF8B949E) + : const Color(0xFF6B7280)), + ), ), ), - ), - child: Text( - '#${check.attemptNumber}', - style: TextStyle( - fontSize: 13, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - color: isSelected - ? (isDark ? Colors.white : Colors.black) - : (isDark - ? const Color(0xFF8B949E) - : const Color(0xFF6B7280)), - ), + ); + }), + const Spacer(), + const Text( + 'BUILD HISTORY', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.grey, + letterSpacing: 1.0, ), ), - ); - }), - const Spacer(), - const Text( - 'BUILD HISTORY', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.grey, - letterSpacing: 1.0, - ), - ), - ], - ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Row( - children: [ - Text( - 'Execution Log', - style: TextStyle(fontWeight: FontWeight.w600), + ], ), - Spacer(), - Text( - 'Raw output', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - ], - ), - ), - Expanded( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 24), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor, - border: Border.all(color: borderColor), - borderRadius: BorderRadius.circular(6), ), - width: double.infinity, - child: SingleChildScrollView( - child: Text( - selectedCheck.summary ?? 'No log summary available', - style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + children: [ + Text( + 'Execution Log', + style: TextStyle(fontWeight: FontWeight.w600), + ), + Spacer(), + Text( + 'Raw output', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], ), ), - ), - ), - Padding( - padding: const EdgeInsets.all(24.0), - child: InkWell( - onTap: () {}, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.open_in_new, - size: 18, - color: isDark - ? const Color(0xFF58A6FF) - : const Color(0xFF0969DA), + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(6), ), - const SizedBox(width: 8), - Text( - 'View more details on LUCI UI', - style: TextStyle( - color: isDark - ? const Color(0xFF58A6FF) - : const Color(0xFF0969DA), - fontSize: 14, + width: double.infinity, + child: SingleChildScrollView( + child: Text( + selectedCheck.summary ?? 'No log summary available', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + ), ), ), - ], + ), ), - ), - ), - ], + Padding( + padding: const EdgeInsets.all(24.0), + child: InkWell( + onTap: selectedCheck.buildNumber == null + ? null + : () => launchUrl( + Uri.parse( + generateBuildLogUrl( + buildName: selectedCheck.buildName, + buildNumber: selectedCheck.buildNumber!, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_new, + size: 18, + color: selectedCheck.buildNumber == null + ? Colors.grey + : (isDark + ? const Color(0xFF58A6FF) + : const Color(0xFF0969DA)), + ), + const SizedBox(width: 8), + Text( + 'View more details on LUCI UI', + style: TextStyle( + color: selectedCheck.buildNumber == null + ? Colors.grey + : (isDark + ? const Color(0xFF58A6FF) + : const Color(0xFF0969DA)), + fontSize: 14, + ), + ), + ], + ), + ), + ), + ], + ); + }, ); } } @@ -626,26 +579,21 @@ class _CheckItem extends StatelessWidget { return InkWell( onTap: onTap, - child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( color: isSelected ? (isDark ? Colors.white.withValues(alpha: 0.05) : Colors.black.withValues(alpha: 0.05)) : Colors.transparent, - border: Border( left: BorderSide( color: isSelected ? const Color(0xFF3B82F6) : Colors.transparent, - width: 2, ), ), ), - child: Row( children: [ _getStatusIcon(status), diff --git a/dashboard/lib/widgets/luci_task_attempt_summary.dart b/dashboard/lib/widgets/luci_task_attempt_summary.dart index 4328769b6f..d0374432a4 100644 --- a/dashboard/lib/widgets/luci_task_attempt_summary.dart +++ b/dashboard/lib/widgets/luci_task_attempt_summary.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 'package:cocoon_common/is_dart_internal.dart'; +import 'package:cocoon_common/build_log_url.dart'; import 'package:cocoon_common/rpc_model.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -17,42 +17,23 @@ class LuciTaskAttemptSummary extends StatelessWidget { /// The task to show information from. final Task task; - @visibleForTesting - static const String luciProdLogBase = - 'https://ci.chromium.org/p/flutter/builders'; - - @visibleForTesting - static const String dartInternalLogBase = - 'https://ci.chromium.org/p/dart-internal/builders'; - @override Widget build(BuildContext context) { return ListBody( children: List.generate(task.buildNumberList.length, (int i) { + final buildNumber = task.buildNumberList[i]; return ElevatedButton( - child: Text('OPEN LOG FOR BUILD #${task.buildNumberList[i]}'), + child: Text('OPEN LOG FOR BUILD #$buildNumber'), onPressed: () async { - if (isTaskFromDartInternalBuilder(builderName: task.builderName)) { - await launchUrl( - _dartInternalLogUrl(task.builderName, task.buildNumberList[i]), - ); - } else { - await launchUrl( - _luciProdLogUrl(task.builderName, task.buildNumberList[i]), - ); - } + final url = generateBuildLogUrl( + buildName: task.builderName, + buildNumber: buildNumber, + isBringup: task.isBringup, + ); + await launchUrl(Uri.parse(url)); }, ); }), ); } - - Uri _luciProdLogUrl(String builderName, int buildNumber) { - final pool = task.isBringup ? 'staging' : 'prod'; - return Uri.parse('$luciProdLogBase/$pool/$builderName/$buildNumber'); - } - - Uri _dartInternalLogUrl(String builderName, int buildNumber) { - return Uri.parse('$dartInternalLogBase/flutter/$builderName/$buildNumber'); - } } diff --git a/dashboard/lib/widgets/state_provider.dart b/dashboard/lib/widgets/state_provider.dart index 14a57560ab..57ed1bb1f3 100644 --- a/dashboard/lib/widgets/state_provider.dart +++ b/dashboard/lib/widgets/state_provider.dart @@ -7,12 +7,14 @@ import 'package:provider/provider.dart'; import '../service/firebase_auth.dart'; import '../state/build.dart'; +import '../state/presubmit.dart'; class StateProvider extends StatelessWidget { const StateProvider({ super.key, this.signInService, this.buildState, + this.presubmitState, this.child, }); @@ -20,6 +22,8 @@ class StateProvider extends StatelessWidget { final BuildState? buildState; + final PresubmitState? presubmitState; + final Widget? child; @override @@ -28,6 +32,7 @@ class StateProvider extends StatelessWidget { providers: >[ ValueProvider(value: signInService), ValueProvider(value: buildState), + ValueProvider(value: presubmitState), ], child: child, ); diff --git a/dashboard/test/state/presubmit_test.dart b/dashboard/test/state/presubmit_test.dart new file mode 100644 index 0000000000..61f2422d5f --- /dev/null +++ b/dashboard/test/state/presubmit_test.dart @@ -0,0 +1,221 @@ +// 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: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.dart'; + +void main() { + late PresubmitState presubmitState; + late MockCocoonService mockCocoonService; + late MockFirebaseAuthService mockAuthService; + + setUp(() { + mockCocoonService = MockCocoonService(); + mockAuthService = MockFirebaseAuthService(); + presubmitState = PresubmitState( + cocoonService: mockCocoonService, + authService: mockAuthService, + ); + + // Default stubs to avoid MissingStubError during auto-fetches + when( + mockCocoonService.fetchPresubmitGuardSummaries( + repo: anyNamed('repo'), + pr: anyNamed('pr'), + ), + ).thenAnswer( + (_) async => const CocoonResponse>.data([]), + ); + when( + mockCocoonService.fetchPresubmitGuard( + repo: anyNamed('repo'), + sha: anyNamed('sha'), + ), + ).thenAnswer( + (_) async => const CocoonResponse.data(null), + ); + when( + mockCocoonService.fetchCommitStatuses( + repo: anyNamed('repo'), + branch: anyNamed('branch'), + ), + ).thenAnswer( + (_) async => const CocoonResponse>.data([]), + ); + }); + + test('PresubmitState initializes with default values', () { + expect(presubmitState.repo, 'flutter'); + expect(presubmitState.pr, isNull); + expect(presubmitState.sha, isNull); + expect(presubmitState.isLoading, isFalse); + expect(presubmitState.guardResponse, isNull); + expect(presubmitState.availableSummaries, isEmpty); + }); + + test( + 'PresubmitState update method updates properties and notifies listeners', + () { + var notified = false; + presubmitState.addListener(() => notified = true); + + presubmitState.update(repo: 'cocoon', pr: '123', sha: 'abc'); + + expect(presubmitState.repo, 'cocoon'); + expect(presubmitState.pr, '123'); + expect(presubmitState.sha, 'abc'); + expect(notified, isTrue); + }, + ); + + test( + 'PresubmitState fetchAvailableShas updates availableSummaries and notifies listeners', + () async { + const summaries = [ + PresubmitGuardSummary( + commitSha: 'sha1', + creationTime: 123, + guardStatus: GuardStatus.succeeded, + ), + ]; + when( + mockCocoonService.fetchPresubmitGuardSummaries( + repo: 'flutter', + pr: '123', + ), + ).thenAnswer( + (_) async => + const CocoonResponse>.data(summaries), + ); + + presubmitState.pr = '123'; + var notified = false; + presubmitState.addListener(() => notified = true); + + await presubmitState.fetchAvailableShas(); + + expect(presubmitState.availableSummaries, summaries); + expect(notified, isTrue); + }, + ); + + test( + 'PresubmitState fetchGuardStatus updates guardResponse and notifies listeners', + () async { + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [], + ); + when( + mockCocoonService.fetchPresubmitGuard(repo: 'flutter', sha: 'sha1'), + ).thenAnswer( + (_) async => + const CocoonResponse.data(guardResponse), + ); + + presubmitState.sha = 'sha1'; + var notified = false; + presubmitState.addListener(() => notified = true); + + await presubmitState.fetchGuardStatus(); + + expect(presubmitState.guardResponse, guardResponse); + expect(presubmitState.isLoading, isFalse); + expect(notified, isTrue); + }, + ); + + 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'); + + var notified = false; + presubmitState.addListener(() => notified = true); + + presubmitState.update(repo: 'flutter', pr: '123', sha: 'abc'); + + expect(notified, isFalse); + }, + ); + + test( + 'PresubmitState fetchAvailableShas returns early if pr is null', + () async { + await presubmitState.fetchAvailableShas(); + verifyNever( + mockCocoonService.fetchPresubmitGuardSummaries( + repo: anyNamed('repo'), + pr: anyNamed('pr'), + ), + ); + }, + ); + + test( + 'PresubmitState fetchAvailableShas defaults sha to latest if sha is null', + () async { + const summaries = [ + PresubmitGuardSummary( + commitSha: 'sha1', + creationTime: 123, + guardStatus: GuardStatus.succeeded, + ), + ]; + when( + mockCocoonService.fetchPresubmitGuardSummaries( + repo: 'flutter', + pr: '123', + ), + ).thenAnswer( + (_) async => + const CocoonResponse>.data(summaries), + ); + + presubmitState.pr = '123'; + presubmitState.sha = null; + + await presubmitState.fetchAvailableShas(); + + expect(presubmitState.sha, 'sha1'); + }, + ); + + test( + 'PresubmitState fetchGuardStatus returns early if sha is null', + () async { + await presubmitState.fetchGuardStatus(); + verifyNever( + mockCocoonService.fetchPresubmitGuard( + repo: anyNamed('repo'), + sha: anyNamed('sha'), + ), + ); + }, + ); + + test('PresubmitState refresh timer management', () { + expect(presubmitState.refreshTimer, isNull); + + void listener() {} + presubmitState.addListener(listener); + expect(presubmitState.refreshTimer, isNotNull); + + presubmitState.removeListener(listener); + expect(presubmitState.refreshTimer, isNull); + }); +} diff --git a/dashboard/test/utils/fake_build.dart b/dashboard/test/utils/fake_build.dart index d7f9602df6..6247e42587 100644 --- a/dashboard/test/utils/fake_build.dart +++ b/dashboard/test/utils/fake_build.dart @@ -133,12 +133,4 @@ class FakeBuildState extends ChangeNotifier implements BuildState { )); return updateTestSuppressionResult; } - - @override - Future?> fetchPresubmitGuardSummaries({ - required String repo, - required String pr, - }) async { - return null; - } } diff --git a/dashboard/test/utils/mocks.mocks.dart b/dashboard/test/utils/mocks.mocks.dart index c0e7733890..543395673c 100644 --- a/dashboard/test/utils/mocks.mocks.dart +++ b/dashboard/test/utils/mocks.mocks.dart @@ -825,20 +825,6 @@ class MockBuildState extends _i1.Mock implements _i14.BuildState { ) as _i8.Future); - @override - _i8.Future?> fetchPresubmitGuardSummaries({ - required String? repo, - required String? pr, - }) => - (super.noSuchMethod( - Invocation.method(#fetchPresubmitGuardSummaries, [], { - #repo: repo, - #pr: pr, - }), - returnValue: _i8.Future?>.value(), - ) - as _i8.Future?>); - @override _i8.Future updateTestSuppression({ required String? testName, diff --git a/dashboard/test/views/presubmit_view_test.dart b/dashboard/test/views/presubmit_view_test.dart index 152b0925e6..e773019481 100644 --- a/dashboard/test/views/presubmit_view_test.dart +++ b/dashboard/test/views/presubmit_view_test.dart @@ -9,11 +9,13 @@ 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/sha_selector.dart'; import 'package:flutter_dashboard/widgets/state_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; import '../utils/fake_flutter_app_icons.dart'; import '../utils/mocks.dart'; @@ -22,6 +24,7 @@ void main() { late MockCocoonService mockCocoonService; late MockFirebaseAuthService mockAuthService; late BuildState buildState; + late PresubmitState presubmitState; setUp(() { mockCocoonService = MockCocoonService(); @@ -66,7 +69,7 @@ void main() { ).thenAnswer( (_) async => const CocoonResponse.data([ PresubmitGuardSummary( - commitSha: 'decaf_1_mock_sha', + commitSha: 'decaf_3_real_sha', creationTime: 123456789, guardStatus: GuardStatus.succeeded, ), @@ -76,7 +79,7 @@ void main() { guardStatus: GuardStatus.failed, ), PresubmitGuardSummary( - commitSha: 'cafe5_3_mock_sha', + commitSha: 'cafe5_1_mock_sha', creationTime: 123456789, guardStatus: GuardStatus.inProgress, ), @@ -96,15 +99,29 @@ void main() { 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), + home: PreSubmitView( + queryParameters: queryParameters, + syncNavigation: false, + ), ), ), ); @@ -120,32 +137,30 @@ void main() { const guardResponse = PresubmitGuardResponse( prNum: 123, - checkRunId: 456, author: 'dash', - stages: [ - PresubmitGuardStage( - name: 'Engine', - createdAt: 0, - builds: {'Mac mac_host_engine': TaskStatus.succeeded}, - ), - ], guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [], ); when( mockCocoonService.fetchPresubmitGuard(repo: 'flutter', sha: 'abc'), ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); - await tester.pumpWidget( - createPreSubmitView({'repo': 'flutter', 'sha': 'abc'}), - ); - await tester.pump(); - await tester.pump(); + 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.textContaining('by dash').evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); expect(find.textContaining('PR #123 by dash (abc)'), findsOneWidget); - expect(find.text('Succeeded'), findsAtLeastNWidgets(1)); - expect(find.text('ENGINE'), findsOneWidget); - expect(find.textContaining('mac_host_engine'), findsOneWidget); + expect(find.textContaining('Succeeded'), findsOneWidget); }, ); @@ -157,11 +172,12 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - const mockSha = 'decaf_1_mock_sha'; + const mockSha = 'decaf_3_real_sha'; const guardResponse = PresubmitGuardResponse( prNum: 123, - checkRunId: 456, author: 'dash', + guardStatus: GuardStatus.failed, + checkRunId: 456, stages: [ PresubmitGuardStage( name: 'Engine', @@ -169,26 +185,15 @@ void main() { builds: {'Mac mac_host_engine 1': TaskStatus.failed}, ), ], - guardStatus: GuardStatus.inProgress, ); when( - mockCocoonService.fetchPresubmitGuard(repo: 'flutter', sha: mockSha), + mockCocoonService.fetchPresubmitGuard( + repo: anyNamed('repo'), + sha: mockSha, + ), ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); - await tester.pumpWidget( - createPreSubmitView({'repo': 'flutter', 'pr': '123'}), - ); - await tester.pump(); - await tester.pump(); - - expect(find.textContaining('PR #123'), findsOneWidget); - - // Select a check - // The check name in mock data is 'Mac mac_host_engine 1' (suffix is from decaf_1) - final checkName = 'mac_host_engine 1'; - - // Stub the details fetch for the mock check when( mockCocoonService.fetchPresubmitCheckDetails( checkRunId: anyNamed('checkRunId'), @@ -198,14 +203,14 @@ void main() { (_) async => CocoonResponse.data([ PresubmitCheckResponse( attemptNumber: 1, - buildName: checkName, + buildName: 'Mac mac_host_engine 1', creationTime: 0, status: 'Succeeded', summary: 'All tests passed (452/452)', ), PresubmitCheckResponse( attemptNumber: 2, - buildName: checkName, + buildName: 'Mac mac_host_engine 1', creationTime: 0, status: 'Failed', summary: 'Test failed: Unit Tests', @@ -213,26 +218,99 @@ void main() { ]), ); - await tester.tap(find.textContaining(checkName).first); - await tester.pump(); - await tester.pump(); + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'pr': '123'}), + ); + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('by dash').evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); + + expect(find.textContaining('PR #123'), findsOneWidget); + + await tester.tap(find.textContaining('mac_host_engine').first); + await tester.runAsync(() async { + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('All tests passed').evaluate().isNotEmpty) { + break; + } + } + }); + await tester.pumpAndSettle(); - // Verify log for attempt #1 - expect(find.textContaining('All tests passed (452/452)'), findsOneWidget); + expect(find.textContaining('All tests passed'), findsOneWidget); expect(find.textContaining('Status: Succeeded'), findsOneWidget); - // Switch to attempt #2 await tester.tap(find.text('#2')); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.textContaining('Test failed: Unit Tests'), findsOneWidget); expect(find.textContaining('Status: Failed'), findsOneWidget); - - // Verify rerun buttons are visible for latest SHA - expect(find.text('Re-run failed'), findsOneWidget); - expect(find.text('Re-run'), findsOneWidget); }); + testWidgets( + 'PreSubmitView automatically selects latest SHA and updates sidebar when opened with PR only', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const latestSha = 'decaf_3_real_sha'; + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.failed, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'Engine', + createdAt: 0, + builds: {'Mac mac_host_engine 1': TaskStatus.failed}, + ), + ], + ); + + when( + mockCocoonService.fetchPresubmitGuard( + repo: anyNamed('repo'), + sha: latestSha, + ), + ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); + + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'pr': '123'}), + ); + // Wait for summaries, then latest SHA selection, then guard status fetch + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + final state = Provider.of( + tester.element(find.byType(PreSubmitView)), + listen: false, + ); + if (state.guardResponse != null) break; + } + }); + await tester.pumpAndSettle(); + + final state = Provider.of( + tester.element(find.byType(PreSubmitView)), + listen: false, + ); + expect(state.sha, latestSha); + expect(find.textContaining('by dash'), findsOneWidget); + expect(find.textContaining('mac_host_engine'), findsOneWidget); + }, + ); + testWidgets('PreSubmitView SHA dropdown switches mock SHAs', ( WidgetTester tester, ) async { @@ -241,20 +319,23 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - createPreSubmitView({'repo': 'flutter', 'pr': '123'}), - ); - await tester.pump(); - await tester.pump(); + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'pr': '123'}), + ); + for (var i = 0; i < 20; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.byType(ShaSelector).evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); - // Find ShaSelector widget expect(find.byType(ShaSelector), findsOneWidget); - // Tap the dropdown to open it await tester.tap(find.byType(ShaSelector)); await tester.pumpAndSettle(); - // Select the second item in the dropdown menu (face5_2_mock_sha) await tester.tap( find.byWidgetPredicate( (widget) => @@ -263,11 +344,9 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.pump(const Duration(seconds: 1)); expect(find.byType(ShaSelector), findsOneWidget); expect(find.textContaining('face5_2'), findsOneWidget); - // Button should be hidden for older SHAs expect(find.text('Re-run failed'), findsNothing); expect(find.text('Re-run'), findsNothing); }); @@ -315,17 +394,31 @@ void main() { ]), ); - await tester.pumpWidget( - createPreSubmitView({'repo': 'flutter', 'sha': 'abc'}), - ); - await tester.pump(); - await tester.pump(); + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'sha': 'abc'}), + ); + for (var i = 0; i < 20; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('by dash').evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); await tester.tap(find.textContaining('mac_host_engine')); - await tester.pump(); - await tester.pump(); + await tester.runAsync(() async { + for (var i = 0; i < 20; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('Live log content').evaluate().isNotEmpty) { + break; + } + } + }); + await tester.pumpAndSettle(); - expect(find.text('Live log content'), findsOneWidget); + expect(find.textContaining('Live log content'), findsOneWidget); }); testWidgets('PreSubmitView meets accessibility guidelines', ( @@ -337,15 +430,12 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - createPreSubmitView({'repo': 'flutter', 'pr': '123'}), - ); - await tester.pump(); - await tester.pump(); - - // Verify text contrast - await expectLater(tester, meetsGuideline(textContrastGuideline)); + await tester.pumpWidget(createPreSubmitView({'repo': 'flutter'})); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); handle.dispose(); }); @@ -353,17 +443,25 @@ void main() { testWidgets('displays loading text when navigated via PR', ( WidgetTester tester, ) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( createPreSubmitView({'repo': 'flutter', 'pr': '123'}), ); - // No pump() here to stay in loading state - expect(find.text('PR #123'), findsOneWidget); - expect(find.textContaining('Feature Implementation'), findsNothing); + expect(find.textContaining('PR #123'), findsOneWidget); }); testWidgets( 'displays empty header text when neither PR nor SHA is provided', (WidgetTester tester) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(createPreSubmitView({'repo': 'flutter'})); expect(find.text(''), findsOneWidget); }, @@ -372,22 +470,31 @@ void main() { testWidgets('displays loading text when navigated via SHA', ( WidgetTester tester, ) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( createPreSubmitView({'repo': 'flutter', 'sha': 'abcdef123456'}), ); - // No pump() here to stay in loading state expect(find.text('(abcdef1)'), findsOneWidget); }); testWidgets('displays full header text when loaded', ( 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, - checkRunId: 456, author: 'dash', - stages: [], guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [], ); when( @@ -397,13 +504,19 @@ void main() { ), ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); - await tester.pumpWidget( - createPreSubmitView({'repo': 'flutter', 'sha': 'abcdef123456'}), - ); - await tester.pump(); - await tester.pump(); - - expect(find.text('PR #123 by dash (abcdef1)'), findsOneWidget); + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'sha': 'abcdef123456'}), + ); + for (var i = 0; i < 20; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('by dash').evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); + + expect(find.textContaining('PR #123 by dash (abcdef1)'), findsOneWidget); }); }); } diff --git a/dashboard/test/widgets/luci_task_attempt_summary_test.dart b/dashboard/test/widgets/luci_task_attempt_summary_test.dart index 0e6f506151..6f2fe4f814 100644 --- a/dashboard/test/widgets/luci_task_attempt_summary_test.dart +++ b/dashboard/test/widgets/luci_task_attempt_summary_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:cocoon_common/build_log_url.dart'; import 'package:cocoon_common/task_status.dart'; import 'package:flutter/material.dart' hide Key; import 'package:flutter_dashboard/widgets/luci_task_attempt_summary.dart'; @@ -137,10 +138,7 @@ void main() { await tester.pump(); expect(urlLauncher.launches, isNotEmpty); - expect( - urlLauncher.launches.single, - '${LuciTaskAttemptSummary.luciProdLogBase}/prod/Linux/456', - ); + expect(urlLauncher.launches.single, '$luciProdLogBase/prod/Linux/456'); }, ); @@ -173,7 +171,7 @@ void main() { expect(urlLauncher.launches, isNotEmpty); expect( urlLauncher.launches.single, - '${LuciTaskAttemptSummary.dartInternalLogBase}/flutter/Linux%20flutter_release_builder/123', + '$dartInternalLogBase/flutter/Linux%20flutter_release_builder/123', ); }); }); diff --git a/packages/cocoon_common/lib/build_log_url.dart b/packages/cocoon_common/lib/build_log_url.dart new file mode 100644 index 0000000000..18164ddf25 --- /dev/null +++ b/packages/cocoon_common/lib/build_log_url.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 'is_dart_internal.dart'; + +/// The base URL for LUCI production logs. +const String luciProdLogBase = 'https://ci.chromium.org/p/flutter/builders'; + +/// The base URL for dart-internal logs. +const String dartInternalLogBase = + 'https://ci.chromium.org/p/dart-internal/builders'; + +/// Generates a LUCI UI URL for a build log. +String generateBuildLogUrl({ + required String buildName, + required int buildNumber, + bool isBringup = false, +}) { + if (isTaskFromDartInternalBuilder(builderName: buildName)) { + return Uri.https( + 'ci.chromium.org', + '/p/dart-internal/builders/flutter/$buildName/$buildNumber', + ).toString(); + } else { + final pool = isBringup ? 'staging' : 'prod'; + return Uri.https( + 'ci.chromium.org', + '/p/flutter/builders/$pool/$buildName/$buildNumber', + ).toString(); + } +} diff --git a/packages/cocoon_common/test/build_log_url_test.dart b/packages/cocoon_common/test/build_log_url_test.dart new file mode 100644 index 0000000000..cf4830f687 --- /dev/null +++ b/packages/cocoon_common/test/build_log_url_test.dart @@ -0,0 +1,38 @@ +// 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/build_log_url.dart'; +import 'package:test/test.dart'; + +void main() { + group('generateBuildLogUrl', () { + test('generates luci prod url', () { + expect( + generateBuildLogUrl(buildName: 'Linux', buildNumber: 123), + '$luciProdLogBase/prod/Linux/123', + ); + }); + + test('generates luci staging url', () { + expect( + generateBuildLogUrl( + buildName: 'Linux', + buildNumber: 123, + isBringup: true, + ), + '$luciProdLogBase/staging/Linux/123', + ); + }); + + test('generates dart-internal url', () { + expect( + generateBuildLogUrl( + buildName: 'Linux flutter_release_builder', + buildNumber: 123, + ), + '$dartInternalLogBase/flutter/Linux%20flutter_release_builder/123', + ); + }); + }); +}