mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
added conductor_status widget (#91445)
This commit is contained in:
parent
01afd64bcc
commit
3a2bc1c5f7
@ -25,8 +25,7 @@ Future<void> 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),
|
||||
|
@ -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<String> headerElements = <String>[
|
||||
'Conductor Version',
|
||||
'Release Channel',
|
||||
'Release Version',
|
||||
'Release Started at',
|
||||
'Release Updated at',
|
||||
'Dart SDK Revision',
|
||||
];
|
||||
}
|
||||
|
||||
class ConductorStatusState extends State<ConductorStatus> {
|
||||
/// Returns the conductor state in a Map<K, V> format for the desktop app to consume.
|
||||
Map<String, Object> presentStateDesktop(pb.ConductorState state) {
|
||||
final List<Map<String, String>> engineCherrypicks = <Map<String, String>>[];
|
||||
for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) {
|
||||
engineCherrypicks
|
||||
.add(<String, String>{'trunkRevision': cherrypick.trunkRevision, 'state': '${cherrypick.state}'});
|
||||
}
|
||||
|
||||
final List<Map<String, String>> frameworkCherrypicks = <Map<String, String>>[];
|
||||
for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) {
|
||||
frameworkCherrypicks
|
||||
.add(<String, String>{'trunkRevision': cherrypick.trunkRevision, 'state': '${cherrypick.state}'});
|
||||
}
|
||||
|
||||
return <String, Object>{
|
||||
'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<String, Object> 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: <Widget>[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Table(
|
||||
columnWidths: const <int, TableColumnWidth>{
|
||||
0: FixedColumnWidth(200.0),
|
||||
1: FixedColumnWidth(400.0),
|
||||
},
|
||||
children: <TableRow>[
|
||||
for (String headerElement in ConductorStatus.headerElements)
|
||||
TableRow(
|
||||
children: <Widget>[
|
||||
Text('$headerElement:'),
|
||||
SelectableText((currentStatus[headerElement] == null || currentStatus[headerElement] == '')
|
||||
? 'Unknown'
|
||||
: currentStatus[headerElement]! as String),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20.0),
|
||||
Wrap(
|
||||
children: <Widget>[
|
||||
Column(
|
||||
children: <Widget>[
|
||||
CherrypickTable(engineOrFramework: 'engine', currentStatus: currentStatus),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 20.0),
|
||||
Column(
|
||||
children: <Widget>[
|
||||
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<StatusTooltip> createState() => _StatusTooltipState();
|
||||
}
|
||||
|
||||
class _StatusTooltipState extends State<StatusTooltip> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
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<String, Object> currentStatus;
|
||||
|
||||
@override
|
||||
CherrypickTableState createState() => CherrypickTableState();
|
||||
}
|
||||
|
||||
class CherrypickTableState extends State<CherrypickTable> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Map<String, String>> cherrypicks = widget.engineOrFramework == 'engine'
|
||||
? widget.currentStatus['Engine Cherrypicks']! as List<Map<String, String>>
|
||||
: widget.currentStatus['Framework Cherrypicks']! as List<Map<String, String>>;
|
||||
|
||||
return DataTable(
|
||||
dataRowHeight: 30.0,
|
||||
headingRowHeight: 30.0,
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.grey)),
|
||||
columns: <DataColumn>[
|
||||
DataColumn(label: Text('${widget.engineOrFramework == 'engine' ? 'Engine' : 'Framework'} Cherrypicks')),
|
||||
DataColumn(label: StatusTooltip(engineOrFramework: widget.engineOrFramework)),
|
||||
],
|
||||
rows: cherrypicks.map((Map<String, String> cherrypick) {
|
||||
return DataRow(
|
||||
cells: <DataCell>[
|
||||
DataCell(
|
||||
SelectableText(cherrypick['trunkRevision']!),
|
||||
),
|
||||
DataCell(
|
||||
SelectableText(cherrypick['state']!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
}
|
||||
|
180
dev/conductor/ui/test/widgets/conductor_status_test.dart
Normal file
180
dev/conductor/ui/test/widgets/conductor_status_test.dart
Normal file
@ -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 <Widget>[
|
||||
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>[
|
||||
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>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user