[A11y] Add semantics tree UI (#9982)
tracking issue: https://github.com/flutter/devtools/issues/9893
Added the tree UI
<img width="1341" height="906" alt="Screenshot 2026-08-27 at 16 59 03" src="https://github.com/user-attachments/assets/23cb465d-48c7-4f79-a916-8524f5b3d6a8" />
### General checklist
### Issues checklist
### Tests checklist
### AI-tooling checklist
* [ ] I read the [AI contributions guidelines] and agree to follow them.
* [ ] I reviewed all AI-generated code before opening this PR.
* [ ] I understand and am able to discuss the code in this PR.
* [ ] I have verifed the accuracy of any AI-generated text included in the PR description.
* [ ] I commit to verifying the accuracy of any AI-generated code or text that I upload in response to review comments.
### Feature-change checklist
* [ ] I added the `release-notes-not-required` label or left a comment requesting the label be added.
* [ ] I added an entry to `packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md`.
* [ ] I included before/after screenshots and/or a GIF demo of the new UI to my PR description.
* [ ] I ran the DevTools app locally to manually verify my changes.
![build.yaml badge]
If you need help, consider asking for help on [Discord].
[`contributions-welcome`]: https://github.com/flutter/devtools/issues?q=state%3Aopen%20label%3Acontributions-welcome
[`good-first-issue`]: https://github.com/flutter/devtools/issues?q=state%3Aopen%20label%3Agood-first-issue
[build.yaml badge]: https://github.com/flutter/devtools/actions/workflows/build.yaml/badge.svgdiff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart
index 8088054..5fd07a4 100644
--- a/packages/devtools_app/lib/devtools_app.dart
+++ b/packages/devtools_app/lib/devtools_app.dart
@@ -12,6 +12,7 @@
export 'src/framework/scaffold/status_line.dart';
export 'src/screens/accessibility/accessibility_controller.dart';
export 'src/screens/accessibility/accessibility_screen.dart';
+export 'src/screens/accessibility/semantics_node_model.dart';
export 'src/screens/app_size/app_size_controller.dart';
export 'src/screens/app_size/app_size_screen.dart';
export 'src/screens/debugger/breakpoint_manager.dart';
diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
index 1fd71a3..573756a 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
@@ -7,11 +7,17 @@
import 'package:devtools_app_shared/service.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart' show ScrollController;
+import 'package:logging/logging.dart';
import '../../service/service_extensions.dart' as extensions;
+import '../../service/service_registrations.dart' as registrations;
import '../../shared/framework/screen.dart';
import '../../shared/framework/screen_controllers.dart';
import '../../shared/globals.dart';
+import 'semantics_node_model.dart';
+
+final _log = Logger('accessibility_controller');
/// Modes for brightness override in the accessibility controls.
enum BrightnessOverride {
@@ -40,6 +46,7 @@
void init() {
super.init();
_initServiceExtensionStates();
+ _initSemanticsTree();
}
void _initListeners() {
@@ -50,6 +57,37 @@
addAutoDisposeListener(highContrast, _onHighContrastChanged);
}
+ void _initSemanticsTree() {
+ if (serviceConnection.serviceManager.isolateManager.mainIsolate.value !=
+ null) {
+ unawaited(_autoLoadSemanticsTreeIfNeeded());
+ }
+ addAutoDisposeListener(
+ serviceConnection.serviceManager.isolateManager.mainIsolate,
+ () {
+ if (serviceConnection.serviceManager.isolateManager.mainIsolate.value !=
+ null) {
+ // Clear stale data from a previous isolate so the guard in
+ // _autoLoadSemanticsTreeIfNeeded doesn't skip the new load.
+ semanticsRoots.value = [];
+ semanticsTreeError.value = null;
+ unawaited(_autoLoadSemanticsTreeIfNeeded());
+ } else {
+ semanticsRoots.value = [];
+ semanticsTreeError.value = null;
+ }
+ },
+ );
+ }
+
+ Future<void> _autoLoadSemanticsTreeIfNeeded() async {
+ if (semanticsRoots.value.isEmpty &&
+ semanticsTreeError.value == null &&
+ !semanticsTreeLoading.value) {
+ await loadSemanticsTree();
+ }
+ }
+
void _initServiceExtensionStates() {
final state = serviceConnection.serviceManager.serviceExtensionManager
.getServiceExtensionState(extensions.brightnessMode.extension);
@@ -109,13 +147,144 @@
final screenReader = ValueNotifier<bool>(false);
final highContrast = ValueNotifier<bool>(false);
+ final semanticsRoots = ValueNotifier<List<SemanticsNodeModel>>([]);
+ final semanticsTreeLoading = ValueNotifier<bool>(false);
+ final semanticsTreeError = ValueNotifier<String?>(null);
+ final treeScrollController = ScrollController();
+
+ Future<void> loadSemanticsTree() async {
+ if (semanticsTreeLoading.value) return;
+
+ final mainIsolate =
+ serviceConnection.serviceManager.isolateManager.mainIsolate.value;
+ if (mainIsolate == null) {
+ semanticsTreeError.value =
+ 'Failed to load semantics tree: no connected application.';
+ return;
+ }
+
+ semanticsTreeLoading.value = true;
+ semanticsTreeError.value = null;
+
+ try {
+ await serviceConnection.serviceManager.callServiceExtensionOnMainIsolate(
+ registrations.enableSemantics,
+ args: {'enabled': 'true'},
+ );
+
+ final response = await serviceConnection.serviceManager
+ .callServiceExtensionOnMainIsolate(registrations.getSemanticsTree);
+
+ final json = response.json;
+ if (json != null && json.containsKey('error')) {
+ throw Exception(json['error']);
+ }
+
+ final rawData = json?['data'];
+ if (rawData == null) {
+ throw Exception(
+ 'Empty semantics tree returned from service extension.',
+ );
+ }
+
+ final roots = <SemanticsNodeModel>[];
+ if (rawData is Map<String, dynamic>) {
+ if (rawData.isNotEmpty) {
+ final rootId = rawData.containsKey('0')
+ ? '0'
+ : rawData.keys.first.toString();
+ roots.add(_buildTreeFromNodesMap(rootId, rawData, <String>{}));
+ }
+ }
+
+ if (roots.isEmpty) {
+ throw Exception('No semantics nodes found in response.');
+ }
+
+ for (final root in roots) {
+ root.expandCascading();
+ }
+ semanticsRoots.value = roots;
+ semanticsTreeError.value = null;
+ } catch (e, st) {
+ _log.warning('Error loading semantics tree: $e', e, st);
+ semanticsRoots.value = [];
+ semanticsTreeError.value = 'Failed to load semantics tree: $e';
+ } finally {
+ if (!disposed) {
+ semanticsTreeLoading.value = false;
+ }
+ }
+ }
+
+ SemanticsNodeModel _buildTreeFromNodesMap(
+ String nodeId,
+ Map<String, dynamic> nodesMap,
+ Set<String> visited,
+ ) {
+ if (!visited.add(nodeId)) {
+ return SemanticsNodeModel(id: nodeId);
+ }
+
+ final json =
+ (nodesMap[nodeId] as Map<String, dynamic>?) ??
+ <String, dynamic>{'id': nodeId};
+ final node = _parseSemanticsNode(json);
+
+ final childIds =
+ (json['childrenInTraversalOrder'] as List?)
+ ?.map((e) => e.toString())
+ .toList() ??
+ (json['childrenInHitTestOrder'] as List?)
+ ?.map((e) => e.toString())
+ .toList() ??
+ const <String>[];
+
+ for (final childId in childIds) {
+ if (nodesMap.containsKey(childId)) {
+ final childNode = _buildTreeFromNodesMap(childId, nodesMap, visited);
+ node.addChild(childNode);
+ }
+ }
+
+ return node;
+ }
+
+ SemanticsNodeModel _parseSemanticsNode(Map<String, dynamic> json) {
+ final rawFlags = json['flags'] as List<Object?>?;
+ final flags = SemanticsNodeModel.parseFlags(rawFlags);
+
+ return SemanticsNodeModel(
+ id: json['id']?.toString() ?? '',
+ label: json['label']?.toString() ?? '',
+ flags: flags,
+ widgetName: json['widgetName']?.toString() ?? '',
+ );
+ }
+
@override
void dispose() {
+ unawaited(_disposeSemanticsOnApp());
brightness.dispose();
textScale.dispose();
boldText.dispose();
screenReader.dispose();
highContrast.dispose();
+ semanticsRoots.dispose();
+ semanticsTreeLoading.dispose();
+ semanticsTreeError.dispose();
+ treeScrollController.dispose();
super.dispose();
}
+
+ Future<void> _disposeSemanticsOnApp() async {
+ try {
+ if (serviceConnection.serviceManager.connectedState.value.connected) {
+ await serviceConnection.serviceManager
+ .callServiceExtensionOnMainIsolate(registrations.disposeSemantics);
+ }
+ } catch (_) {
+ // Ignore errors if the app or isolate connection is already closed.
+ }
+ }
}
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
new file mode 100644
index 0000000..2ee7209
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
@@ -0,0 +1,56 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+/// @docImport 'package:flutter/semantics.dart';
+library;
+
+import 'dart:ui' show SemanticsFlag;
+
+import '../../shared/primitives/trees.dart';
+
+/// Represents a node in the accessibility semantics tree.
+class SemanticsNodeModel extends TreeNode<SemanticsNodeModel> {
+ SemanticsNodeModel({
+ required this.id,
+ this.label = '',
+ this.flags = const <SemanticsFlag>{},
+ this.widgetName = '',
+ });
+
+ /// The semantics node identifier, as provided by the Flutter framework.
+ final String id;
+
+ /// The user-visible label announced by screen readers (maps to [SemanticsData.label]).
+ final String label;
+
+ /// Semantic flags active on this node.
+ final Set<SemanticsFlag> flags;
+
+ /// The name of the Flutter widget that produced this node, if available.
+ final String widgetName;
+
+ /// Mapping from flag name strings to [SemanticsFlag] instances.
+ static final _flagByName = <String, SemanticsFlag>{
+ for (final flag in SemanticsFlag.values) flag.name: flag,
+ };
+
+ /// Parses a list of flag name strings into a set of [SemanticsFlag]s.
+ static Set<SemanticsFlag> parseFlags(List<Object?>? rawFlags) {
+ if (rawFlags == null) return const <SemanticsFlag>{};
+ return rawFlags
+ .map((e) => _flagByName[e?.toString()])
+ .whereType<SemanticsFlag>()
+ .toSet();
+ }
+
+ @override
+ SemanticsNodeModel shallowCopy() {
+ return SemanticsNodeModel(
+ id: id,
+ label: label,
+ flags: flags,
+ widgetName: widgetName,
+ );
+ }
+}
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
index 8081874..9b97007 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
@@ -2,10 +2,17 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+import 'dart:ui' show SemanticsFlag;
+
import 'package:devtools_app_shared/ui.dart';
import 'package:flutter/material.dart';
+import '../../shared/analytics/constants.dart' as gac;
+import '../../shared/globals.dart';
import '../../shared/ui/common_widgets.dart';
+import '../../shared/ui/tree_view.dart';
+import 'accessibility_controller.dart';
+import 'semantics_node_model.dart';
/// A pane that displays the semantics tree of the connected app.
class AccessibilitySemanticsTreePane extends StatelessWidget {
@@ -13,17 +20,216 @@
@override
Widget build(BuildContext context) {
- return const DevToolsAreaPane(
- header: AreaPaneHeader(
- title: Text('Semantics Tree'),
- includeTopBorder: false,
- roundedTopBorder: false,
+ final controller = screenControllers.lookup<AccessibilityController>();
+ return ValueListenableBuilder<List<SemanticsNodeModel>>(
+ valueListenable: controller.semanticsRoots,
+ builder: (context, roots, _) {
+ return DevToolsAreaPane(
+ header: AreaPaneHeader(
+ title: const Text('Semantics Tree'),
+ includeTopBorder: false,
+ roundedTopBorder: false,
+ actions: [
+ if (roots.isNotEmpty)
+ RefreshButton(
+ iconOnly: true,
+ tooltip: 'Refresh Semantics Tree',
+ gaScreen: gac.accessibility,
+ gaSelection: gac.refresh,
+ onPressed: controller.loadSemanticsTree,
+ ),
+ ],
+ ),
+ child: ValueListenableBuilder<bool>(
+ valueListenable: controller.semanticsTreeLoading,
+ builder: (context, loading, _) {
+ if (loading) {
+ return const CenteredCircularProgressIndicator();
+ }
+ return ValueListenableBuilder<String?>(
+ valueListenable: controller.semanticsTreeError,
+ builder: (context, error, _) {
+ if (error != null) {
+ return _SemanticsTreeErrorState(
+ errorMessage: error,
+ onRetry: controller.loadSemanticsTree,
+ );
+ }
+ if (roots.isEmpty) {
+ return _SemanticsTreeEmptyState(
+ onLoad: controller.loadSemanticsTree,
+ );
+ }
+ return _SemanticsTreeContent(controller: controller);
+ },
+ );
+ },
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _SemanticsTreeEmptyState extends StatelessWidget {
+ const _SemanticsTreeEmptyState({required this.onLoad});
+
+ final VoidCallback onLoad;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const CenteredMessage(
+ message:
+ 'No semantics tree loaded. Inspect the accessibility hierarchy of the connected app.',
+ ),
+ const SizedBox(height: defaultSpacing),
+ DevToolsButton(
+ onPressed: onLoad,
+ icon: Icons.account_tree_outlined,
+ label: 'Load Semantics Tree',
+ elevated: true,
+ ),
+ ],
),
- child: CenteredMessage(
- message:
- 'Accessibility semantics tree placeholder.\n'
- '// TODO(hannah-hyj): Implement semantics tree view and details explorer.',
+ );
+ }
+}
+
+class _SemanticsTreeErrorState extends StatelessWidget {
+ const _SemanticsTreeErrorState({
+ required this.errorMessage,
+ required this.onRetry,
+ });
+
+ final String errorMessage;
+ final VoidCallback onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(defaultSpacing),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ SelectableText(
+ errorMessage,
+ style: theme.regularTextStyle.copyWith(
+ color: theme.colorScheme.error,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: defaultSpacing),
+ DevToolsButton(
+ onPressed: onRetry,
+ icon: Icons.refresh,
+ label: 'Try Again',
+ elevated: true,
+ ),
+ ],
+ ),
),
);
}
}
+
+class _SemanticsTreeContent extends StatelessWidget {
+ const _SemanticsTreeContent({required this.controller});
+
+ final AccessibilityController controller;
+
+ static IconData _iconForNode(SemanticsNodeModel node) {
+ if (node.flags.contains(SemanticsFlag.isButton)) {
+ return Icons.smart_button_rounded;
+ }
+ if (node.flags.contains(SemanticsFlag.isTextField)) {
+ return Icons.text_fields_rounded;
+ }
+ if (node.flags.contains(SemanticsFlag.isHeader)) {
+ return Icons.title_rounded;
+ }
+ if (node.flags.contains(SemanticsFlag.isSlider)) {
+ return Icons.linear_scale_rounded;
+ }
+ if (node.flags.contains(SemanticsFlag.hasCheckedState)) {
+ return Icons.check_box_outlined;
+ }
+ return Icons.widgets_outlined;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
+ return TreeView<SemanticsNodeModel>(
+ dataRootsListenable: controller.semanticsRoots,
+ scrollController: controller.treeScrollController,
+ includeScrollbar: true,
+ dataDisplayProvider: (node, onPressed) {
+ return InkWell(
+ onTap: onPressed,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: denseSpacing),
+ child: Row(
+ children: [
+ Icon(
+ _iconForNode(node),
+ size: defaultIconSize,
+ color: colorScheme.onSurface.withValues(alpha: 0.7),
+ ),
+ const SizedBox(width: denseSpacing),
+ Text(
+ 'SemanticsNode #${node.id}',
+ maxLines: 1,
+ style: theme.fixedFontStyle,
+ ),
+ if (node.label.isNotEmpty) ...[
+ const SizedBox(width: denseSpacing),
+ Flexible(
+ child: Text(
+ '"${node.label}"',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.subtleTextStyle.copyWith(
+ fontStyle: FontStyle.italic,
+ color: colorScheme.onSurface.withValues(alpha: 0.6),
+ ),
+ ),
+ ),
+ ],
+ if (node.widgetName.isNotEmpty) ...[
+ const SizedBox(width: denseSpacing),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: densePadding,
+ ),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer.withValues(
+ alpha: 0.2,
+ ),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Text(
+ node.widgetName,
+ maxLines: 1,
+ style: theme.subtleTextStyle.copyWith(
+ color: colorScheme.primary,
+ fontSize: smallFontSize,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/packages/devtools_app/lib/src/service/service_registrations.dart b/packages/devtools_app/lib/src/service/service_registrations.dart
index 2f6c0bc..958e071 100644
--- a/packages/devtools_app/lib/src/service/service_registrations.dart
+++ b/packages/devtools_app/lib/src/service/service_registrations.dart
@@ -60,3 +60,12 @@
/// Service extension that returns whether or not the Impeller rendering engine
/// is being used (if false, the app is using SKIA).
const isImpellerEnabled = 'ext.ui.window.impellerEnabled';
+
+/// Service extension to enable or disable semantics on the connected app.
+const enableSemantics = 'ext.flutter.accessibility.enableSemantics';
+
+/// Service extension to fetch the accessibility semantics tree from the connected app.
+const getSemanticsTree = 'ext.flutter.accessibility.getSemanticsTree';
+
+/// Service extension to dispose semantics on the connected app.
+const disposeSemantics = 'ext.flutter.accessibility.disposeSemantics';
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
index 1363f9d..06f3eb8 100644
--- a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
@@ -5,11 +5,17 @@
@TestOn('vm')
library;
+import 'dart:ui' show SemanticsFlag;
+
import 'package:devtools_app/devtools_app.dart';
+import 'package:devtools_app/src/service/service_registrations.dart'
+ as registrations;
import 'package:devtools_app_shared/utils.dart';
import 'package:devtools_test/devtools_test.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
+import 'package:vm_service/vm_service.dart';
void main() {
group('AccessibilityController', () {
@@ -24,6 +30,19 @@
fakeServiceConnection.serviceManager.connectedApp!.isProfileBuildNow,
).thenReturn(false);
+ fakeServiceConnection
+ .serviceManager
+ .serviceExtensionResponses[registrations.enableSemantics] =
+ Response.parse({})!;
+ fakeServiceConnection
+ .serviceManager
+ .serviceExtensionResponses[registrations.getSemanticsTree] =
+ Response.parse({
+ 'data': {
+ '0': {'id': '0', 'label': 'Root'},
+ },
+ })!;
+
setGlobal(NotificationService, NotificationService());
setGlobal(
DevToolsEnvironmentParameters,
@@ -36,7 +55,17 @@
});
test('initial state', () {
- expect(controller.brightness.value, BrightnessOverride.system);
+ final uninitializedController = AccessibilityController();
+ expect(
+ uninitializedController.brightness.value,
+ BrightnessOverride.system,
+ );
+ expect(uninitializedController.textScale.value, 1.0);
+ expect(uninitializedController.boldText.value, isFalse);
+ expect(uninitializedController.screenReader.value, isFalse);
+ expect(uninitializedController.highContrast.value, isFalse);
+ expect(uninitializedController.semanticsRoots.value, isEmpty);
+ expect(uninitializedController.semanticsTreeLoading.value, isFalse);
});
test(
@@ -114,5 +143,305 @@
expect(systemState.enabled, isFalse);
},
);
+
+ test('SemanticsNodeModel properties and shallowCopy', () {
+ final child = SemanticsNodeModel(
+ id: '1',
+ label: 'Child Node',
+ flags: {SemanticsFlag.isButton, SemanticsFlag.hasCheckedState},
+ widgetName: 'ElevatedButton',
+ );
+ final parent = SemanticsNodeModel(
+ id: '0',
+ label: 'Parent Node',
+ flags: {SemanticsFlag.isHeader},
+ widgetName: 'Column',
+ )..addChild(child);
+
+ expect(parent.children, hasLength(1));
+ expect(parent.children.first.id, equals('1'));
+
+ final copy = child.shallowCopy();
+ expect(copy.id, equals('1'));
+ expect(copy.label, equals('Child Node'));
+ expect(
+ copy.flags,
+ equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}),
+ );
+ expect(copy.widgetName, equals('ElevatedButton'));
+ expect(copy.children, isEmpty);
+ });
+
+ test(
+ 'loadSemanticsTree sets error state when no main isolate connected',
+ () async {
+ final fakeServiceConnection = _NullIsolateServiceConnectionManager();
+ setGlobal(ServiceConnectionManager, fakeServiceConnection);
+
+ final testController = AccessibilityController();
+ expect(testController.semanticsTreeError.value, isNull);
+ await testController.loadSemanticsTree();
+ expect(
+ testController.semanticsTreeError.value,
+ equals('Failed to load semantics tree: no connected application.'),
+ );
+ expect(testController.semanticsTreeLoading.value, isFalse);
+ expect(testController.semanticsRoots.value, isEmpty);
+ },
+ );
+
+ test(
+ 'loadSemanticsTree sets error state when service extension returns error',
+ () async {
+ final fakeServiceManager =
+ serviceConnection.serviceManager as FakeServiceManager;
+ fakeServiceManager.serviceExtensionResponses[registrations
+ .enableSemantics] = Response.parse(
+ {},
+ )!;
+ fakeServiceManager.serviceExtensionResponses[registrations
+ .getSemanticsTree] = Response.parse({
+ 'error': 'Semantics not enabled.',
+ })!;
+
+ final testController = AccessibilityController();
+ await testController.loadSemanticsTree();
+
+ expect(
+ testController.semanticsTreeError.value,
+ equals(
+ 'Failed to load semantics tree: Exception: Semantics not enabled.',
+ ),
+ );
+ expect(testController.semanticsRoots.value, isEmpty);
+ },
+ );
+
+ test(
+ 'loadSemanticsTree parses full SemanticsNode.toJson format with multiple nodes',
+ () async {
+ final fakeServiceManager =
+ serviceConnection.serviceManager as FakeServiceManager;
+ fakeServiceManager.serviceExtensionResponses[registrations
+ .enableSemantics] = Response.parse(
+ {},
+ )!;
+ fakeServiceManager.serviceExtensionResponses[registrations
+ .getSemanticsTree] = Response.parse({
+ 'data': {
+ '0': {
+ 'id': 0,
+ 'label': 'Root View',
+ 'value': 'Main Screen',
+ 'hint': '',
+ 'tooltip': '',
+ 'increasedValue': '',
+ 'decreasedValue': '',
+ 'flags': ['hasEnabledState', 'isEnabled'],
+ 'actions': [],
+ 'rect': {
+ 'left': 0.0,
+ 'top': 0.0,
+ 'width': 390.0,
+ 'height': 844.0,
+ },
+ 'transform': [
+ 1.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ],
+ 'childrenInTraversalOrder': [1, 2],
+ 'childrenInHitTestOrder': [2, 1],
+ },
+ '1': {
+ 'id': 1,
+ 'label': 'Settings Header',
+ 'flags': ['isHeader'],
+ 'actions': [],
+ 'rect': {
+ 'left': 16.0,
+ 'top': 40.0,
+ 'width': 358.0,
+ 'height': 32.0,
+ },
+ },
+ '2': {
+ 'id': 2,
+ 'label': 'Search Input',
+ 'value': 'Flutter',
+ 'hint': 'Enter search query',
+ 'tooltip': 'Search field',
+ 'flags': ['isTextField'],
+ 'actions': ['tap', 'setSelection'],
+ 'rect': {
+ 'left': 16.0,
+ 'top': 88.0,
+ 'width': 358.0,
+ 'height': 48.0,
+ },
+ 'childrenInTraversalOrder': [3],
+ 'childrenInHitTestOrder': [3],
+ },
+ '3': {
+ 'id': 3,
+ 'label': 'Clear Text',
+ 'tooltip': 'Clear input content',
+ 'flags': ['isButton', 'hasCheckedState'],
+ 'actions': ['tap'],
+ 'rect': {
+ 'left': 330.0,
+ 'top': 96.0,
+ 'width': 32.0,
+ 'height': 32.0,
+ },
+ },
+ },
+ })!;
+
+ final testController = AccessibilityController();
+ await testController.loadSemanticsTree();
+
+ expect(testController.semanticsRoots.value, hasLength(1));
+ final root = testController.semanticsRoots.value.first;
+ expect(root.id, equals('0'));
+ expect(root.label, equals('Root View'));
+ expect(
+ root.flags,
+ equals({SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled}),
+ );
+ expect(root.children, hasLength(2));
+
+ // Node 1: Settings Header
+ final headerNode = root.children[0];
+ expect(headerNode.id, equals('1'));
+ expect(headerNode.label, equals('Settings Header'));
+ expect(headerNode.flags, equals({SemanticsFlag.isHeader}));
+ expect(headerNode.children, isEmpty);
+
+ // Node 2: Search Input
+ final searchNode = root.children[1];
+ expect(searchNode.id, equals('2'));
+ expect(searchNode.label, equals('Search Input'));
+ expect(searchNode.flags, equals({SemanticsFlag.isTextField}));
+ expect(searchNode.children, hasLength(1));
+
+ // Node 3: Clear Text Button (child of Node 2)
+ final clearButtonNode = searchNode.children.first;
+ expect(clearButtonNode.id, equals('3'));
+ expect(clearButtonNode.label, equals('Clear Text'));
+ expect(
+ clearButtonNode.flags,
+ equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}),
+ );
+ expect(clearButtonNode.children, isEmpty);
+ },
+ );
+
+ test(
+ 'loadSemanticsTree parses flat nodes map and builds child hierarchy',
+ () async {
+ final fakeServiceManager =
+ serviceConnection.serviceManager as FakeServiceManager;
+ fakeServiceManager.serviceExtensionResponses[registrations
+ .enableSemantics] = Response.parse(
+ {},
+ )!;
+ fakeServiceManager.serviceExtensionResponses[registrations
+ .getSemanticsTree] = Response.parse({
+ 'data': {
+ '0': {
+ 'id': 0,
+ 'label': 'Root',
+ 'childrenInTraversalOrder': [1],
+ },
+ '1': {
+ 'id': 1,
+ 'label': 'Child full',
+ 'flags': ['isButton'],
+ },
+ },
+ })!;
+
+ final testController = AccessibilityController();
+ await testController.loadSemanticsTree();
+
+ expect(testController.semanticsRoots.value, hasLength(1));
+ final root = testController.semanticsRoots.value.first;
+ expect(root.id, equals('0'));
+ expect(root.label, equals('Root'));
+ expect(root.children, hasLength(1));
+ expect(root.children.first.id, equals('1'));
+ expect(root.children.first.label, equals('Child full'));
+ expect(root.children.first.flags, equals({SemanticsFlag.isButton}));
+ },
+ );
+
+ test('dispose calls disposeSemantics', () async {
+ final recordingServiceConnection = _RecordingServiceConnectionManager();
+ setGlobal(ServiceConnectionManager, recordingServiceConnection);
+
+ final testController = AccessibilityController();
+ testController.dispose();
+ await Future<void>.delayed(Duration.zero);
+
+ final calls =
+ (recordingServiceConnection.serviceManager
+ as _RecordingServiceManager)
+ .recordedCalls;
+ expect(
+ calls.any((call) => call.$1 == registrations.disposeSemantics),
+ isTrue,
+ );
+ });
});
}
+
+class _RecordingServiceConnectionManager extends FakeServiceConnectionManager {
+ @override
+ late final serviceManager = _RecordingServiceManager();
+}
+
+// ignore: subtype_of_sealed_class, fake for testing.
+class _RecordingServiceManager extends FakeServiceManager {
+ final recordedCalls = <(String, Map<String, dynamic>?)>[];
+
+ @override
+ Future<Response> callServiceExtensionOnMainIsolate(
+ String method, {
+ Map<String, dynamic>? args,
+ }) async {
+ recordedCalls.add((method, args));
+ return serviceExtensionResponses[method] ?? Response.parse({})!;
+ }
+}
+
+class _NullIsolateServiceConnectionManager
+ extends FakeServiceConnectionManager {
+ @override
+ late final serviceManager = _NullIsolateServiceManager();
+}
+
+// ignore: subtype_of_sealed_class, fake for testing.
+class _NullIsolateServiceManager extends FakeServiceManager {
+ @override
+ late final isolateManager = _NullIsolateManager();
+}
+
+base class _NullIsolateManager extends FakeIsolateManager {
+ @override
+ ValueListenable<IsolateRef?> get mainIsolate =>
+ ValueNotifier<IsolateRef?>(null);
+}
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
index ba5e53e..337d24e 100644
--- a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
@@ -5,6 +5,8 @@
@TestOn('vm')
library;
+import 'dart:ui' show SemanticsFlag;
+
import 'package:devtools_app/devtools_app.dart';
import 'package:devtools_app_shared/ui.dart';
import 'package:devtools_app_shared/utils.dart';
@@ -181,5 +183,128 @@
expect(controller.highContrast.value, isTrue);
},
);
+
+ testWidgetsWithWindowSize(
+ 'renders semantics tree when nodes are loaded',
+ windowSize,
+ (WidgetTester tester) async {
+ final rootNode = SemanticsNodeModel(
+ id: '0',
+ label: 'Root Node',
+ flags: {SemanticsFlag.isHeader},
+ widgetName: 'HeaderWidget',
+ );
+ controller.semanticsRoots.value = [rootNode];
+
+ await pumpAccessibilityScreen(tester);
+ await tester.pumpAndSettle();
+
+ expect(find.text('SemanticsNode #0'), findsAtLeastNWidgets(1));
+ expect(find.text('"Root Node"'), findsAtLeastNWidgets(1));
+ expect(find.text('HeaderWidget'), findsOneWidget);
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'renders semantics tree error state when error occurs',
+ windowSize,
+ (WidgetTester tester) async {
+ controller.semanticsTreeError.value =
+ 'Failed to load semantics tree: network error.';
+
+ await pumpAccessibilityScreen(tester);
+ await tester.pumpAndSettle();
+
+ expect(
+ find.text('Failed to load semantics tree: network error.'),
+ findsOneWidget,
+ );
+ expect(find.text('Try Again'), findsOneWidget);
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'renders parent and child nodes in semantics tree',
+ windowSize,
+ (WidgetTester tester) async {
+ final childNode = SemanticsNodeModel(
+ id: '1',
+ label: 'Child Node',
+ flags: {SemanticsFlag.isButton},
+ widgetName: 'ElevatedButton',
+ );
+ final rootNode = SemanticsNodeModel(
+ id: '0',
+ label: 'Root Node',
+ widgetName: 'Column',
+ )..addChild(childNode);
+
+ rootNode.expandCascading();
+ controller.semanticsRoots.value = [rootNode];
+
+ await pumpAccessibilityScreen(tester);
+ await tester.pumpAndSettle();
+
+ expect(find.text('SemanticsNode #0'), findsOneWidget);
+ expect(find.text('SemanticsNode #1'), findsOneWidget);
+ expect(find.text('"Child Node"'), findsOneWidget);
+ expect(find.text('ElevatedButton'), findsOneWidget);
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'renders empty state with Load Semantics Tree button when no roots',
+ windowSize,
+ (WidgetTester tester) async {
+ controller.semanticsRoots.value = [];
+ await pumpAccessibilityScreen(tester);
+ controller.semanticsTreeError.value = null;
+ await tester.pumpAndSettle();
+
+ expect(
+ find.text(
+ 'No semantics tree loaded. Inspect the accessibility hierarchy of the connected app.',
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.widgetWithText(DevToolsButton, 'Load Semantics Tree'),
+ findsOneWidget,
+ );
+ expect(find.byType(RefreshButton), findsNothing);
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'renders refresh button when roots are loaded',
+ windowSize,
+ (WidgetTester tester) async {
+ final rootNode = SemanticsNodeModel(id: '0', label: 'Root Node');
+ controller.semanticsRoots.value = [rootNode];
+
+ await pumpAccessibilityScreen(tester);
+ await tester.pumpAndSettle();
+
+ expect(find.byType(RefreshButton), findsOneWidget);
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'renders appropriate icon for node with hasCheckedState flag',
+ windowSize,
+ (WidgetTester tester) async {
+ final node = SemanticsNodeModel(
+ id: '0',
+ label: 'Checkbox Node',
+ flags: {SemanticsFlag.hasCheckedState},
+ );
+ controller.semanticsRoots.value = [node];
+
+ await pumpAccessibilityScreen(tester);
+ await tester.pumpAndSettle();
+
+ expect(find.byIcon(Icons.check_box_outlined), findsOneWidget);
+ },
+ );
});
}