From 1f93809ad25090a0e5b6f5b8b690e2e6e2c78ef7 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Tue, 11 Jun 2024 22:15:34 -0700 Subject: [PATCH] Add new `WidgetInspector` service extension: `getRootWidgetTree` (#150010) The new service extension `getRootWidgetTree` can be used instead of the existing: * `getRootWidgetSummaryTree` --> use`getRootWidgetTree` with parameters `isSummaryTree=true` * `getRootWidgetSummaryTreeWithPreviews` --> use `getRootWidgetTree` with parameters `isSummaryTree=true` and `withPreviews=true` This new service extension will enable Flutter DevTools to combine the widget summary tree with the widget details tree by calling `getRootWidgetTree` with `isSummary=false` and `withPreviews=true`. Closes https://github.com/flutter/devtools/issues/7894 --- .../lib/src/widgets/service_extensions.dart | 16 + .../lib/src/widgets/widget_inspector.dart | 102 ++++-- .../foundation/service_extensions_test.dart | 2 +- .../test/widgets/widget_inspector_test.dart | 331 +++++++++++++++--- 4 files changed, 386 insertions(+), 65 deletions(-) diff --git a/packages/flutter/lib/src/widgets/service_extensions.dart b/packages/flutter/lib/src/widgets/service_extensions.dart index 54971b59c3..516a8fdd20 100644 --- a/packages/flutter/lib/src/widgets/service_extensions.dart +++ b/packages/flutter/lib/src/widgets/service_extensions.dart @@ -366,6 +366,22 @@ enum WidgetInspectorServiceExtensions { /// extension is registered. getRootWidget, + /// Name of service extension that, when called, will return the + /// [DiagnosticsNode] data for the root [Element] of the widget tree. + /// + /// If the parameter `isSummaryTree` is true, the tree will only include + /// [Element]s that were created by user code. + /// + /// If the parameter `withPreviews` is true, text previews will be included + /// for [Element]s with a corresponding [RenderObject] of type + /// [RenderParagraph]. + /// + /// See also: + /// + /// * [WidgetInspectorService.initServiceExtensions], where the service + /// extension is registered. + getRootWidgetTree, + /// Name of service extension that, when called, will return the /// [DiagnosticsNode] data for the root [Element] of the summary tree, which /// only includes [Element]s that were created by user code. diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index 5b284201fd..686b3fd197 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -1255,6 +1255,11 @@ mixin WidgetInspectorService { callback: _getRootWidgetSummaryTreeWithPreviews, registerExtension: registerExtension, ); + registerServiceExtension( + name: WidgetInspectorServiceExtensions.getRootWidgetTree.name, + callback: _getRootWidgetTree, + registerExtension: registerExtension, + ); registerServiceExtension( name: WidgetInspectorServiceExtensions.getDetailsSubtree.name, callback: (Map parameters) async { @@ -1951,42 +1956,93 @@ mixin WidgetInspectorService { String groupName, { Map? Function(DiagnosticsNode, InspectorSerializationDelegate)? addAdditionalPropertiesCallback, }) { - return _nodeToJson( - WidgetsBinding.instance.rootElement?.toDiagnosticsNode(), - InspectorSerializationDelegate( - groupName: groupName, - subtreeDepth: 1000000, - summaryTree: true, - service: this, - addAdditionalPropertiesCallback: addAdditionalPropertiesCallback, - ), + return _getRootWidgetTreeImpl( + groupName: groupName, + isSummaryTree: true, + withPreviews: false, + addAdditionalPropertiesCallback: addAdditionalPropertiesCallback, ); } - Future> _getRootWidgetSummaryTreeWithPreviews( Map parameters, ) { final String groupName = parameters['groupName']!; - final Map? result = _getRootWidgetSummaryTree( - groupName, - addAdditionalPropertiesCallback: (DiagnosticsNode node, InspectorSerializationDelegate? delegate) { - final Map additionalJson = {}; - final Object? value = node.value; - if (value is Element) { - final RenderObject? renderObject = value.renderObject; - if (renderObject is RenderParagraph) { - additionalJson['textPreview'] = renderObject.text.toPlainText(); - } - } - return additionalJson; - }, + final Map? result = _getRootWidgetTreeImpl( + groupName: groupName, + isSummaryTree: true, + withPreviews: true, ); return Future>.value({ 'result': result, }); } + Future> _getRootWidgetTree( + Map parameters, + ) { + final String groupName = parameters['groupName']!; + final bool isSummaryTree = parameters['isSummaryTree'] == 'true'; + final bool withPreviews = parameters['withPreviews'] == 'true'; + + final Map? result = _getRootWidgetTreeImpl( + groupName: groupName, + isSummaryTree: isSummaryTree, + withPreviews: withPreviews, + ); + + return Future>.value({ + 'result': result, + }); + } + + Map? _getRootWidgetTreeImpl({ + required String groupName, + required bool isSummaryTree, + required bool withPreviews, + Map? Function( + DiagnosticsNode, InspectorSerializationDelegate)? + addAdditionalPropertiesCallback, + }) { + final bool shouldAddAdditionalProperties = + addAdditionalPropertiesCallback != null || withPreviews; + + // Combine the given addAdditionalPropertiesCallback with logic to add text + // previews as well (if withPreviews is true): + Map? combinedAddAdditionalPropertiesCallback( + DiagnosticsNode node, + InspectorSerializationDelegate delegate, + ) { + final Map additionalPropertiesJson = + addAdditionalPropertiesCallback?.call(node, delegate) ?? + {}; + if (!withPreviews) { + return additionalPropertiesJson; + } + final Object? value = node.value; + if (value is Element) { + final RenderObject? renderObject = value.renderObject; + if (renderObject is RenderParagraph) { + additionalPropertiesJson['textPreview'] = + renderObject.text.toPlainText(); + } + } + return additionalPropertiesJson; + } + return _nodeToJson( + WidgetsBinding.instance.rootElement?.toDiagnosticsNode(), + InspectorSerializationDelegate( + groupName: groupName, + subtreeDepth: 1000000, + summaryTree: isSummaryTree, + service: this, + addAdditionalPropertiesCallback: shouldAddAdditionalProperties + ? combinedAddAdditionalPropertiesCallback + : null, + ), + ); + } + /// Returns a JSON representation of the subtree rooted at the /// [DiagnosticsNode] object that `diagnosticsNodeId` references providing /// information needed for the details subtree view. diff --git a/packages/flutter/test/foundation/service_extensions_test.dart b/packages/flutter/test/foundation/service_extensions_test.dart index 56ff355dda..0d395bd8b9 100644 --- a/packages/flutter/test/foundation/service_extensions_test.dart +++ b/packages/flutter/test/foundation/service_extensions_test.dart @@ -166,7 +166,7 @@ void main() { tearDownAll(() async { // See widget_inspector_test.dart for tests of the ext.flutter.inspector // service extensions included in this count. - int widgetInspectorExtensionCount = 28; + int widgetInspectorExtensionCount = 29; if (WidgetInspectorService.instance.isWidgetCreationTracked()) { // Some inspector extensions are only exposed if widget creation locations // are tracked. diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart index 209c76d9e9..59c95fbf7f 100644 --- a/packages/flutter/test/widgets/widget_inspector_test.dart +++ b/packages/flutter/test/widgets/widget_inspector_test.dart @@ -1977,6 +1977,27 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { ))! as List; } + /// Returns whether the child was created by the local project. + bool wasCreatedByLocalProject(Map childJson) { + return childJson['createdByLocalProject'] == true; + } + + /// Returns whether the child has a description matching [description]. + bool hasDescription( + Map childJson, { + required String description, + }) { + return childJson['description'] == description; + } + + /// Returns whether the child has a text preview matching [preview]. + bool hasTextPreview( + Map childJson, { + required String preview, + }) { + return childJson['textPreview'] == preview; + } + /// Verifies that the children from the JSON response are identical to /// those from [WidgetInspectorServiceExtensions.getChildrenSummaryTree]. Future verifyChildrenMatchOtherApi(Map jsonResponse, @@ -2044,7 +2065,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { // If the tree was requested with previews, then check that the // child has the `textPreview` key: if (checkForPreviews) { - expect(child['textPreview'], equals('c')); + expect(hasTextPreview(child, preview: 'c'), isTrue); } // Get the first child's first child's third child's children. @@ -2057,46 +2078,184 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { expect(childrenFromOtherApi.length, equals(children.length)); } - testWidgets('ext.flutter.inspector.getRootWidgetSummaryTree', + bool allChildrenSatisfyCondition(Map treeRoot, + { + required bool Function(Map child) condition, + }) { + final List children = childrenFromJsonResponse(treeRoot); + for (int childIdx = 0; childIdx < children.length; childIdx++) { + final Map child = + children[childIdx]! as Map; + if (!condition(child)) { + return false; + } + if (!allChildrenSatisfyCondition(child, condition: condition)) { + return false; + } + } + + return true; + } + + bool oneChildSatisfiesCondition(Map treeRoot, + { + required bool Function(Map child) condition, + }) { + final List children = childrenFromJsonResponse(treeRoot); + for (int childIdx = 0; childIdx < children.length; childIdx++) { + final Map child = + children[childIdx]! as Map; + if (condition(child)) { + return true; + } + if (oneChildSatisfiesCondition(child, condition: condition)) { + return true; + } + } + + return false; + } + + /// Determines which API to call to get the summary tree. + String getExtensionApiToCall({ + required bool useGetRootWidgetTreeApi, + required bool withPreviews, + }) { + if (useGetRootWidgetTreeApi) { + return WidgetInspectorServiceExtensions.getRootWidgetTree.name; + } else if (withPreviews) { + return WidgetInspectorServiceExtensions + .getRootWidgetSummaryTreeWithPreviews.name; + } else { + return WidgetInspectorServiceExtensions + .getRootWidgetSummaryTree.name; + } + } + + /// Determines which parameters to use for the summary tree API call. + Map getExtensionApiParams({ + required bool useGetRootWidgetTreeApi, + required String groupName, + required bool withPreviews, + }) { + if (useGetRootWidgetTreeApi) { + return { + 'groupName': groupName, + 'isSummaryTree': 'true', + 'withPreviews': '$withPreviews', + }; + } else if (withPreviews) { + return {'groupName': groupName}; + } else { + return {'objectGroup': groupName}; + } + } + + for (final bool useGetRootWidgetTreeApi in [true, false]) { + final String extensionApiNoPreviews = getExtensionApiToCall( + useGetRootWidgetTreeApi: useGetRootWidgetTreeApi, + withPreviews: false, + ); + final String extensionApiWithPreviews = getExtensionApiToCall( + useGetRootWidgetTreeApi: useGetRootWidgetTreeApi, + withPreviews: true, + ); + + testWidgets( + 'summary tree using ext.flutter.inspector.$extensionApiNoPreviews', + (WidgetTester tester) async { + const String group = 'test-group'; + await pumpWidgetTreeWithABC(tester); + final Element elementA = findElementABC('a'); + final Map jsonA = + await selectedWidgetResponseForElement(elementA); + + service.resetPubRootDirectories(); + + Map rootJson = (await service.testExtension( + extensionApiNoPreviews, + getExtensionApiParams( + useGetRootWidgetTreeApi: useGetRootWidgetTreeApi, + groupName: group, + withPreviews: false, + ), + ))! as Map; + + // We haven't yet properly specified which directories are summary tree + // directories so we get an empty tree other than the root that is always + // included. + final Object? rootWidget = + service.toObject(rootJson['valueId']! as String); + expect(rootWidget, equals(WidgetsBinding.instance.rootElement)); + final List childrenJson = + rootJson['children']! as List; + // There are no summary tree children. + expect(childrenJson.length, equals(0)); + + final Map creationLocation = + verifyAndReturnCreationLocation(jsonA); + final String testFile = verifyAndReturnTestFile(creationLocation); + addPubRootDirectoryFor(testFile); + + rootJson = (await service.testExtension( + extensionApiNoPreviews, + getExtensionApiParams( + useGetRootWidgetTreeApi: useGetRootWidgetTreeApi, + groupName: group, + withPreviews: false, + ), + ))! as Map; + + expect( + allChildrenSatisfyCondition(rootJson, + condition: wasCreatedByLocalProject, + ), + isTrue, + ); + await verifyChildrenMatchOtherApi(rootJson, group: group); + }); + + testWidgets( + 'summary tree with previews using ext.flutter.inspector.$extensionApiWithPreviews', (WidgetTester tester) async { - const String group = 'test-group'; - await pumpWidgetTreeWithABC(tester); - final Element elementA = findElementABC('a'); - final Map jsonA = - await selectedWidgetResponseForElement(elementA); + const String group = 'test-group'; - service.resetPubRootDirectories(); - Map rootJson = (await service.testExtension( - WidgetInspectorServiceExtensions.getRootWidgetSummaryTree.name, - {'objectGroup': group}, - ))! as Map; + await pumpWidgetTreeWithABC(tester); + final Element elementA = findElementABC('a'); + final Map jsonA = + await selectedWidgetResponseForElement(elementA); - // We haven't yet properly specified which directories are summary tree - // directories so we get an empty tree other than the root that is always - // included. - final Object? rootWidget = - service.toObject(rootJson['valueId']! as String); - expect(rootWidget, equals(WidgetsBinding.instance.rootElement)); - final List childrenJson = - rootJson['children']! as List; - // There are no summary tree children. - expect(childrenJson.length, equals(0)); + final Map creationLocation = + verifyAndReturnCreationLocation(jsonA); + final String testFile = verifyAndReturnTestFile(creationLocation); + addPubRootDirectoryFor(testFile); - final Map creationLocation = - verifyAndReturnCreationLocation(jsonA); - final String testFile = verifyAndReturnTestFile(creationLocation); - addPubRootDirectoryFor(testFile); + final Map rootJson = + (await service.testExtension( + extensionApiWithPreviews, + getExtensionApiParams( + useGetRootWidgetTreeApi: useGetRootWidgetTreeApi, + groupName: group, + withPreviews: true, + ), + ))! as Map; - rootJson = (await service.testExtension( - WidgetInspectorServiceExtensions.getRootWidgetSummaryTree.name, - {'objectGroup': group}, - ))! as Map; - - await verifyChildrenMatchOtherApi(rootJson, group: group); - }); + expect( + allChildrenSatisfyCondition(rootJson, + condition: wasCreatedByLocalProject, + ), + isTrue, + ); + await verifyChildrenMatchOtherApi( + rootJson, + group: group, + checkForPreviews: true, + ); + }); + } testWidgets( - 'ext.flutter.inspector.getRootWidgetSummaryTreeWithPreviews', + 'full tree using ext.flutter.inspector.getRootWidgetTree', (WidgetTester tester) async { const String group = 'test-group'; @@ -2111,15 +2270,105 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { addPubRootDirectoryFor(testFile); final Map rootJson = (await service.testExtension( - WidgetInspectorServiceExtensions - .getRootWidgetSummaryTreeWithPreviews.name, - {'groupName': group}, + WidgetInspectorServiceExtensions.getRootWidgetTree.name, + { + 'groupName': group, + 'isSummaryTree': 'false', + 'withPreviews': 'false', + }, ))! as Map; - await verifyChildrenMatchOtherApi( - rootJson, - group: group, - checkForPreviews: true, + expect( + allChildrenSatisfyCondition(rootJson, + condition: wasCreatedByLocalProject, + ), + isFalse, + ); + expect( + oneChildSatisfiesCondition(rootJson, condition: (Map child) { + return hasDescription(child, description: 'Text') && + wasCreatedByLocalProject(child) && + !hasTextPreview(child, preview: 'a'); + }, + ), + isTrue, + ); + expect( + oneChildSatisfiesCondition(rootJson, condition: (Map child) { + return hasDescription(child, description: 'Text') && + wasCreatedByLocalProject(child) && + !hasTextPreview(child, preview: 'b'); + }, + ), + isTrue, + ); + expect( + oneChildSatisfiesCondition(rootJson, condition: (Map child) { + return hasDescription(child, description: 'Text') && + wasCreatedByLocalProject(child) && + !hasTextPreview(child, preview: 'c'); + }, + ), + isTrue, + ); + }); + + testWidgets( + 'full tree with previews using ext.flutter.inspector.getRootWidgetTree', + (WidgetTester tester) async { + const String group = 'test-group'; + + await pumpWidgetTreeWithABC(tester); + final Element elementA = findElementABC('a'); + final Map jsonA = + await selectedWidgetResponseForElement(elementA); + + final Map creationLocation = + verifyAndReturnCreationLocation(jsonA); + final String testFile = verifyAndReturnTestFile(creationLocation); + addPubRootDirectoryFor(testFile); + + final Map rootJson = (await service.testExtension( + WidgetInspectorServiceExtensions.getRootWidgetTree.name, + { + 'groupName': group, + 'isSummaryTree': 'false', + 'withPreviews': 'true', + }, + ))! as Map; + + expect( + allChildrenSatisfyCondition(rootJson, + condition: wasCreatedByLocalProject, + ), + isFalse, + ); + expect( + oneChildSatisfiesCondition(rootJson, condition: (Map child) { + return hasDescription(child, description: 'Text') && + wasCreatedByLocalProject(child) && + hasTextPreview(child, preview: 'a'); + }, + ), + isTrue, + ); + expect( + oneChildSatisfiesCondition(rootJson, condition: (Map child) { + return hasDescription(child, description: 'Text') && + wasCreatedByLocalProject(child) && + hasTextPreview(child, preview: 'b'); + }, + ), + isTrue, + ); + expect( + oneChildSatisfiesCondition(rootJson, condition: (Map child) { + return hasDescription(child, description: 'Text') && + wasCreatedByLocalProject(child) && + hasTextPreview(child, preview: 'c'); + }, + ), + isTrue, ); }); });