diff --git a/packages/devtools_app/integration_test/test/live_connection/app_test.dart b/packages/devtools_app/integration_test/test/live_connection/app_test.dart index 10bf5c17a96..19ec8273af3 100644 --- a/packages/devtools_app/integration_test/test/live_connection/app_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/app_test.dart @@ -59,7 +59,11 @@ void main() { final screens = (ScreenMetaData.values.toList() ..removeWhere((data) => !availableScreenIds.contains(data.id))); for (final screen in screens) { - await switchToScreen(tester, screen); + await switchToScreen( + tester, + tabIcon: screen.icon!, + screenId: screen.id, + ); } }); } diff --git a/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart b/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart index e2d0ce2a089..2f7ec11b1ff 100644 --- a/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart @@ -29,7 +29,11 @@ void main() { testWidgets('Debugger panel', (tester) async { await pumpAndConnectDevTools(tester, testApp); - await switchToScreen(tester, ScreenMetaData.debugger); + await switchToScreen( + tester, + tabIcon: ScreenMetaData.debugger.icon!, + screenId: ScreenMetaData.debugger.id, + ); await tester.pump(safePumpDuration); logStatus('looking for the main.dart file'); diff --git a/packages/devtools_app/integration_test/test/live_connection/devtools_extensions_test.dart b/packages/devtools_app/integration_test/test/live_connection/devtools_extensions_test.dart new file mode 100644 index 00000000000..84a4b792b7c --- /dev/null +++ b/packages/devtools_app/integration_test/test/live_connection/devtools_extensions_test.dart @@ -0,0 +1,311 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Do not delete these arguments. They are parsed by test runner. +// test-argument:experimentsOn=true + +// import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/extensions/embedded/view.dart'; +import 'package:devtools_app/src/extensions/extension_screen.dart'; +import 'package:devtools_app/src/extensions/extension_screen_controls.dart'; +import 'package:devtools_app/src/extensions/extension_settings.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_shared/devtools_extensions.dart'; +import 'package:devtools_test/devtools_integration_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +// To run: +// dart run integration_test/run_tests.dart --target=integration_test/test/live_connection/devtools_extensions_test.dart + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late TestApp testApp; + + setUpAll(() { + testApp = TestApp.fromEnvironment(); + expect(testApp.vmServiceUri, isNotNull); + }); + + testWidgets('end to end extensions flow', (tester) async { + await pumpAndConnectDevTools(tester, testApp); + + expect(extensionService.availableExtensions.value.length, 3); + expect(extensionService.visibleExtensions.value.length, 3); + await _verifyExtensionsSettingsMenu( + tester, + [ + ExtensionEnabledState.none, + ExtensionEnabledState.none, + ExtensionEnabledState.none, + ], + ); + + // Bar extension. + // Enable, test context menu actions, then disable from context menu. + await _switchToExtensionScreen( + tester, + extensionIndex: 0, + initialLoad: true, + ); + await _answerEnableExtensionPrompt(tester, enable: true); + await _verifyExtensionsSettingsMenu( + tester, + [ + ExtensionEnabledState.enabled, + ExtensionEnabledState.none, + ExtensionEnabledState.none, + ], + ); + + await _verifyContextMenuActions(tester); + + expect(extensionService.availableExtensions.value.length, 3); + expect(extensionService.visibleExtensions.value.length, 2); + await _verifyExtensionTabVisibility( + tester, + extensionIndex: 0, + visible: false, + ); + await _verifyExtensionsSettingsMenu( + tester, + [ + ExtensionEnabledState.disabled, + ExtensionEnabledState.none, + ExtensionEnabledState.none, + ], + ); + + // Foo extension. Hide immediately, then re-enable from extensions menu. + await _switchToExtensionScreen( + tester, + extensionIndex: 1, + initialLoad: true, + ); + await _answerEnableExtensionPrompt(tester, enable: false); + + expect(extensionService.availableExtensions.value.length, 3); + expect(extensionService.visibleExtensions.value.length, 1); + await _verifyExtensionTabVisibility( + tester, + extensionIndex: 1, + visible: false, + ); + await _verifyExtensionsSettingsMenu( + tester, + [ + ExtensionEnabledState.disabled, + ExtensionEnabledState.disabled, + ExtensionEnabledState.none, + ], + ); + + logStatus('verify we can re-enable an extension from the settings menu'); + await _changeExtensionSetting(tester, extensionIndex: 1, enable: true); + + expect(extensionService.availableExtensions.value.length, 3); + expect(extensionService.visibleExtensions.value.length, 2); + await _switchToExtensionScreen(tester, extensionIndex: 1); + expect(find.byType(EnableExtensionPrompt), findsNothing); + expect(find.byType(EmbeddedExtensionView), findsOneWidget); + expect(find.byType(HtmlElementView), findsOneWidget); + await _verifyExtensionsSettingsMenu( + tester, + [ + ExtensionEnabledState.disabled, + ExtensionEnabledState.enabled, + ExtensionEnabledState.none, + ], + ); + + // Provider extension. Disable directly from settings menu. + logStatus( + 'verify we can disable an extension screen directly from the settings menu', + ); + await _verifyExtensionTabVisibility( + tester, + extensionIndex: 2, + visible: true, + ); + + logStatus('disable the extension from the settings menu'); + await _changeExtensionSetting(tester, extensionIndex: 2, enable: false); + expect(extensionService.availableExtensions.value.length, 3); + expect(extensionService.visibleExtensions.value.length, 1); + await _verifyExtensionTabVisibility( + tester, + extensionIndex: 2, + visible: false, + ); + await _verifyExtensionsSettingsMenu( + tester, + [ + ExtensionEnabledState.disabled, + ExtensionEnabledState.enabled, + ExtensionEnabledState.disabled, + ], + ); + }); +} + +Future _switchToExtensionScreen( + WidgetTester tester, { + required int extensionIndex, + bool initialLoad = false, +}) async { + final extensionConfig = + extensionService.availableExtensions.value[extensionIndex]; + await switchToScreen( + tester, + tabIcon: extensionConfig.icon, + screenId: extensionConfig.displayName, + warnIfTapMissed: false, + ); + await tester.pump(safePumpDuration); + + if (initialLoad) { + logStatus( + 'verify the first load state for the ${extensionConfig.name}' + ' extension screen', + ); + expect(find.byType(EnableExtensionPrompt), findsOneWidget); + expect(find.byType(EmbeddedExtensionView), findsNothing); + } +} + +Future _verifyExtensionTabVisibility( + WidgetTester tester, { + required int extensionIndex, + required bool visible, +}) async { + logStatus( + 'verify the extension at index $extensionIndex is ' + '${!visible ? 'not' : ''} visible', + ); + final extensionConfig = + extensionService.availableExtensions.value[extensionIndex]; + final tabFinder = await findTabOrOpenOverflowMenu( + tester, + extensionConfig.icon, + ); + expect(tabFinder.evaluate(), visible ? isNotEmpty : isEmpty); +} + +Future _answerEnableExtensionPrompt( + WidgetTester tester, { + required bool enable, +}) async { + logStatus('verify we can ${enable ? 'enable' : 'hide'} an extension'); + final buttonFinder = find.descendant( + of: find.byType(EnableExtensionPrompt), + matching: find.text(enable ? 'Enable' : 'No, hide this screen'), + ); + expect(buttonFinder, findsOneWidget); + await tester.tap(buttonFinder); + await tester.pump(longPumpDuration); + + expect(find.byType(EnableExtensionPrompt), findsNothing); + expect( + find.byType(EmbeddedExtensionView), + enable ? findsOneWidget : findsNothing, + ); + expect( + find.byType(HtmlElementView), + enable ? findsOneWidget : findsNothing, + ); +} + +Future _verifyContextMenuActions(WidgetTester tester) async { + logStatus('verify we can perform context menu actions'); + final contextMenuFinder = find.descendant( + of: find.byType(EmbeddedExtensionHeader), + matching: find.byType(ContextMenuButton), + ); + expect(contextMenuFinder, findsOneWidget); + await tester.tap(contextMenuFinder); + await tester.pump(shortPumpDuration); + + final disableExtensionFinder = find.text('Disable extension'); + final forceReloadExtensionFinder = find.text('Force reload extension'); + expect(disableExtensionFinder, findsOneWidget); + expect(forceReloadExtensionFinder, findsOneWidget); + + logStatus('verify we can force reload the extension'); + await tester.tap(forceReloadExtensionFinder); + await tester.pumpAndSettle(shortPumpDuration); + + logStatus('verify we can disable the extension from the context menu'); + await tester.tap(contextMenuFinder); + await tester.pump(shortPumpDuration); + await tester.tap(disableExtensionFinder); + await tester.pumpAndSettle(shortPumpDuration); + await tester.tap(find.text('YES, DISABLE')); + await tester.pumpAndSettle(longPumpDuration); +} + +Future _verifyExtensionsSettingsMenu( + WidgetTester tester, + List enabledStates, +) async { + await _openExtensionSettingsMenu(tester); + + expect(find.byType(ExtensionSetting), findsNWidgets(enabledStates.length)); + final toggleButtonGroups = tester + .widgetList(find.byType(DevToolsToggleButtonGroup)) + .cast() + .toList(); + for (int i = 0; i < toggleButtonGroups.length; i++) { + final group = toggleButtonGroups[i]; + final expectedStates = switch (enabledStates[i]) { + ExtensionEnabledState.enabled => [true, false], + ExtensionEnabledState.disabled => [false, true], + _ => [false, false], + }; + expect(group.selectedStates, expectedStates); + } + + await _closeExtensionSettingsMenu(tester); +} + +Future _openExtensionSettingsMenu(WidgetTester tester) async { + await tester.tap(find.byType(ExtensionSettingsAction)); + await tester.pumpAndSettle(shortPumpDuration); +} + +Future _closeExtensionSettingsMenu(WidgetTester tester) async { + await tester.tap( + find.descendant( + of: find.byType(ExtensionSettingsDialog), + matching: find.byType(DialogCloseButton), + ), + ); + await tester.pumpAndSettle(safePumpDuration); +} + +Future _changeExtensionSetting( + WidgetTester tester, { + required int extensionIndex, + required bool enable, +}) async { + final settingValue = enable ? 'Enabled' : 'Disabled'; + logStatus( + 'changing the extension setting at index $extensionIndex to value $settingValue', + ); + await _openExtensionSettingsMenu(tester); + final extensionSetting = tester + .widgetList(find.byType(DevToolsToggleButtonGroup)) + .cast() + .toList()[extensionIndex]; + await tester.tap( + find.descendant( + of: find.byWidget(extensionSetting), + matching: find.text(enable ? 'Enabled' : 'Disabled'), + ), + ); + await tester.pumpAndSettle(shortPumpDuration); + await _closeExtensionSettingsMenu(tester); +} diff --git a/packages/devtools_app/integration_test/test/live_connection/eval_and_browse_test.dart b/packages/devtools_app/integration_test/test/live_connection/eval_and_browse_test.dart index 5adcebcae3f..1f7c44c1192 100644 --- a/packages/devtools_app/integration_test/test/live_connection/eval_and_browse_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/eval_and_browse_test.dart @@ -4,9 +4,6 @@ // Do not delete these arguments. They are parsed by test runner. // test-argument:appPath="test/test_infra/fixtures/memory_app" -// test-argument:experimentsOn=true - -// ignore_for_file: avoid_print import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/memory/panes/control/primary_controls.dart'; @@ -19,9 +16,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -// TODO(polina-c): enable the test -// https://github.com/flutter/devtools/issues/6271 - // To run: // dart run integration_test/run_tests.dart --target=integration_test/test/live_connection/eval_and_browse_test.dart @@ -155,7 +149,11 @@ class _EvalAndBrowseTester { /// visible on the screen for testing. Future prepareMemoryUI() async { // Open memory screen. - await switchToScreen(tester, ScreenMetaData.memory); + await switchToScreen( + tester, + tabIcon: ScreenMetaData.memory.icon!, + screenId: ScreenMetaData.memory.id, + ); // Close warning and chart to get screen space. await tapAndPump( diff --git a/packages/devtools_app/integration_test/test/live_connection/performance_screen_event_recording_test.dart b/packages/devtools_app/integration_test/test/live_connection/performance_screen_event_recording_test.dart index 4a5d9c245c6..03217bdda3d 100644 --- a/packages/devtools_app/integration_test/test/live_connection/performance_screen_event_recording_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/performance_screen_event_recording_test.dart @@ -28,7 +28,11 @@ void main() { 'Open the Performance screen and switch to the Timeline Events tab', ); - await switchToScreen(tester, ScreenMetaData.performance); + await switchToScreen( + tester, + tabIcon: ScreenMetaData.performance.icon!, + screenId: ScreenMetaData.performance.id, + ); await tester.pump(safePumpDuration); await tester.tap(find.widgetWithText(InkWell, 'Timeline Events')); diff --git a/packages/devtools_app/integration_test/test/live_connection/service_connection_test.dart b/packages/devtools_app/integration_test/test/live_connection/service_connection_test.dart index 2042011ed69..e3342eafdef 100644 --- a/packages/devtools_app/integration_test/test/live_connection/service_connection_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/service_connection_test.dart @@ -111,7 +111,11 @@ void main() { // TODO(kenz): re-work this integration test so that we do not have to be // on the inspector screen for this to pass. - await switchToScreen(tester, ScreenMetaData.inspector); + await switchToScreen( + tester, + tabIcon: ScreenMetaData.inspector.icon!, + screenId: ScreenMetaData.inspector.id, + ); await tester.pump(longDuration); // Ensure all futures are completed before running checks. diff --git a/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart b/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart index 3f1f73b2240..b93777886f0 100644 --- a/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart +++ b/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart @@ -30,6 +30,8 @@ class TestFlutterApp extends IntegrationTestApp { '--machine', '-d', testAppDevice.argName, + // Do not serve DevTools from Flutter Tools. + '--no-devtools', ], workingDirectory: testAppPath, ); @@ -301,7 +303,7 @@ abstract class IntegrationTestApp with IOMixin { Future manuallyStopApp() async {} Future start() async { - _debugPrint('starting the test app process...'); + _debugPrint('starting the test app process for $testAppPath'); await startProcess(); assert( runProcess != null, diff --git a/packages/devtools_app/integration_test/test_infra/run/run_test.dart b/packages/devtools_app/integration_test/test_infra/run/run_test.dart index c01a2336b22..e84eb82454c 100644 --- a/packages/devtools_app/integration_test/test_infra/run/run_test.dart +++ b/packages/devtools_app/integration_test/test_infra/run/run_test.dart @@ -30,11 +30,19 @@ Future runFlutterIntegrationTest( if (!offline) { if (testRunnerArgs.testAppUri == null) { + debugLog('Starting a test application'); // Create the test app and start it. try { if (testRunnerArgs.testAppDevice == TestAppDevice.cli) { + debugLog( + 'Creating a TestDartCliApp with path ${testFileArgs.appPath}', + ); testApp = TestDartCliApp(appPath: testFileArgs.appPath); } else { + debugLog( + 'Creating a TestFlutterApp with path ${testFileArgs.appPath} and ' + 'device ${testRunnerArgs.testAppDevice}', + ); testApp = TestFlutterApp( appPath: testFileArgs.appPath, appDevice: testRunnerArgs.testAppDevice, diff --git a/packages/devtools_app/lib/src/extensions/extension_screen.dart b/packages/devtools_app/lib/src/extensions/extension_screen.dart index 5c0d713f497..1c8389f4cd7 100644 --- a/packages/devtools_app/lib/src/extensions/extension_screen.dart +++ b/packages/devtools_app/lib/src/extensions/extension_screen.dart @@ -21,7 +21,7 @@ class ExtensionScreen extends Screen { : super.conditional( // TODO(kenz): we may need to ensure this is a unique id. id: '${extensionConfig.name}_ext', - title: extensionConfig.name.toSentenceCase(), + title: extensionConfig.screenTitle, icon: extensionConfig.icon, // TODO(kenz): support static DevTools extensions. requiresConnection: true, @@ -131,4 +131,6 @@ extension ExtensionConfigExtension on DevToolsExtensionConfig { materialIconCodePoint, fontFamily: 'MaterialIcons', ); + + String get screenTitle => name.toSentenceCase(); } diff --git a/packages/devtools_app/lib/src/extensions/extension_service.dart b/packages/devtools_app/lib/src/extensions/extension_service.dart index b6cdcde1d5f..f77bb5dc0d8 100644 --- a/packages/devtools_app/lib/src/extensions/extension_service.dart +++ b/packages/devtools_app/lib/src/extensions/extension_service.dart @@ -56,12 +56,14 @@ class ExtensionService extends DisposableController // TODO(https://github.com/flutter/flutter/issues/134470): refresh on // hot reload and hot restart events instead. addAutoDisposeListener( - serviceConnection.serviceManager.isolateManager.mainIsolate, () async { - if (serviceConnection.serviceManager.isolateManager.mainIsolate.value != - null) { - await _maybeRefreshExtensions(); - } - }); + serviceConnection.serviceManager.isolateManager.mainIsolate, + () async { + if (serviceConnection.serviceManager.isolateManager.mainIsolate.value != + null) { + await _maybeRefreshExtensions(); + } + }, + ); // TODO(kenz): we should also refresh the available extensions on some event // from the analysis server that is watching the diff --git a/packages/devtools_app/lib/src/shared/development_helpers.dart b/packages/devtools_app/lib/src/shared/development_helpers.dart index d33547bbea9..70844ce71e1 100644 --- a/packages/devtools_app/lib/src/shared/development_helpers.dart +++ b/packages/devtools_app/lib/src/shared/development_helpers.dart @@ -4,6 +4,8 @@ import 'package:devtools_shared/devtools_extensions.dart'; +import 'globals.dart'; + /// Whether to build DevTools for conveniently debugging DevTools extensions. /// /// Turning this flag to [true] allows for debugging the extensions framework @@ -11,15 +13,17 @@ import 'package:devtools_shared/devtools_extensions.dart'; /// /// This flag should never be checked in with a value of true - this is covered /// by a test. -const debugDevToolsExtensions = false; +final debugDevToolsExtensions = false || integrationTestMode; List debugHandleRefreshAvailableExtensions( + // ignore: avoid-unused-parameters, false positive due to conditional imports String rootPath, ) { return debugExtensions; } ExtensionEnabledState debugHandleExtensionEnabledState({ + // ignore: avoid-unused-parameters, false positive due to conditional imports required String rootPath, required String extensionName, bool? enable, diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png index 9399de4ea89..487411076d5 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png and b/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png index adca519f8dd..1ba08de4041 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png index adca519f8dd..1ba08de4041 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png index adca519f8dd..1ba08de4041 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png differ diff --git a/packages/devtools_shared/lib/src/extensions/extension_model.dart b/packages/devtools_shared/lib/src/extensions/extension_model.dart index 71308cee568..325aeb41976 100644 --- a/packages/devtools_shared/lib/src/extensions/extension_model.dart +++ b/packages/devtools_shared/lib/src/extensions/extension_model.dart @@ -101,6 +101,7 @@ class DevToolsExtensionConfig implements Comparable { /// [IconData] class for displaying in DevTools. /// /// This code point should be part of the 'MaterialIcons' font family. + /// See https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/icons.dart. final int materialIconCodePoint; String get displayName => name.toLowerCase(); diff --git a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart index a68abc94eec..951c23a9721 100644 --- a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart +++ b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart @@ -69,9 +69,31 @@ void _verifyFooterColor(WidgetTester tester, Color? expectedColor) { ); } -Future switchToScreen(WidgetTester tester, ScreenMetaData screen) async { - logStatus('switching to ${screen.name} screen (icon ${screen.icon})'); - final tabFinder = find.widgetWithIcon(Tab, screen.icon!); +/// Switches to the DevTools screen with icon [tabIcon] and pumps the tester +/// to settle the UI. +Future switchToScreen( + WidgetTester tester, { + required IconData tabIcon, + required String screenId, + bool warnIfTapMissed = true, +}) async { + logStatus('switching to $screenId screen (icon $tabIcon)'); + final tabFinder = await findTabOrOpenOverflowMenu(tester, tabIcon); + expect(tabFinder, findsOneWidget); + + await tester.tap(tabFinder, warnIfMissed: warnIfTapMissed); + // We use pump here instead of pumpAndSettle because pumpAndSettle will + // never complete if there is an animation (e.g. a progress indicator). + await tester.pump(safePumpDuration); +} + +/// Finds the tab with [icon], opening the tab overflow menu if the tab is not +/// immediately visible. +Future findTabOrOpenOverflowMenu( + WidgetTester tester, + IconData icon, +) async { + final tabFinder = find.widgetWithIcon(Tab, icon); // If we cannot find the tab, try opening the tab overflow menu, if present. if (tabFinder.evaluate().isEmpty) { @@ -81,11 +103,7 @@ Future switchToScreen(WidgetTester tester, ScreenMetaData screen) async { await tester.pump(shortPumpDuration); } } - - await tester.tap(tabFinder); - // We use pump here instead of pumpAndSettle because pumpAndSettle will - // never complete if there is an animation (e.g. a progress indicator). - await tester.pump(safePumpDuration); + return tabFinder; } Future pumpDevTools(WidgetTester tester) async {