From 3d3804dfe23903aef57ad94949730a256ba02033 Mon Sep 17 00:00:00 2001 From: NemesisDevX Date: Wed, 28 Oct 2026 09:52:19 +0300 Subject: [PATCH 1/2] Fix Network toolbar and footer overflow on narrow screens The Network screen toolbar and the shared footer status line overflowed and overlapped at narrow widths. Network toolbar: below 600 px the search field moves to its own row, and below 900 px the filter field moves to its own row. Layout at 900 px and above is unchanged. Footer (StatusLine, shared by all screens): footer text is truncated instead of overflowing. Below 900 px the video tutorial link and, when DevTools is embedded, the embedded actions move into a More actions menu. Each menu item runs the same action as its inline counterpart. Below 600 px the disconnected status shows an icon with a tooltip instead of text. Fixes #10004 Assisted-by: Devin AI --- .../lib/src/framework/scaffold/scaffold.dart | 3 +- .../src/framework/scaffold/status_line.dart | 157 ++++-- .../src/screens/network/network_screen.dart | 65 ++- .../release_notes/NEXT_RELEASE_NOTES.md | 6 +- .../framework/scaffold/status_line_test.dart | 457 ++++++++++++++++++ .../network/network_responsive_test.dart | 173 +++++++ .../test_infra/responsive_layout_utils.dart | 299 ++++++++++++ 7 files changed, 1107 insertions(+), 53 deletions(-) create mode 100644 packages/devtools_app/test/framework/scaffold/status_line_test.dart create mode 100644 packages/devtools_app/test/screens/network/network_responsive_test.dart create mode 100644 packages/devtools_app/test/test_infra/responsive_layout_utils.dart diff --git a/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart b/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart index 90b3e884ffe..db8a3a6750a 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart @@ -24,6 +24,7 @@ import '../../shared/globals.dart'; import '../../shared/managers/banner_messages.dart'; import '../../shared/primitives/query_parameters.dart'; import '../../shared/title.dart'; +import '../../shared/ui/common_widgets.dart'; import 'about_dialog.dart'; import 'app_bar.dart'; import 'bottom_pane.dart'; @@ -59,7 +60,7 @@ class DevToolsScaffold extends StatefulWidget { ); /// Returns the list of ScaffoldAction widgets. - static List defaultActions({ + static List defaultActions({ Color? color, Screen? currentScreen, DevToolsQueryParams? queryParams, diff --git a/packages/devtools_app/lib/src/framework/scaffold/status_line.dart b/packages/devtools_app/lib/src/framework/scaffold/status_line.dart index a292d6ef68c..05529e07413 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/status_line.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/status_line.dart @@ -2,11 +2,14 @@ // 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:async'; + import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; +import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; import '../../shared/framework/screen.dart'; import '../../shared/globals.dart'; @@ -57,8 +60,12 @@ class StatusLine extends StatelessWidget { valueListenable: currentScreen.showIsolateSelector, builder: (context, showIsolateSelector, _) { showIsolateSelector = showIsolateSelector && isConnected; + // Keep every footer item on a single line. Items that do not fit are + // truncated rather than overflowing the status line. return DefaultTextStyle.merge( style: TextStyle(color: foregroundColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, child: Container( decoration: BoxDecoration( color: backgroundColor, @@ -93,6 +100,24 @@ class StatusLine extends StatelessWidget { final showVideoTutorial = screenMetaData?.tutorialVideoTimestamp != null || screenMetaData?.tutorialVideoUrl != null; + // Secondary items (the tutorial link and, when embedded, the DevTools + // actions) are shown inline only on wide screens. Below [MediaSize.s] they + // move into one overflow menu, so they stay reachable and the device name + // and page status are not squeezed. + final showSecondaryItemsInline = screenWidth > MediaSize.s; + final showTutorialInline = showVideoTutorial && showSecondaryItemsInline; + final embeddedActions = isEmbedded + ? DevToolsScaffold.defaultActions( + color: foregroundColor, + currentScreen: currentScreen, + ) + : const []; + final overflowTutorial = showVideoTutorial && !showTutorialInline + ? screenMetaData + : null; + final overflowActions = showSecondaryItemsInline + ? const [] + : embeddedActions; return [ Row( mainAxisSize: MainAxisSize.min, @@ -102,11 +127,10 @@ class StatusLine extends StatelessWidget { screenWidth: screenWidth, highlightForConnection: highlightForConnection, ), - if (showVideoTutorial) ...[ + if (showTutorialInline) ...[ BulletSpacer(color: foregroundColor), VideoTutorialLink( screenMetaData: screenMetaData!, - screenWidth: screenWidth, highlightForConnection: highlightForConnection, ), ], @@ -118,18 +142,28 @@ class StatusLine extends StatelessWidget { BulletSpacer(color: foregroundColor), ], if (screenWidth > MediaSize.xs && pageStatus != null) ...[ - pageStatus, + // The page status needs to be flexible so that the status line can + // shrink to fit narrow screens without overflowing. + Expanded(child: pageStatus), BulletSpacer(color: foregroundColor), ], - buildConnectionStatus(context, screenWidth), - if (widerThanXxs && isEmbedded) ...[ + // The connection status needs to be flexible so that the status line + // can shrink to fit narrow screens without overflowing. + Flexible(child: buildConnectionStatus(context, screenWidth)), + if (showSecondaryItemsInline && embeddedActions.isNotEmpty) ...[ BulletSpacer(color: foregroundColor), Row( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, - children: DevToolsScaffold.defaultActions( - color: foregroundColor, - currentScreen: currentScreen, - ), + children: embeddedActions, + ), + ], + if (overflowTutorial != null || overflowActions.isNotEmpty) ...[ + BulletSpacer(color: foregroundColor), + _StatusLineOverflowMenu( + tutorial: overflowTutorial, + actions: overflowActions, + color: foregroundColor, ), ], ]; @@ -174,16 +208,18 @@ class StatusLine extends StatelessWidget { }, ), const SizedBox(width: denseSpacing), - DevToolsTooltip( - message: 'Connected device', - child: Text( - description, - style: highlightForConnection - ? theme.regularTextStyle.copyWith( - color: theme.colorScheme.onPrimary, - ) - : theme.regularTextStyle, - overflow: TextOverflow.clip, + Flexible( + child: DevToolsTooltip( + message: 'Connected device', + child: Text( + description, + style: highlightForConnection + ? theme.regularTextStyle.copyWith( + color: theme.colorScheme.onPrimary, + ) + : theme.regularTextStyle, + overflow: TextOverflow.ellipsis, + ), ), ), ], @@ -192,7 +228,7 @@ class StatusLine extends StatelessWidget { return child!; } }, - child: screenWidth <= MediaSize.xxs + child: screenWidth <= MediaSize.xs ? const DevToolsTooltip( message: noConnectionMsg, child: Icon(Icons.warning_amber_rounded, size: actionsIconSize), @@ -245,7 +281,6 @@ class VideoTutorialLink extends StatelessWidget { VideoTutorialLink({ super.key, required this.screenMetaData, - required this.screenWidth, required this.highlightForConnection, }) : assert( screenMetaData.tutorialVideoTimestamp != null || @@ -254,12 +289,30 @@ class VideoTutorialLink extends StatelessWidget { final ScreenMetaData screenMetaData; - final MediaSize screenWidth; - final bool highlightForConnection; static const _devToolsYouTubeVideoUrl = 'https://youtu.be/_EYk-E29edo'; + static GaLink _linkFor(ScreenMetaData screenMetaData) { + return GaLink( + display: 'Watch tutorial', + url: + screenMetaData.tutorialVideoUrl ?? + '$_devToolsYouTubeVideoUrl${screenMetaData.tutorialVideoTimestamp}', + gaScreenName: screenMetaData.id, + gaSelectedItemDescription: + '${gac.videoTutorialLink}-${screenMetaData.id}', + ); + } + + /// Opens the tutorial video for [screenMetaData] and records the same + /// analytics event as this link, for use from the overflow menu. + static void open(ScreenMetaData screenMetaData) { + final link = _linkFor(screenMetaData); + unawaited(launchUrlWithErrorHandling(link.url)); + ga.select(link.gaScreenName!, link.gaSelectedItemDescription!); + } + @override Widget build(BuildContext context) { final color = highlightForConnection @@ -267,20 +320,60 @@ class VideoTutorialLink extends StatelessWidget { : null; return LinkIconLabel( icon: Icons.ondemand_video_rounded, - link: GaLink( - display: screenWidth <= MediaSize.xs ? 'Tutorial' : 'Watch tutorial', - url: - screenMetaData.tutorialVideoUrl ?? - '$_devToolsYouTubeVideoUrl${screenMetaData.tutorialVideoTimestamp}', - gaScreenName: screenMetaData.id, - gaSelectedItemDescription: - '${gac.videoTutorialLink}-${screenMetaData.id}', - ), + link: _linkFor(screenMetaData), color: color, ); } } +/// The footer items that do not fit at the current width: the video tutorial +/// link and, for embedded DevTools, the DevTools actions (Settings, Feedback, +/// About, and extension settings), in one menu. +/// +/// Each item runs the same behavior as its inline counterpart. +class _StatusLineOverflowMenu extends StatelessWidget { + const _StatusLineOverflowMenu({ + required this.tutorial, + required this.actions, + required this.color, + }); + + /// The metadata of the screen whose tutorial should be offered, if any. + final ScreenMetaData? tutorial; + + final List actions; + + final Color? color; + + @override + Widget build(BuildContext context) { + final tutorial = this.tutorial; + return PopupMenuButton( + tooltip: 'More actions', + padding: EdgeInsets.zero, + onSelected: (onSelected) => onSelected(), + itemBuilder: (_) => [ + if (tutorial != null) + PopupMenuItem( + value: () => VideoTutorialLink.open(tutorial), + child: const Text('Watch tutorial'), + ), + for (final action in actions) + PopupMenuItem( + value: () => action.onPressed(context), + child: Text(action.tooltip), + ), + ], + child: SizedBox( + width: actionWidgetSize, + child: Center( + child: Icon(Icons.more_horiz, size: actionsIconSize, color: color), + ), + ), + ); + } +} + class IsolateSelector extends StatelessWidget { const IsolateSelector({super.key, required this.foregroundColor}); diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart index ef42f6476b3..15aefde008c 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -69,10 +69,13 @@ class NetworkScreen extends Screen { return Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - 'Showing ${nf.format(filteredCount)} of ' - '${nf.format(totalCount)} ' - '${pluralize('request', totalCount)}', + Flexible( + child: Text( + 'Showing ${nf.format(filteredCount)} of ' + '${nf.format(totalCount)} ' + '${pluralize('request', totalCount)}', + overflow: TextOverflow.ellipsis, + ), ), const SizedBox(width: denseSpacing), child!, @@ -202,6 +205,24 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> } final screenWidth = ScreenSize(context).width; + // The search field's navigation controls and the filter field's prefix and + // suffix controls need more width than is left beside the other controls + // on narrow screens. Below [MediaSize.s] the filter field moves to its own + // row, and below [MediaSize.xs] the search field does too. The threshold + // for the filter row is [MediaSize.s] because the filter field's suffix + // controls overflowed by 16 pixels at a 600 pixel width. + final searchOnOwnRow = screenWidth <= MediaSize.xs; + final filterOnOwnRow = screenWidth <= MediaSize.s; + final searchField = SearchField( + searchController: controller, + searchFieldWidth: screenWidth <= MediaSize.xs + ? defaultSearchFieldWidth + : wideSearchFieldWidth, + ); + final filterField = StandaloneFilterField( + controller: controller, + filteredItem: 'request', + ); return Column( children: [ Row( @@ -226,21 +247,14 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> ), const SizedBox(width: denseSpacing), // TODO(kenz): fix focus issue when state is refreshed - Expanded( - child: SearchField( - searchController: controller, - searchFieldWidth: screenWidth <= MediaSize.xs - ? defaultSearchFieldWidth - : wideSearchFieldWidth, - ), - ), - const SizedBox(width: denseSpacing), - Expanded( - child: StandaloneFilterField( - controller: controller, - filteredItem: 'request', - ), - ), + if (searchOnOwnRow) + const Spacer() + else + Expanded(child: searchField), + if (!filterOnOwnRow) ...[ + const SizedBox(width: denseSpacing), + Expanded(child: filterField), + ], const SizedBox(width: denseSpacing), OpenSaveButtonGroup( screenId: ScreenMetaData.network.id, @@ -262,6 +276,19 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> ), ], ), + // On narrow screens, place the search and filter fields on their own + // rows so that each has enough space for its navigation, prefix, and + // suffix controls without overflowing. + if (searchOnOwnRow) + Padding( + padding: const EdgeInsets.only(top: denseSpacing), + child: Row(children: [Expanded(child: searchField)]), + ), + if (filterOnOwnRow) + Padding( + padding: const EdgeInsets.only(top: denseSpacing), + child: filterField, + ), if (!_recording) const Padding( padding: EdgeInsets.only(top: intermediateSpacing), diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 7e9c5050340..ae92c0c6588 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -47,7 +47,11 @@ TODO: Remove this section if there are not any updates. ## Network profiler updates -TODO: Remove this section if there are not any updates. +* Fixed an issue where the Network screen's toolbar controls and footer status + bar would overflow and overlap on narrow screens. On compact widths, the + video tutorial link and the embedded DevTools actions moved into the + More actions menu in the footer. + [#10004](https://github.com/flutter/devtools/issues/10004) ## Logging updates diff --git a/packages/devtools_app/test/framework/scaffold/status_line_test.dart b/packages/devtools_app/test/framework/scaffold/status_line_test.dart new file mode 100644 index 00000000000..d616224f5f3 --- /dev/null +++ b/packages/devtools_app/test/framework/scaffold/status_line_test.dart @@ -0,0 +1,457 @@ +// 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. + +@TestOn('vm') +library; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/framework/scaffold/about_dialog.dart'; +import 'package:devtools_app/src/framework/scaffold/settings_dialog.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../test_infra/responsive_layout_utils.dart'; + +/// The height of the status line. It is fixed, so the overflow menu trigger is +/// clamped to it. The trigger is [actionWidgetSize] wide and this tall. +const _footerHeight = statusLineHeight + 2 * densePadding; + +/// The width, in logical pixels, at and above which the video tutorial link and +/// the embedded DevTools actions are shown inline. Below it they are in the +/// overflow menu. +const _inlineSecondaryItemsMinWidth = 900.0; + +/// The compact width used for menu interaction tests. The Settings dialog's +/// content overflows below this width, so Settings is tested only here; see +/// [_expectDialogOpens]. +const _compactInteractionWidth = 600.0; + +const _noClientConnection = 'No client connection'; + +/// The URL of the video tutorial for [screen], as the footer link opens it. +String _tutorialUrl(ScreenMetaData screen) { + return 'https://youtu.be/_EYk-E29edo${screen.tutorialVideoTimestamp}'; +} + +Finder _inStatusLine(Finder finder) { + return find.descendant(of: find.byType(StatusLine), matching: finder); +} + +/// The footer's connection status. It is text when there is room for it, and +/// otherwise an icon with a tooltip. +Finder _connectionStatus({required bool connected}) { + if (connected) return _inStatusLine(find.byTooltip('Connected device')); + final icon = _inStatusLine(find.byTooltip(_noClientConnection)); + return icon.evaluate().isNotEmpty + ? icon + : _inStatusLine(find.text(_noClientConnection)); +} + +Finder _trigger() => _inStatusLine(find.byTooltip('More actions')); + +/// Gives keyboard focus to the overflow menu trigger by requesting focus on the +/// focus node that the trigger's button owns. It does not press Tab, so Tab +/// traversal to the trigger is not covered by these tests. +Future _focusTrigger(WidgetTester tester) async { + final icon = find.descendant(of: _trigger(), matching: find.byType(Icon)); + final triggerFocus = Focus.of(tester.element(icon)); + triggerFocus.requestFocus(); + await tester.pump(); + expect( + FocusManager.instance.primaryFocus, + triggerFocus, + reason: 'The overflow menu trigger should take keyboard focus.', + ); +} + +/// Opens the overflow menu from the keyboard, as a keyboard user would. +Future _openMenuWithKeyboard(WidgetTester tester) async { + await _focusTrigger(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.byType(PopupMenuItem), + findsWidgets, + reason: 'Enter on the focused trigger should open the menu.', + ); +} + +/// The label of the menu item that has keyboard focus, if any. +String? _focusedMenuItemLabel() { + final focus = FocusManager.instance.primaryFocus?.context; + final item = focus + ?.findAncestorWidgetOfExactType>(); + final child = item?.child; + return child is Text ? child.data : null; +} + +/// Activates the menu item labeled [label] from the keyboard: arrow keys move +/// focus through the open menu, and Enter activates the focused item. +Future _activateMenuItem(WidgetTester tester, String label) async { + for (var i = 0; i < 10 && _focusedMenuItemLabel() != label; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + } + expect( + _focusedMenuItemLabel(), + label, + reason: '"$label" should be reachable with the arrow keys.', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 500)); +} + +/// Asserts that [dialog] is shown, then closes it with its Close button. +/// +/// The Settings dialog's checkbox rows overflow below 600 pixels (224 pixels at +/// 328 pixels), in shared dialog code that is reported separately from this +/// change. The About dialog has no overflow at 328 pixels. +Future _expectDialogOpens(WidgetTester tester, Finder dialog) async { + expect(dialog, findsOneWidget); + await tester.tap(find.text('CLOSE')); + await tester.pump(const Duration(milliseconds: 500)); + expect(dialog, findsNothing); +} + +void main() { + setUpAll(loadFonts); + + late FakeServiceConnectionManager connection; + late NetworkController network; + late LoggingController logging; + late DebuggerController debugger; + + setUp(() { + connection = createLayoutServiceConnection(); + setUpLayoutGlobals(connection); + network = NetworkController(); + logging = LoggingController(); + debugger = DebuggerController(); + }); + + Future> pumpFooter( + WidgetTester tester, { + required String pageId, + required bool embedded, + required bool connected, + }) { + return collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: pageId, + embedded: embedded, + connected: connected, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + } + + group('footer layout', () { + for (final embedded in [false, true]) { + for (final connected in [true, false]) { + final mode = + '${embedded ? 'embedded' : 'standalone'}, ' + '${connected ? 'connected' : 'disconnected'}'; + + for (final width in responsiveLayoutWidths) { + testWidgetsWithWindowSize( + 'Network footer ($mode) has no overlap or clipping at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: embedded, + connected: connected, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectFooterGeometry(tester, width: width, connected: connected); + await stopNetworkPolling(tester, network); + }, + ); + } + } + } + + for (final width in [328.0, 600.0, 901.0, 1400.0]) { + testWidgetsWithWindowSize( + 'Logging footer (embedded) has no overlap or clipping at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpFooter( + tester, + pageId: ScreenMetaData.logging.id, + embedded: true, + connected: true, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectFooterGeometry(tester, width: width, connected: true); + }, + ); + } + }); + + group('overflow menu', () { + testWidgetsWithWindowSize( + 'trigger is a footer-height button with an accessible name', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + final handle = tester.ensureSemantics(); + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + final trigger = _trigger(); + expect(trigger, findsOneWidget); + expect(tester.getSize(trigger).width, actionWidgetSize); + expect( + tester.getSize(trigger).height, + lessThanOrEqualTo(_footerHeight), + ); + expect( + tester.getSemantics(trigger), + matchesSemantics( + tooltip: 'More actions', + isButton: true, + isEnabled: true, + isFocusable: true, + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + hasExpandedState: true, + ), + ); + handle.dispose(); + await stopNetworkPolling(tester, network); + }, + ); + + testWidgetsWithWindowSize( + 'opens with Enter, lists every action, and closes with Escape', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + for (final label in [ + 'Watch tutorial', + 'Settings', + 'Report feedback', + 'About DevTools', + ]) { + expect( + find.widgetWithText(PopupMenuItem, label), + findsOneWidget, + ); + } + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(PopupMenuItem), findsNothing); + await stopNetworkPolling(tester, network); + }, + ); + + testWidgetsWithWindowSize( + 'Settings from the menu opens the Settings dialog', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + final errors = await collectFlutterErrors( + () => _activateMenuItem(tester, 'Settings'), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + await _expectDialogOpens(tester, find.byType(SettingsDialog)); + await stopNetworkPolling(tester, network); + }, + ); + + for (final width in [_compactInteractionWidth, 328.0]) { + testWidgetsWithWindowSize( + 'About from the menu opens the About dialog' + ' at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + final errors = await collectFlutterErrors( + () => _activateMenuItem(tester, 'About DevTools'), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + await _expectDialogOpens(tester, find.byType(DevToolsAboutDialog)); + await stopNetworkPolling(tester, network); + }, + ); + } + + for (final width in [_compactInteractionWidth, 328.0]) { + testWidgetsWithWindowSize( + 'Watch tutorial from the menu opens the same video and records the same ' + 'analytics event as the inline link' + ' at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + final analytics = AnalyticsRecorder()..install(); + addTearDown(() { + urls.dispose(); + analytics.dispose(); + }); + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + await _activateMenuItem(tester, 'Watch tutorial'); + await tester.pump(const Duration(milliseconds: 200)); + + expect(urls.launched, [_tutorialUrl(ScreenMetaData.network)]); + expect( + analytics.recordedSelection( + screenId: ScreenMetaData.network.id, + item: AnalyticsRecorder.tutorialItem(ScreenMetaData.network.id), + ), + isTrue, + ); + await stopNetworkPolling(tester, network); + }, + ); + } + + for (final width in [_compactInteractionWidth, 328.0]) { + testWidgetsWithWindowSize( + 'Report feedback from the menu opens the issue tracker and records the ' + 'analytics event' + ' at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + final analytics = AnalyticsRecorder()..install(); + addTearDown(() { + urls.dispose(); + analytics.dispose(); + }); + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + await _activateMenuItem(tester, 'Report feedback'); + await tester.pump(const Duration(milliseconds: 200)); + + expect(urls.launched, hasLength(1)); + expect( + urls.launched.single, + startsWith('https://github.com/flutter/devtools/issues/new'), + ); + expect( + analytics.recordedSelection( + screenId: 'main', + item: 'feedbackButton', + ), + isTrue, + ); + await stopNetworkPolling(tester, network); + }, + ); + } + + testWidgetsWithWindowSize( + 'inline tutorial link at a wide width opens the same video', + const Size(1400, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + addTearDown(urls.dispose); + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + expect(_trigger(), findsNothing); + await tester.tap(_inStatusLine(find.text('Watch tutorial'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(urls.launched, [_tutorialUrl(ScreenMetaData.network)]); + await stopNetworkPolling(tester, network); + }, + ); + }); +} + +/// Asserts the geometry of the footer for the given configuration. +/// +/// Every footer element must lie inside the status line, must not intersect +/// another element, and must not be truncated. Each primary control must be +/// hit-testable. +void _expectFooterGeometry( + WidgetTester tester, { + required double width, + required bool connected, +}) { + final statusLine = tester.getRect(find.byType(StatusLine)); + final docs = _inStatusLine(find.byType(DocumentationLink)); + final connection = _connectionStatus(connected: connected); + final tutorial = _inStatusLine(find.text('Watch tutorial')); + final trigger = _trigger(); + + final elements = { + 'docs': tester.getRect(docs), + 'connection': tester.getRect(connection), + }; + if (trigger.evaluate().isNotEmpty) { + elements['trigger'] = tester.getRect(trigger); + } + if (tutorial.evaluate().isNotEmpty) { + elements['tutorial'] = tester.getRect(tutorial); + } + expectNoIntersections(elements); + expectWithinBounds(elements, statusLine); + + expectTextNotTruncated( + tester, + find.descendant(of: docs, matching: find.byType(Text)), + ); + final connectionText = connected + ? find.descendant(of: connection, matching: find.byType(Text)) + : _inStatusLine(find.text(_noClientConnection)); + if (connectionText.evaluate().isNotEmpty) { + expectTextNotTruncated(tester, connectionText); + } + + final inlineSecondary = width >= _inlineSecondaryItemsMinWidth; + expect(trigger, inlineSecondary ? findsNothing : findsOneWidget); + expect(tutorial, inlineSecondary ? findsWidgets : findsNothing); + expectInteractable(docs); + if (trigger.evaluate().isNotEmpty) { + expect(tester.getSize(trigger).width, actionWidgetSize); + expectInteractable(trigger); + } +} diff --git a/packages/devtools_app/test/screens/network/network_responsive_test.dart b/packages/devtools_app/test/screens/network/network_responsive_test.dart new file mode 100644 index 00000000000..25e48c55a96 --- /dev/null +++ b/packages/devtools_app/test/screens/network/network_responsive_test.dart @@ -0,0 +1,173 @@ +// 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. + +@TestOn('vm') +library; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/shared/ui/file_import.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../test_infra/responsive_layout_utils.dart'; + +/// The width, in logical pixels, below which the search field is on its own +/// row. Matches the threshold in `NetworkScreen`. +const _searchOnOwnRowBelowWidth = 600.0; + +/// The width, in logical pixels, below which the filter field is on its own +/// row. Matches the threshold in `NetworkScreen`. +const _filterOnOwnRowBelowWidth = 900.0; + +Finder _inToolbar(Finder finder) { + return find.descendant(of: find.byType(NetworkScreenBody), matching: finder); +} + +Finder get _search => _inToolbar(find.byType(SearchField)); + +Finder get _filter => + _inToolbar(find.byType(StandaloneFilterField)); + +Finder get _recordButton => _inToolbar(find.byType(StartStopRecordingButton)); + +/// Asserts the layout of the Network toolbar at [width]. +void _expectToolbarLayout(WidgetTester tester, {required double width}) { + final body = tester.getRect(find.byType(NetworkScreenBody)); + final search = tester.getRect(_search); + final filter = tester.getRect(_filter); + final record = tester.getRect(_recordButton); + final clear = tester.getRect(_inToolbar(find.byType(ClearButton))); + final export = tester.getRect(_inToolbar(find.byType(OpenSaveButtonGroup))); + + expectNoIntersections({ + 'record': record, + 'clear': clear, + 'search': search, + 'filter': filter, + 'export': export, + }); + expectWithinBounds({ + 'record': record, + 'clear': clear, + 'search': search, + 'filter': filter, + 'export': export, + }, body); + + // The field's own controls were the elements that overlapped in the report. + final fieldControls = { + for (final (i, control) in _inToolbar( + find.descendant( + of: _filter, + matching: find.byType(InputDecorationSuffixButton), + ), + ).evaluate().indexed) + 'filterSuffix$i': tester.getRect(find.byWidget(control.widget)), + }; + expect(fieldControls, isNotEmpty); + expectWithinBounds(fieldControls, filter); + expectNoIntersections(fieldControls); + + if (width < _searchOnOwnRowBelowWidth) { + expect(search.top, greaterThanOrEqualTo(record.bottom - geometryTolerance)); + } else { + expect(search.center.dy, closeTo(record.center.dy, geometryTolerance)); + } + if (width < _filterOnOwnRowBelowWidth) { + expect(filter.top, greaterThanOrEqualTo(search.bottom - geometryTolerance)); + } else { + expect(filter.center.dy, closeTo(search.center.dy, geometryTolerance)); + } + + [_recordButton, _search, _filter].forEach(expectInteractable); +} + +void main() { + setUpAll(loadFonts); + + late FakeServiceConnectionManager connection; + late NetworkController network; + late LoggingController logging; + late DebuggerController debugger; + + setUp(() { + connection = createLayoutServiceConnection(); + setUpLayoutGlobals(connection); + network = NetworkController(); + logging = LoggingController(); + debugger = DebuggerController(); + }); + + for (final embedded in [false, true]) { + group('Network toolbar (${embedded ? 'embedded' : 'standalone'})', () { + for (final width in responsiveLayoutWidths) { + testWidgetsWithWindowSize( + 'has no overlap or clipping at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: embedded, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectToolbarLayout(tester, width: width); + await stopNetworkPolling(tester, network); + }, + ); + } + }); + } + + group('Network toolbar behavior', () { + for (final width in [328.0, 900.0, 1400.0]) { + testWidgetsWithWindowSize( + 'search, filter, and recording work at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: false, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ); + + await tester.enterText( + find.descendant(of: _search, matching: find.byType(EditableText)), + 'GET', + ); + expect(network.search, 'GET'); + + await tester.enterText( + find.descendant(of: _filter, matching: find.byType(EditableText)), + 'http', + ); + expect(network.activeFilter.value.queryFilter.query, 'http'); + + final wasRecording = network.recordingNotifier.value; + await tester.tap(_recordButton); + await tester.pump(const Duration(seconds: 1)); + expect(network.recordingNotifier.value, !wasRecording); + + await stopNetworkPolling(tester, network); + }, + ); + } + }); +} diff --git a/packages/devtools_app/test/test_infra/responsive_layout_utils.dart b/packages/devtools_app/test/test_infra/responsive_layout_utils.dart new file mode 100644 index 00000000000..853d5c14b86 --- /dev/null +++ b/packages/devtools_app/test/test_infra/responsive_layout_utils.dart @@ -0,0 +1,299 @@ +// 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. + +import 'dart:async'; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/framework/scaffold/scaffold.dart'; +import 'package:devtools_app/src/shared/analytics/constants.dart' as gac; +import 'package:devtools_app/src/shared/framework/framework_controller.dart'; +import 'package:devtools_app/src/shared/managers/survey.dart'; +import 'package:devtools_app/src/shared/primitives/message_bus.dart'; +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/shared.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../screens/network/utils/network_test_utils.dart'; + +/// The viewport widths, in logical pixels, used by the responsive layout tests. +/// +/// These include the realistic narrow widths from the issue (328 and 411), the +/// `MediaSize` boundaries used by the Network toolbar and the status line (600 +/// and 900), and a desktop baseline. +const responsiveLayoutWidths = [ + 328, + 411, + 599, + 600, + 601, + 899, + 900, + 901, + 1400, +]; + +/// The viewport height, in logical pixels, used by the responsive layout tests. +const responsiveLayoutHeight = 900.0; + +/// The tolerance, in logical pixels, used when comparing geometry. +const geometryTolerance = 0.5; + +/// Creates a fake service connection to a Dart VM app, with the VM details that +/// the About dialog reads. +FakeServiceConnectionManager createLayoutServiceConnection() { + final connection = FakeServiceConnectionManager( + service: FakeServiceManager.createFakeService( + socketProfile: loadSocketProfile(), + httpProfile: loadHttpProfile(), + ), + ); + final vm = connection.serviceManager.vm; + when(vm.version).thenReturn('1.9.1'); + when(vm.targetCPU).thenReturn('arm64'); + when(vm.architectureBits).thenReturn(64); + when(vm.operatingSystem).thenReturn('android'); + mockConnectedApp( + connection.serviceManager.connectedApp!, + isFlutterApp: false, + ); + for (final screenId in [ + ScreenMetaData.network.id, + ScreenMetaData.logging.id, + ]) { + when( + connection.errorBadgeManager.errorCountNotifier(screenId), + ).thenReturn(ValueNotifier(0)); + } + return connection; +} + +/// Registers the globals that a full [DevToolsScaffold] needs in tests. +void setUpLayoutGlobals(FakeServiceConnectionManager connection) { + setGlobal(ServiceConnectionManager, connection); + setGlobal(OfflineDataController, OfflineDataController()); + setGlobal(IdeTheme, IdeTheme()); + setGlobal( + DevToolsEnvironmentParameters, + ExternalDevToolsEnvironmentParameters(), + ); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(NotificationService, NotificationService()); + setGlobal(DTDManager, MockDTDManager()); + setGlobal(FrameworkController, FrameworkController()); + setGlobal(SurveyService, SurveyService()); + setGlobal(BannerMessagesController, BannerMessagesController()); + setGlobal(BreakpointManager, BreakpointManager()); + setGlobal(MessageBus, MessageBus()); +} + +/// Pumps a full [DevToolsScaffold] showing the screen identified by [pageId]. +/// +/// The scaffold contains the real application-wide `StatusLine`, so the footer +/// and the screen body are laid out together as they are in the app. +Future pumpDevToolsScaffold( + WidgetTester tester, { + required String pageId, + required bool embedded, + required bool connected, + required FakeServiceConnectionManager connection, + required NetworkController network, + required LoggingController logging, + required DebuggerController debugger, +}) async { + final embedMode = embedded ? EmbedMode.embedOne : EmbedMode.none; + setGlobal(IdeTheme, IdeTheme(embedMode: embedMode)); + connection.serviceManager.setConnectedState(connected); + // The providers sit above the app so that dialogs opened from the footer can + // read them, as they do in the app. + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value( + value: AnalyticsController( + enabled: false, + shouldShowConsentMessage: false, + consentMessage: 'fake message', + ), + ), + Provider.value(value: ReleaseNotesController()), + ], + child: wrapWithControllers( + DevToolsScaffold( + screens: [NetworkScreen(), LoggingScreen()], + page: pageId, + embedMode: embedMode, + ), + network: network, + logging: logging, + debugger: debugger, + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); +} + +/// Runs [action] and returns the message of every [FlutterError] it reports. +/// +/// Layout exceptions are reported through [FlutterError.onError]. Collecting +/// them lets tests assert on them after the handler is restored. +Future> collectFlutterErrors( + Future Function() action, +) async { + final errors = []; + final originalOnError = FlutterError.onError; + FlutterError.onError = errors.add; + try { + await action(); + } finally { + FlutterError.onError = originalOnError; + } + return [for (final error in errors) error.exceptionAsString()]; +} + +/// Stops the polling of [network] and lets pending timers complete, so that no +/// timers are left pending when a test ends. +Future stopNetworkPolling( + WidgetTester tester, + NetworkController network, +) async { + await network.stopRecording(); + await tester.pumpAndSettle(const Duration(seconds: 1)); +} + +/// Asserts that no two rectangles in [named] intersect. +void expectNoIntersections(Map named) { + final entries = named.entries.toList(); + for (var i = 0; i < entries.length; i++) { + for (var j = i + 1; j < entries.length; j++) { + final a = entries[i]; + final b = entries[j]; + expect( + a.value + .deflate(geometryTolerance) + .overlaps(b.value.deflate(geometryTolerance)), + isFalse, + reason: '${a.key} ${a.value} intersects ${b.key} ${b.value}', + ); + } + } +} + +/// Asserts that every rectangle in [named] lies entirely within [bounds]. +/// +/// A rectangle that extends past [bounds] is clipped or overflowing. +void expectWithinBounds(Map named, Rect bounds) { + for (final entry in named.entries) { + final rect = entry.value; + expect( + rect.width > 0 && rect.height > 0, + isTrue, + reason: '${entry.key} has no size: $rect', + ); + expect( + rect.left >= bounds.left - geometryTolerance && + rect.right <= bounds.right + geometryTolerance && + rect.top >= bounds.top - geometryTolerance && + rect.bottom <= bounds.bottom + geometryTolerance, + isTrue, + reason: '${entry.key} $rect is not within $bounds', + ); + } +} + +/// Asserts that the text in [finder] is laid out at its full intrinsic width, +/// so that it is not ellipsized or wrapped. +void expectTextNotTruncated(WidgetTester tester, Finder finder) { + final paragraph = tester.renderObject(finder); + final fullWidth = paragraph.getMaxIntrinsicWidth(double.infinity); + expect( + paragraph.size.width + geometryTolerance >= fullWidth, + isTrue, + reason: + 'Text "${paragraph.text.toPlainText()}" is truncated: laid out at ' + '${paragraph.size.width}, needs $fullWidth', + ); +} + +/// Records the URLs that DevTools asks the platform to open. +/// +/// Launching is intercepted at the `url_launcher` method channel, so the test +/// checks the URL without opening a browser. +class UrlLaunchRecorder { + final launched = []; + + void install() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + switch (call.method) { + case 'canLaunch': + return true; + case 'launch': + launched.add((call.arguments as Map)['url'] as String); + return true; + } + return null; + }); + } + + void dispose() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + } + + static const _channel = MethodChannel('plugins.flutter.io/url_launcher'); +} + +/// Records the analytics events that DevTools reports. +/// +/// The analytics implementation used in tests logs each selection, so the +/// events are captured from the log rather than from a network request. +class AnalyticsRecorder { + final events = []; + + StreamSubscription? _subscription; + Level? _originalLevel; + + void install() { + _originalLevel = Logger.root.level; + Logger.root.level = Level.ALL; + _subscription = Logger.root.onRecord.listen((record) { + if (record.loggerName == '_analytics_stub') { + events.add(record.message); + } + }); + } + + void dispose() { + unawaited(_subscription?.cancel()); + Logger.root.level = _originalLevel ?? Level.INFO; + } + + /// Returns whether a selection of [item] on [screenId] was recorded. + bool recordedSelection({required String screenId, required String item}) { + return events.any( + (event) => event.contains('screenName:$screenId, selectedItem:$item, '), + ); + } + + /// The selected item description for the tutorial link on [screenId]. + static String tutorialItem(String screenId) { + return '${gac.videoTutorialLink}-$screenId'; + } +} + +/// Asserts that [finder] is hit-testable, so a user tapping its center reaches +/// it. +void expectInteractable(Finder finder) { + expect(finder.hitTestable(), findsOneWidget); +} From 416fe201d6ad773ba6b91b4d85faf6982be39729 Mon Sep 17 00:00:00 2001 From: NemesisDevX Date: Wed, 28 Oct 2026 15:06:02 +0300 Subject: [PATCH 2/2] Preserve toolbar field state and footer spacing across resizes Give the Network search and filter fields stable keys so their widget state (text, selection, and focus) survives being reparented between toolbar rows when the window crosses the 600 and 900 pixel breakpoints. Previously each reparent recreated the filter field's text controller and focus, losing the selection and focus, and the remounted search field's autofocus stole focus. Wrap the footer's page status in Flexible instead of Expanded so it keeps its natural width and the status line's spaceBetween alignment keeps spacing even on wide screens, matching the layout before the responsive change. It still shrinks below its natural width, and ellipsizes, when space is constrained. Assisted-by: Devin AI --- .../src/framework/scaffold/status_line.dart | 2 +- .../src/screens/network/network_screen.dart | 9 + .../framework/scaffold/status_line_test.dart | 54 ++++++ .../network/network_responsive_test.dart | 154 ++++++++++++++++++ 4 files changed, 218 insertions(+), 1 deletion(-) diff --git a/packages/devtools_app/lib/src/framework/scaffold/status_line.dart b/packages/devtools_app/lib/src/framework/scaffold/status_line.dart index 05529e07413..4a7238c5a55 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/status_line.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/status_line.dart @@ -144,7 +144,7 @@ class StatusLine extends StatelessWidget { if (screenWidth > MediaSize.xs && pageStatus != null) ...[ // The page status needs to be flexible so that the status line can // shrink to fit narrow screens without overflowing. - Expanded(child: pageStatus), + Flexible(child: pageStatus), BulletSpacer(color: foregroundColor), ], // The connection status needs to be flexible so that the status line diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart index 15aefde008c..3e6d120c163 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -186,6 +186,13 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> bool _recording = false; + // The search and filter fields move between rows as the screen width + // crosses the responsive breakpoints below. The stable keys keep each + // field's state (text, selection, and focus) attached to the field when it + // is reparented to a different row in the same frame. + final _searchFieldKey = GlobalKey(debugLabel: 'networkSearchField'); + final _filterFieldKey = GlobalKey(debugLabel: 'networkFilterField'); + @override void initState() { super.initState(); @@ -214,12 +221,14 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> final searchOnOwnRow = screenWidth <= MediaSize.xs; final filterOnOwnRow = screenWidth <= MediaSize.s; final searchField = SearchField( + key: _searchFieldKey, searchController: controller, searchFieldWidth: screenWidth <= MediaSize.xs ? defaultSearchFieldWidth : wideSearchFieldWidth, ); final filterField = StandaloneFilterField( + key: _filterFieldKey, controller: controller, filteredItem: 'request', ); diff --git a/packages/devtools_app/test/framework/scaffold/status_line_test.dart b/packages/devtools_app/test/framework/scaffold/status_line_test.dart index d616224f5f3..50a488d0903 100644 --- a/packages/devtools_app/test/framework/scaffold/status_line_test.dart +++ b/packages/devtools_app/test/framework/scaffold/status_line_test.dart @@ -197,6 +197,60 @@ void main() { }, ); } + + // The page status is wrapped in a Flexible, not an Expanded, so that it + // keeps its natural width and the spaceBetween alignment keeps spacing the + // footer items evenly on wide screens, as it did before this change. + for (final embedded in [false, true]) { + for (final width in [900.0, 901.0, 1400.0]) { + testWidgetsWithWindowSize( + 'page status keeps its natural width on a wide screen ' + '(${embedded ? 'embedded' : 'standalone'}, $width)', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: embedded, + connected: true, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + + final statusText = _inStatusLine(find.textContaining(' of ')); + expect(statusText, findsOneWidget); + final textRect = tester.getRect(statusText); + // The nearest ancestor Row is the page status box, which also + // holds the recording spinner. + final boxRect = tester.getRect( + find.ancestor(of: statusText, matching: find.byType(Row)).first, + ); + expect( + boxRect.width, + lessThanOrEqualTo( + textRect.width + denseSpacing + smallProgressSize + 8.0, + ), + reason: + 'The page status should hug its content on a wide screen; ' + 'an Expanded wrapper would stretch it to fill the slack.', + ); + + if (!embedded) { + // With no trailing actions, the connection status is the last + // item and stays pinned to the right edge. + final connection = tester.getRect( + _connectionStatus(connected: true), + ); + final statusLine = tester.getRect(find.byType(StatusLine)); + expect( + statusLine.right - connection.right, + lessThanOrEqualTo(defaultSpacing + geometryTolerance), + ); + } + await stopNetworkPolling(tester, network); + }, + ); + } + } }); group('overflow menu', () { diff --git a/packages/devtools_app/test/screens/network/network_responsive_test.dart b/packages/devtools_app/test/screens/network/network_responsive_test.dart index 25e48c55a96..ac3a8b1ba55 100644 --- a/packages/devtools_app/test/screens/network/network_responsive_test.dart +++ b/packages/devtools_app/test/screens/network/network_responsive_test.dart @@ -28,6 +28,26 @@ Finder _inToolbar(Finder finder) { return find.descendant(of: find.byType(NetworkScreenBody), matching: finder); } +/// Resizes the test window to [width] and pumps, rebuilding the screen at the +/// new size in the same widget tree. +Future _setWindowWidth(WidgetTester tester, double width) async { + tester.view.physicalSize = Size(width, responsiveLayoutHeight); + tester.view.devicePixelRatio = 1.0; + await tester.pump(); +} + +/// Whether the EditableText inside [field] holds primary focus. +bool _fieldHasFocus(WidgetTester tester, Finder field) { + final editable = find.descendant( + of: field, + matching: find.byType(EditableText), + ); + if (editable.evaluate().isEmpty) return false; + final focusNode = tester.widget(editable).focusNode; + return focusNode.hasFocus && + identical(focusNode, FocusManager.instance.primaryFocus); +} + Finder get _search => _inToolbar(find.byType(SearchField)); Finder get _filter => @@ -131,6 +151,140 @@ void main() { }); } + group('Network toolbar state across resizes', () { + // Resizing across the breakpoints reparents the search and filter fields + // between rows. Their stable keys keep each field's state attached, so + // text, selection, and focus survive the move. + testWidgetsWithWindowSize( + 'search and filter keep text, selection, and focus across resizes', + const Size(650, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: false, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + + final searchEditable = find.descendant( + of: _search, + matching: find.byType(EditableText), + ); + final filterEditable = find.descendant( + of: _filter, + matching: find.byType(EditableText), + ); + + await tester.enterText(searchEditable, 'method:GET'); + await tester.enterText(filterEditable, 'status:200'); + tester.widget(searchEditable).controller.selection = + const TextSelection(baseOffset: 2, extentOffset: 7); + tester.widget(filterEditable).controller.selection = + const TextSelection(baseOffset: 1, extentOffset: 4); + await tester.pump(); + + // Focus the filter field so the test can catch a focus steal by the + // search field's autofocus when it is remounted. + await tester.showKeyboard(filterEditable); + await tester.pump(); + + final searchFieldState = tester.state(_search); + final searchEditableState = tester.state(searchEditable); + final filterFieldState = tester.state(_filter); + final filterEditableState = tester.state(filterEditable); + final searchCtl = tester + .widget(searchEditable) + .controller; + final filterCtl = tester + .widget(filterEditable) + .controller; + final searchText = searchCtl.value.text; + final searchSelection = searchCtl.value.selection; + final filterText = filterCtl.value.text; + final filterSelection = filterCtl.value.selection; + final activeQuery = network.activeFilter.value.queryFilter.query; + + expect(searchText, 'method:GET'); + expect(filterText, 'status:200'); + expect(network.search, 'method:GET'); + expect(_fieldHasFocus(tester, _filter), isTrue); + expect(_fieldHasFocus(tester, _search), isFalse); + + // Cross each boundary in both directions in the same tree, and finish + // on a wide desktop baseline. + for (final width in [599.0, 600.0, 899.0, 900.0, 899.0, 1400.0]) { + final resizeErrors = await collectFlutterErrors( + () => _setWindowWidth(tester, width), + ); + expect(resizeErrors, isEmpty, reason: resizeErrors.join('\n')); + + expect( + tester.state(_search), + same(searchFieldState), + reason: 'SearchField state lost at $width', + ); + expect( + tester.state(searchEditable), + same(searchEditableState), + reason: 'search EditableText state lost at $width', + ); + expect( + tester.state(_filter), + same(filterFieldState), + reason: 'StandaloneFilterField state lost at $width', + ); + expect( + tester.state(filterEditable), + same(filterEditableState), + reason: 'filter EditableText state lost at $width', + ); + expect( + tester.widget(searchEditable).controller, + same(searchCtl), + reason: 'search text controller replaced at $width', + ); + expect( + tester.widget(filterEditable).controller, + same(filterCtl), + reason: 'filter text controller replaced at $width', + ); + expect(searchCtl.value.text, searchText); + expect(searchCtl.value.selection, searchSelection); + expect(filterCtl.value.text, filterText); + expect(filterCtl.value.selection, filterSelection); + expect(network.search, 'method:GET'); + expect(network.activeFilter.value.queryFilter.query, activeQuery); + expect( + _fieldHasFocus(tester, _filter), + isTrue, + reason: 'filter focus lost at $width', + ); + expect( + _fieldHasFocus(tester, _search), + isFalse, + reason: 'remounted search field stole focus at $width', + ); + } + + // The wide baseline still places both fields in the top row. + final search = tester.getRect(_search); + final filter = tester.getRect(_filter); + final record = tester.getRect(_recordButton); + expect(search.center.dy, closeTo(record.center.dy, geometryTolerance)); + expect(filter.center.dy, closeTo(search.center.dy, geometryTolerance)); + + await stopNetworkPolling(tester, network); + }, + ); + }); + group('Network toolbar behavior', () { for (final width in [328.0, 900.0, 1400.0]) { testWidgetsWithWindowSize(