diff --git a/dev/conductor/ui/lib/main.dart b/dev/conductor/ui/lib/main.dart index 0b36a220213..89324c280fb 100644 --- a/dev/conductor/ui/lib/main.dart +++ b/dev/conductor/ui/lib/main.dart @@ -25,8 +25,7 @@ Future main() async { throw Exception('The conductor only supports MacOS and Linux desktop'); } final File _stateFile = _fs.file(_stateFilePath); - final pb.ConductorState? state = - _stateFile.existsSync() ? readStateFromFile(_stateFile) : null; + final pb.ConductorState? state = _stateFile.existsSync() ? readStateFromFile(_stateFile) : null; WidgetsFlutterBinding.ensureInitialized(); runApp(MyApp(state)); @@ -44,7 +43,6 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: _title, -// TODO(Yugue): Add theming, https://github.com/flutter/flutter/issues/90982. home: Scaffold( appBar: AppBar( title: const Text(_title), diff --git a/dev/conductor/ui/lib/widgets/conductor_status.dart b/dev/conductor/ui/lib/widgets/conductor_status.dart index 46614553b9d..b8abab0b205 100644 --- a/dev/conductor/ui/lib/widgets/conductor_status.dart +++ b/dev/conductor/ui/lib/widgets/conductor_status.dart @@ -6,7 +6,7 @@ import 'package:conductor_core/conductor_core.dart'; import 'package:conductor_core/proto.dart' as pb; import 'package:flutter/material.dart'; -/// Display the current conductor state +/// Displays the current conductor state. class ConductorStatus extends StatefulWidget { const ConductorStatus({ Key? key, @@ -19,15 +19,192 @@ class ConductorStatus extends StatefulWidget { @override ConductorStatusState createState() => ConductorStatusState(); + + static final List headerElements = [ + 'Conductor Version', + 'Release Channel', + 'Release Version', + 'Release Started at', + 'Release Updated at', + 'Dart SDK Revision', + ]; } class ConductorStatusState extends State { + /// Returns the conductor state in a Map format for the desktop app to consume. + Map presentStateDesktop(pb.ConductorState state) { + final List> engineCherrypicks = >[]; + for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) { + engineCherrypicks + .add({'trunkRevision': cherrypick.trunkRevision, 'state': '${cherrypick.state}'}); + } + + final List> frameworkCherrypicks = >[]; + for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) { + frameworkCherrypicks + .add({'trunkRevision': cherrypick.trunkRevision, 'state': '${cherrypick.state}'}); + } + + return { + 'Conductor Version': state.conductorVersion, + 'Release Channel': state.releaseChannel, + 'Release Version': state.releaseVersion, + 'Release Started at': DateTime.fromMillisecondsSinceEpoch(state.createdDate.toInt()).toString(), + 'Release Updated at': DateTime.fromMillisecondsSinceEpoch(state.lastUpdatedDate.toInt()).toString(), + 'Engine Candidate Branch': state.engine.candidateBranch, + 'Engine Starting Git HEAD': state.engine.startingGitHead, + 'Engine Current Git HEAD': state.engine.currentGitHead, + 'Engine Path to Checkout': state.engine.checkoutPath, + 'Engine LUCI Dashboard': luciConsoleLink(state.releaseChannel, 'engine'), + 'Engine Cherrypicks': engineCherrypicks, + 'Dart SDK Revision': state.engine.dartRevision, + 'Framework Candidate Branch': state.framework.candidateBranch, + 'Framework Starting Git HEAD': state.framework.startingGitHead, + 'Framework Current Git HEAD': state.framework.currentGitHead, + 'Framework Path to Checkout': state.framework.checkoutPath, + 'Framework LUCI Dashboard': luciConsoleLink(state.releaseChannel, 'flutter'), + 'Framework Cherrypicks': frameworkCherrypicks, + 'Current Phase': state.currentPhase, + }; + } + @override Widget build(BuildContext context) { - return SelectableText( - widget.releaseState != null - ? presentState(widget.releaseState!) - : 'No persistent state file found at ${widget.stateFilePath}', + late final Map currentStatus; + if (widget.releaseState == null) { + return SelectableText('No persistent state file found at ${widget.stateFilePath}'); + } else { + currentStatus = presentStateDesktop(widget.releaseState!); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Table( + columnWidths: const { + 0: FixedColumnWidth(200.0), + 1: FixedColumnWidth(400.0), + }, + children: [ + for (String headerElement in ConductorStatus.headerElements) + TableRow( + children: [ + Text('$headerElement:'), + SelectableText((currentStatus[headerElement] == null || currentStatus[headerElement] == '') + ? 'Unknown' + : currentStatus[headerElement]! as String), + ], + ), + ], + ), + const SizedBox(height: 20.0), + Wrap( + children: [ + Column( + children: [ + CherrypickTable(engineOrFramework: 'engine', currentStatus: currentStatus), + ], + ), + const SizedBox(width: 20.0), + Column( + children: [ + CherrypickTable(engineOrFramework: 'framework', currentStatus: currentStatus), + ], + ), + ], + ) + ], + ) + ], + ); + } +} + +/// Displays explanations for each status type as a tooltip. +class StatusTooltip extends StatefulWidget { + const StatusTooltip({ + Key? key, + this.engineOrFramework, + }) : super(key: key); + + final String? engineOrFramework; + + @override + State createState() => _StatusTooltipState(); +} + +class _StatusTooltipState extends State { + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Text('Status'), + const SizedBox(width: 10.0), + Tooltip( + padding: const EdgeInsets.all(10.0), + message: ''' +PENDING: The cherrypick has not yet been applied. +PENDING_WITH_CONFLICT: The cherrypick has not been applied and will require manual resolution. +COMPLETED: The cherrypick has been successfully applied to the local checkout. +ABANDONED: The cherrypick will NOT be applied in this release.''', + child: Icon( + Icons.info, + size: 16.0, + key: Key('${widget.engineOrFramework}ConductorStatusTooltip'), + ), + ), + ], + ); + } +} + +/// Widget for showing the engine and framework cherrypicks applied to the current release. +/// +/// Shows the cherrypicks' SHA and status in two separate table DataRow cells. +class CherrypickTable extends StatefulWidget { + const CherrypickTable({ + Key? key, + required this.engineOrFramework, + required this.currentStatus, + }) : super(key: key); + + final String engineOrFramework; + final Map currentStatus; + + @override + CherrypickTableState createState() => CherrypickTableState(); +} + +class CherrypickTableState extends State { + @override + Widget build(BuildContext context) { + final List> cherrypicks = widget.engineOrFramework == 'engine' + ? widget.currentStatus['Engine Cherrypicks']! as List> + : widget.currentStatus['Framework Cherrypicks']! as List>; + + return DataTable( + dataRowHeight: 30.0, + headingRowHeight: 30.0, + decoration: BoxDecoration(border: Border.all(color: Colors.grey)), + columns: [ + DataColumn(label: Text('${widget.engineOrFramework == 'engine' ? 'Engine' : 'Framework'} Cherrypicks')), + DataColumn(label: StatusTooltip(engineOrFramework: widget.engineOrFramework)), + ], + rows: cherrypicks.map((Map cherrypick) { + return DataRow( + cells: [ + DataCell( + SelectableText(cherrypick['trunkRevision']!), + ), + DataCell( + SelectableText(cherrypick['state']!), + ), + ], + ); + }).toList(), ); } } diff --git a/dev/conductor/ui/test/main_test.dart b/dev/conductor/ui/test/main_test.dart index 8a245931a42..6349306d7bb 100644 --- a/dev/conductor/ui/test/main_test.dart +++ b/dev/conductor/ui/test/main_test.dart @@ -4,31 +4,17 @@ import 'dart:io' show Platform; -import 'package:conductor_core/proto.dart' as pb; import 'package:conductor_ui/main.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Main app', () { - testWidgets('Handles null state', (WidgetTester tester) async { + testWidgets('Scaffold Initialization', (WidgetTester tester) async { await tester.pumpWidget(const MyApp(null)); expect(find.textContaining('Flutter Desktop Conductor'), findsOneWidget); - expect(find.textContaining('No persistent state file found at'), findsOneWidget); - }); - - testWidgets('App prints release channel from state file', - (WidgetTester tester) async { - const String channelName = 'dev'; - final pb.ConductorState state = pb.ConductorState( - releaseChannel: channelName, - ); - await tester.pumpWidget(MyApp(state)); - - expect(find.textContaining('Flutter Desktop Conductor'), findsOneWidget); - expect(find.textContaining('Conductor version'), findsOneWidget); - expect(find.text('1'), findsNothing); + expect(find.textContaining('Desktop app for managing a release'), findsOneWidget); }); }, skip: Platform.isWindows); // This app does not support Windows [intended] } diff --git a/dev/conductor/ui/test/widgets/conductor_status_test.dart b/dev/conductor/ui/test/widgets/conductor_status_test.dart new file mode 100644 index 00000000000..79d3726534c --- /dev/null +++ b/dev/conductor/ui/test/widgets/conductor_status_test.dart @@ -0,0 +1,180 @@ +// Copyright 2014 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:conductor_core/proto.dart' as pb; +import 'package:conductor_ui/widgets/conductor_status.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('conductor_status', () { + testWidgets('Conductor_status displays nothing found when there is no state file', (WidgetTester tester) async { + const String testPath = './testPath'; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: Column( + children: const [ + ConductorStatus( + stateFilePath: testPath, + ), + ], + ), + ), + ); + }, + ), + ); + + expect(find.text('No persistent state file found at $testPath'), findsOneWidget); + expect(find.text('Conductor version:'), findsNothing); + }); + + testWidgets('Conductor_status displays correct status with a state file', (WidgetTester tester) async { + const String testPath = './testPath'; + const String conductorVersion = 'v1.0'; + const String releaseChannel = 'beta'; + const String releaseVersion = '1.2.0-3.4.pre'; + const String candidateBranch = 'flutter-1.2-candidate.3'; + const String workingBranch = 'cherrypicks-$candidateBranch'; + const String dartRevision = 'fe9708ab688dcda9923f584ba370a66fcbc3811f'; + const String engineCherrypick1 = 'a5a25cd702b062c24b2c67b8d30b5cb33e0ef6f0'; + const String engineCherrypick2 = '94d06a2e1d01a3b0c693b94d70c5e1df9d78d249'; + const String frameworkCherrypick = '768cd702b691584b2c67b8d30b5cb33e0ef6f0'; + const String engineStartingGitHead = '083049e6cae311910c6a6619a6681b7eba4035b4'; + const String engineCurrentGitHead = '083049e6cae311910c6a6619a6681b7eba4035b4'; + const String engineCheckoutPath = '/Users/alexchen/Desktop/flutter_conductor_checkouts/engine'; + const String frameworkStartingGitHead = '083049e6cae311910c6a6619a6681b7eba4035b4'; + const String frameworkCurrentGitHead = '083049e6cae311910c6a6619a6681b7eba4035b4'; + const String frameworkCheckoutPath = '/Users/alexchen/Desktop/flutter_conductor_checkouts/framework'; + + final pb.ConductorState state = pb.ConductorState( + engine: pb.Repository( + candidateBranch: candidateBranch, + cherrypicks: [ + pb.Cherrypick(trunkRevision: engineCherrypick1), + pb.Cherrypick(trunkRevision: engineCherrypick2), + ], + dartRevision: dartRevision, + workingBranch: workingBranch, + startingGitHead: engineStartingGitHead, + currentGitHead: engineCurrentGitHead, + checkoutPath: engineCheckoutPath, + ), + framework: pb.Repository( + candidateBranch: candidateBranch, + cherrypicks: [ + pb.Cherrypick(trunkRevision: frameworkCherrypick), + ], + workingBranch: workingBranch, + startingGitHead: frameworkStartingGitHead, + currentGitHead: frameworkCurrentGitHead, + checkoutPath: frameworkCheckoutPath, + ), + conductorVersion: conductorVersion, + releaseChannel: releaseChannel, + releaseVersion: releaseVersion, + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: Column( + children: [ + ConductorStatus( + releaseState: state, + stateFilePath: testPath, + ), + ], + ), + ), + ); + }, + ), + ); + + expect(find.text('No persistent state file found at $testPath'), findsNothing); + for (final String headerElement in ConductorStatus.headerElements) { + expect(find.text('$headerElement:'), findsOneWidget); + } + expect(find.text(conductorVersion), findsOneWidget); + expect(find.text(releaseChannel), findsOneWidget); + expect(find.text(releaseVersion), findsOneWidget); + expect(find.text('Release Started at:'), findsOneWidget); + expect(find.text('Release Updated at:'), findsOneWidget); + expect(find.text(dartRevision), findsOneWidget); + expect(find.text(engineCherrypick1), findsOneWidget); + expect(find.text(engineCherrypick2), findsOneWidget); + expect(find.text(frameworkCherrypick), findsOneWidget); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: Offset.zero); + + /// Tests the tooltip is displaying status explanations upon cursor hovering. + /// + /// Before hovering, status explanations are not found. + /// When the cursor hovers over the info icon, the explanations are displayed and found. + expect(find.textContaining('PENDING: The cherrypick has not yet been applied.'), findsNothing); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byKey(const Key('engineConductorStatusTooltip')))); + await tester.pumpAndSettle(); + expect(find.textContaining('PENDING: The cherrypick has not yet been applied.'), findsOneWidget); + }); + + testWidgets('Conductor_status displays correct status with a null state file except a releaseChannel', + (WidgetTester tester) async { + const String testPath = './testPath'; + const String releaseChannel = 'beta'; + final pb.ConductorState state = pb.ConductorState( + releaseChannel: releaseChannel, + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: Column( + children: [ + ConductorStatus( + releaseState: state, + stateFilePath: testPath, + ), + ], + ), + ), + ); + }, + ), + ); + + expect(find.text('No persistent state file found at $testPath'), findsNothing); + for (final String headerElement in ConductorStatus.headerElements) { + expect(find.text('$headerElement:'), findsOneWidget); + } + expect(find.text(releaseChannel), findsOneWidget); + expect(find.text('Unknown'), findsNWidgets(3)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: Offset.zero); + + /// Tests the tooltip is displaying status explanations upon cursor hovering. + /// + /// Before hovering, status explanations are not found. + /// When the cursor hovers over the info icon, the explanations are displayed and found. + expect(find.textContaining('PENDING: The cherrypick has not yet been applied.'), findsNothing); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byKey(const Key('engineConductorStatusTooltip')))); + await tester.pumpAndSettle(); + expect(find.textContaining('PENDING: The cherrypick has not yet been applied.'), findsOneWidget); + }); + }); +}