From 8f62e3426735381f1117952bd2ac80a40e1b82bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Sharma?= <737941+loic-sharma@users.noreply.github.com> Date: Mon, 27 Mar 2023 17:33:39 -0700 Subject: [PATCH] [Focus] Add run key command to dump the focus tree (#123473) [Focus] Add run key command to dump the focus tree --- .../flutter_gallery/test/smoke_test.dart | 1 + .../lib/src/rendering/service_extensions.dart | 1 - packages/flutter/lib/src/widgets/binding.dart | 10 ++++ .../lib/src/widgets/service_extensions.dart | 9 ++++ .../foundation/service_extensions_test.dart | 15 +++++- .../lib/src/base/command_help.dart | 6 +++ .../lib/src/resident_runner.dart | 19 ++++++++ packages/flutter_tools/lib/src/vmservice.dart | 10 ++++ .../general.shard/base/command_help_test.dart | 3 ++ .../general.shard/resident_runner_test.dart | 1 + .../general.shard/terminal_handler_test.dart | 46 +++++++++++++++++++ .../test/general.shard/vmservice_test.dart | 40 ++++++++++++++++ .../overall_experience_test.dart | 1 + 13 files changed, 160 insertions(+), 2 deletions(-) diff --git a/dev/integration_tests/flutter_gallery/test/smoke_test.dart b/dev/integration_tests/flutter_gallery/test/smoke_test.dart index 77478f93c4..98f473880d 100644 --- a/dev/integration_tests/flutter_gallery/test/smoke_test.dart +++ b/dev/integration_tests/flutter_gallery/test/smoke_test.dart @@ -81,6 +81,7 @@ Future smokeDemo(WidgetTester tester, GalleryDemo demo) async { verifyToStringOutput('debugDumpApp', routeName, WidgetsBinding.instance.rootElement!.toStringDeep()); verifyToStringOutput('debugDumpRenderTree', routeName, RendererBinding.instance.renderView.toStringDeep()); verifyToStringOutput('debugDumpLayerTree', routeName, RendererBinding.instance.renderView.debugLayer?.toStringDeep() ?? ''); + verifyToStringOutput('debugDumpFocusTree', routeName, WidgetsBinding.instance.focusManager.toStringDeep()); // Scroll the demo around a bit more. await tester.flingFrom(const Offset(400.0, 300.0), const Offset(0.0, 400.0), 1000.0); diff --git a/packages/flutter/lib/src/rendering/service_extensions.dart b/packages/flutter/lib/src/rendering/service_extensions.dart index 99d6da3509..2f58135e3e 100644 --- a/packages/flutter/lib/src/rendering/service_extensions.dart +++ b/packages/flutter/lib/src/rendering/service_extensions.dart @@ -68,7 +68,6 @@ enum RenderingServiceExtensions { /// registered. debugDumpLayerTree, - /// Name of service extension that, when called, will toggle whether all /// clipping effects from the layer tree will be ignored. /// diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 9ccff18545..6c41b70542 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -386,6 +386,16 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB }, ); + registerServiceExtension( + name: WidgetsServiceExtensions.debugDumpFocusTree.name, + callback: (Map parameters) async { + final String data = focusManager.toStringDeep(); + return { + 'data': data, + }; + }, + ); + if (!kIsWeb) { registerBoolServiceExtension( name: WidgetsServiceExtensions.showPerformanceOverlay.name, diff --git a/packages/flutter/lib/src/widgets/service_extensions.dart b/packages/flutter/lib/src/widgets/service_extensions.dart index 30fe56c50d..834f1f0856 100644 --- a/packages/flutter/lib/src/widgets/service_extensions.dart +++ b/packages/flutter/lib/src/widgets/service_extensions.dart @@ -20,6 +20,15 @@ enum WidgetsServiceExtensions { /// registered. debugDumpApp, + /// Name of service extension that, when called, will output a string + /// representation of the focus tree to the console. + /// + /// See also: + /// + /// * [WidgetsBinding.initServiceExtensions], where the service extension is + /// registered. + debugDumpFocusTree, + /// Name of service extension that, when called, will overlay a performance /// graph on top of this app. /// diff --git a/packages/flutter/test/foundation/service_extensions_test.dart b/packages/flutter/test/foundation/service_extensions_test.dart index c4bd0960ad..00af67e169 100644 --- a/packages/flutter/test/foundation/service_extensions_test.dart +++ b/packages/flutter/test/foundation/service_extensions_test.dart @@ -177,7 +177,7 @@ void main() { // framework, excluding any that are for the widget inspector // (see widget_inspector_test.dart for tests of the ext.flutter.inspector // service extensions). - const int serviceExtensionCount = 37; + const int serviceExtensionCount = 38; expect(binding.extensions.length, serviceExtensionCount + widgetInspectorExtensionCount - disabledExtensions); @@ -218,6 +218,19 @@ void main() { }); }); + test('Service extensions - debugDumpFocusTree', () async { + final Map result = await binding.testExtension(WidgetsServiceExtensions.debugDumpFocusTree.name, {}); + + expect(result, { + 'data': matches( + r'^' + r'FocusManager#[0-9a-f]{5}\n' + r' └─rootScope: FocusScopeNode#[0-9a-f]{5}\(Root Focus Scope\)\n' + r'$', + ), + }); + }); + test('Service extensions - debugDumpRenderTree', () async { await binding.doFrame(); final Map result = await binding.testExtension(RenderingServiceExtensions.debugDumpRenderTree.name, {}); diff --git a/packages/flutter_tools/lib/src/base/command_help.dart b/packages/flutter_tools/lib/src/base/command_help.dart index fe76b7c7a3..f9922dbc21 100644 --- a/packages/flutter_tools/lib/src/base/command_help.dart +++ b/packages/flutter_tools/lib/src/base/command_help.dart @@ -97,6 +97,12 @@ class CommandHelp { 'Detach (terminate "flutter run" but leave application running).', ); + late final CommandHelpOption f = _makeOption( + 'f', + 'Dump focus tree to the console.', + 'debugDumpFocusTree', + ); + late final CommandHelpOption g = _makeOption( 'g', 'Run source code generators.' diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart index 1ec5659ed9..40c6c24d84 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -752,6 +752,22 @@ abstract class ResidentHandlers { return true; } + Future debugDumpFocusTree() async { + if (!supportsServiceProtocol || !isRunningDebug) { + return false; + } + for (final FlutterDevice? device in flutterDevices) { + final List views = await device!.vmService!.getFlutterViews(); + for (final FlutterView view in views) { + final String data = await device.vmService!.flutterDebugDumpFocusTree( + isolateId: view.uiIsolate!.id!, + ); + logger.printStatus(data); + } + } + return true; + } + /// Dump the application's current semantics tree to the terminal. /// /// If semantics are not enabled, nothing is returned. @@ -1521,6 +1537,7 @@ abstract class ResidentRunner extends ResidentHandlers { commandHelp.t.print(); if (isRunningDebug) { commandHelp.L.print(); + commandHelp.f.print(); commandHelp.S.print(); commandHelp.U.print(); commandHelp.i.print(); @@ -1706,6 +1723,8 @@ class TerminalHandler { case 'D': await residentRunner.detach(); return true; + case 'f': + return residentRunner.debugDumpFocusTree(); case 'g': await residentRunner.runSourceGenerators(); return true; diff --git a/packages/flutter_tools/lib/src/vmservice.dart b/packages/flutter_tools/lib/src/vmservice.dart index 163c711481..aee03df7bf 100644 --- a/packages/flutter_tools/lib/src/vmservice.dart +++ b/packages/flutter_tools/lib/src/vmservice.dart @@ -643,6 +643,16 @@ class FlutterVmService { return response?['data']?.toString() ?? ''; } + Future flutterDebugDumpFocusTree({ + required String isolateId, + }) async { + final Map? response = await invokeFlutterExtensionRpcRaw( + 'ext.flutter.debugDumpFocusTree', + isolateId: isolateId, + ); + return response?['data']?.toString() ?? ''; + } + Future flutterDebugDumpSemanticsTreeInTraversalOrder({ required String isolateId, }) async { diff --git a/packages/flutter_tools/test/general.shard/base/command_help_test.dart b/packages/flutter_tools/test/general.shard/base/command_help_test.dart index 3fbf80afda..43451e2f42 100644 --- a/packages/flutter_tools/test/general.shard/base/command_help_test.dart +++ b/packages/flutter_tools/test/general.shard/base/command_help_test.dart @@ -60,6 +60,7 @@ void _testMessageLength({ expect(commandHelp.b.toString().length, lessThanOrEqualTo(expectedWidth)); expect(commandHelp.c.toString().length, lessThanOrEqualTo(expectedWidth)); expect(commandHelp.d.toString().length, lessThanOrEqualTo(expectedWidth)); + expect(commandHelp.f.toString().length, lessThanOrEqualTo(expectedWidth)); expect(commandHelp.g.toString().length, lessThanOrEqualTo(expectedWidth)); expect(commandHelp.hWithDetails.toString().length, lessThanOrEqualTo(expectedWidth)); expect(commandHelp.hWithoutDetails.toString().length, lessThanOrEqualTo(expectedWidth)); @@ -137,6 +138,7 @@ void main() { expect(commandHelp.U.toString(), endsWith('\x1B[90m(debugDumpSemantics)\x1B[39m\x1B[22m')); expect(commandHelp.a.toString(), endsWith('\x1B[90m(debugProfileWidgetBuilds)\x1B[39m\x1B[22m')); expect(commandHelp.b.toString(), endsWith('\x1B[90m(debugBrightnessOverride)\x1B[39m\x1B[22m')); + expect(commandHelp.f.toString(), endsWith('\x1B[90m(debugDumpFocusTree)\x1B[39m\x1B[22m')); expect(commandHelp.i.toString(), endsWith('\x1B[90m(WidgetsApp.showWidgetInspectorOverride)\x1B[39m\x1B[22m')); expect(commandHelp.o.toString(), endsWith('\x1B[90m(defaultTargetPlatform)\x1B[39m\x1B[22m')); expect(commandHelp.p.toString(), endsWith('\x1B[90m(debugPaintSizeEnabled)\x1B[39m\x1B[22m')); @@ -193,6 +195,7 @@ void main() { expect(commandHelp.b.toString(), equals('\x1B[1mb\x1B[22m Toggle platform brightness (dark and light mode). \x1B[90m(debugBrightnessOverride)\x1B[39m\x1B[22m')); expect(commandHelp.c.toString(), equals('\x1B[1mc\x1B[22m Clear the screen')); expect(commandHelp.d.toString(), equals('\x1B[1md\x1B[22m Detach (terminate "flutter run" but leave application running).')); + expect(commandHelp.f.toString(), equals('\x1B[1mf\x1B[22m Dump focus tree to the console. \x1B[90m(debugDumpFocusTree)\x1B[39m\x1B[22m')); expect(commandHelp.g.toString(), equals('\x1B[1mg\x1B[22m Run source code generators.')); expect(commandHelp.hWithDetails.toString(), equals('\x1B[1mh\x1B[22m Repeat this help message.')); expect(commandHelp.hWithoutDetails.toString(), equals('\x1B[1mh\x1B[22m List all available interactive commands.')); diff --git a/packages/flutter_tools/test/general.shard/resident_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_runner_test.dart index 464ce3a336..54e1ae297e 100644 --- a/packages/flutter_tools/test/general.shard/resident_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_runner_test.dart @@ -1455,6 +1455,7 @@ flutter: commandHelp.w, commandHelp.t, commandHelp.L, + commandHelp.f, commandHelp.S, commandHelp.U, commandHelp.i, diff --git a/packages/flutter_tools/test/general.shard/terminal_handler_test.dart b/packages/flutter_tools/test/general.shard/terminal_handler_test.dart index 09d46bc9c8..246865bd06 100644 --- a/packages/flutter_tools/test/general.shard/terminal_handler_test.dart +++ b/packages/flutter_tools/test/general.shard/terminal_handler_test.dart @@ -400,6 +400,52 @@ void main() { await terminalHandler.processTerminalInput('L'); }); + testWithoutContext('f - debugDumpFocusTree', () async { + final TerminalHandler terminalHandler = setUpTerminalHandler([ + listViews, + const FakeVmServiceRequest( + method: 'ext.flutter.debugDumpFocusTree', + args: { + 'isolateId': '1', + }, + jsonResponse: { + 'data': 'FOCUS TREE', + } + ), + ]); + await terminalHandler.processTerminalInput('f'); + + expect(terminalHandler.logger.statusText, contains('FOCUS TREE')); + }); + + testWithoutContext('f - debugDumpLayerTree with web target', () async { + final TerminalHandler terminalHandler = setUpTerminalHandler([ + listViews, + const FakeVmServiceRequest( + method: 'ext.flutter.debugDumpFocusTree', + args: { + 'isolateId': '1', + }, + jsonResponse: { + 'data': 'FOCUS TREE', + } + ), + ], web: true); + await terminalHandler.processTerminalInput('f'); + + expect(terminalHandler.logger.statusText, contains('FOCUS TREE')); + }); + + testWithoutContext('f - debugDumpFocusTree with service protocol and profile mode is skipped', () async { + final TerminalHandler terminalHandler = setUpTerminalHandler([], buildMode: BuildMode.profile); + await terminalHandler.processTerminalInput('f'); + }); + + testWithoutContext('f - debugDumpFocusTree without service protocol is skipped', () async { + final TerminalHandler terminalHandler = setUpTerminalHandler([], supportsServiceProtocol: false); + await terminalHandler.processTerminalInput('f'); + }); + testWithoutContext('o,O - debugTogglePlatform', () async { final TerminalHandler terminalHandler = setUpTerminalHandler([ // Request 1. diff --git a/packages/flutter_tools/test/general.shard/vmservice_test.dart b/packages/flutter_tools/test/general.shard/vmservice_test.dart index 6f7b562df9..59801da86d 100644 --- a/packages/flutter_tools/test/general.shard/vmservice_test.dart +++ b/packages/flutter_tools/test/general.shard/vmservice_test.dart @@ -447,6 +447,46 @@ void main() { expect(fakeVmServiceHost.hasRemainingExpectations, false); }); + testWithoutContext('flutterDebugDumpFocusTree handles missing method', () async { + final FakeVmServiceHost fakeVmServiceHost = FakeVmServiceHost( + requests: [ + const FakeVmServiceRequest( + method: 'ext.flutter.debugDumpFocusTree', + args: { + 'isolateId': '1', + }, + errorCode: RPCErrorCodes.kMethodNotFound, + ), + ] + ); + + expect(await fakeVmServiceHost.vmService.flutterDebugDumpFocusTree( + isolateId: '1', + ), ''); + expect(fakeVmServiceHost.hasRemainingExpectations, false); + }); + + testWithoutContext('flutterDebugDumpFocusTree returns data', () async { + final FakeVmServiceHost fakeVmServiceHost = FakeVmServiceHost( + requests: [ + const FakeVmServiceRequest( + method: 'ext.flutter.debugDumpFocusTree', + args: { + 'isolateId': '1', + }, + jsonResponse: { + 'data': 'Hello world', + }, + ), + ] + ); + + expect(await fakeVmServiceHost.vmService.flutterDebugDumpFocusTree( + isolateId: '1', + ), 'Hello world'); + expect(fakeVmServiceHost.hasRemainingExpectations, false); + }); + testWithoutContext('Framework service extension invocations return null if service disappears ', () async { final FakeVmServiceHost fakeVmServiceHost = FakeVmServiceHost( requests: [ diff --git a/packages/flutter_tools/test/integration.shard/overall_experience_test.dart b/packages/flutter_tools/test/integration.shard/overall_experience_test.dart index 3ee5ecdf73..5b2c140006 100644 --- a/packages/flutter_tools/test/integration.shard/overall_experience_test.dart +++ b/packages/flutter_tools/test/integration.shard/overall_experience_test.dart @@ -604,6 +604,7 @@ void main() { 'w Dump widget hierarchy to the console. (debugDumpApp)', 't Dump rendering tree to the console. (debugDumpRenderTree)', 'L Dump layer tree to the console. (debugDumpLayerTree)', + 'f Dump focus tree to the console. (debugDumpFocusTree)', 'S Dump accessibility tree in traversal order. (debugDumpSemantics)', 'U Dump accessibility tree in inverse hit test order. (debugDumpSemantics)', 'i Toggle widget inspector. (WidgetsApp.showWidgetInspectorOverride)',