diff --git a/packages/devtools_app/lib/src/screens/network/network_controller.dart b/packages/devtools_app/lib/src/screens/network/network_controller.dart index deecab62c7e..43b75bf69db 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart @@ -192,6 +192,15 @@ class NetworkController extends DevToolsScreenController _currentNetworkRequests, _filterAndRefreshSearchMatches, ); + autoDisposeStreamSubscription( + serviceConnection.serviceManager.isolateManager.onIsolateCreated.listen(( + _, + ) async { + if (_recordingNotifier.value) { + await allowedError(_enableNetworkTrafficRecordingOnAllIsolates()); + } + }), + ); } @override @@ -346,13 +355,16 @@ class NetworkController extends DevToolsScreenController ]), ); - // TODO(kenz): only call these if http logging and socket profiling are not - // already enabled. Listen to service manager streams for this info. + await _enableNetworkTrafficRecordingOnAllIsolates(); + await togglePolling(true); + } + + /// Enables HTTP timeline logging and socket profiling on all isolates. + Future _enableNetworkTrafficRecordingOnAllIsolates() async { await [ http_service.toggleHttpRequestLogging(true), networkService.toggleSocketProfiling(true), ].wait; - await togglePolling(true); } Future stopRecording() async { 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 66843cf09b0..e2390beb28b 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -233,7 +233,7 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> Expanded( child: SearchField( searchController: controller, - searchFieldEnabled: hasRequests, + searchFieldEnabled: _recording || hasRequests, searchFieldWidth: screenWidth <= MediaSize.xs ? defaultSearchFieldWidth : wideSearchFieldWidth, diff --git a/packages/devtools_app/lib/src/screens/network/network_service.dart b/packages/devtools_app/lib/src/screens/network/network_service.dart index a93f35dc05a..8dcd94dd522 100644 --- a/packages/devtools_app/lib/src/screens/network/network_service.dart +++ b/packages/devtools_app/lib/src/screens/network/network_service.dart @@ -202,9 +202,21 @@ class NetworkService { } Future clearData() async { - await updateLastSocketDataRefreshTime(); - updateLastHttpDataRefreshTime(); - await _clearSocketProfile(); - await _clearHttpProfile(); + final service = serviceConnection.serviceManager.service; + if (service == null) return; + + try { + final timestamp = (await service.getVMTimelineMicros()).timestamp!; + networkController.lastSocketDataRefreshMicros = timestamp; + await service.forEachIsolate((isolate) async { + lastHttpDataRefreshTimePerIsolate[isolate.id!] = timestamp; + }); + await _clearSocketProfile(); + await _clearHttpProfile(); + } on RPCError catch (e) { + if (!e.isServiceDisposedError) { + rethrow; + } + } } } diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 2cd2c928fe0..4af01ad3c52 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -39,7 +39,15 @@ 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 tab would stop capturing HTTP requests after + a hot restart. - + [#9856](https://github.com/flutter/devtools/pull/9856) +* Fixed an issue where the Network tab would stop capturing new HTTP requests + after pressing Clear while recording. - + [#9856](https://github.com/flutter/devtools/pull/9856) +* Fixed an issue where the Network tab search field was disabled after pressing + Clear while recording. - + [#9856](https://github.com/flutter/devtools/pull/9856) ## Logging updates diff --git a/packages/devtools_app/test/screens/network/network_clear_test.dart b/packages/devtools_app/test/screens/network/network_clear_test.dart new file mode 100644 index 00000000000..59d761f71a6 --- /dev/null +++ b/packages/devtools_app/test/screens/network/network_clear_test.dart @@ -0,0 +1,312 @@ +// Copyright 2025 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_test/devtools_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/hot_restart_network_vm_service.dart'; +import 'utils/network_lifecycle_test_utils.dart'; +import 'utils/network_test_utils.dart'; + +void main() { + group('Network View clear button', () { + late HotRestartNetworkVmService vmService; + late FakeServiceConnectionManager fakeServiceConnection; + + setUp(() { + vmService = HotRestartNetworkVmService(); + fakeServiceConnection = FakeServiceConnectionManager(service: vmService); + }); + + tearDown(disposeNetworkLifecycleControllers); + + group('request visibility after clear', () { + test('displays new HTTP requests after clear while recording', () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'before-clear', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, hasLength(1)); + + await controller.clear(); + expect(controller.requests.value, isEmpty); + + vmService.appendHttpRequest( + isolateId, + createTestHttpRequest(id: 'after-clear', method: 'POST'), + ); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('POST'), + reason: + 'Network View should display new requests after Clear while ' + 'recording is active.', + ); + }); + + test('polling remains active after clear', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'polling-test', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + await controller.clear(); + + expect(controller.isPolling, isTrue); + expect(controller.recordingNotifier.value, isTrue); + }); + + test('keeps HTTP logging enabled after clear', () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'logging-test', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + await controller.clear(); + + expect(vmService.isHttpLoggingEnabled(isolateId), isTrue); + }); + + test('keeps socket profiling enabled after clear', () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'socket-test', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + await controller.clear(); + + expect(vmService.isSocketProfilingEnabled(isolateId), isTrue); + }); + }); + + group('clear refresh timestamp tracking', () { + test( + 'resets HTTP refresh timestamps to the VM timeline on clear', + () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + await controller.networkService.refreshNetworkData(); + + controller + .networkService + .lastHttpDataRefreshTimePerIsolate[isolateId] = + DateTime.now().microsecondsSinceEpoch; + + await controller.clear(); + + final timelineMicros = + (await vmService.getVMTimelineMicros()).timestamp!; + expect( + controller + .networkService + .lastHttpDataRefreshTimePerIsolate[isolateId], + timelineMicros, + ); + }, + ); + + test('does not show stale requests after clear', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest( + id: 'stale-request', + method: 'GET', + startTime: 1500000, + ), + ], + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, hasLength(1)); + + await controller.clear(); + await controller.networkService.refreshNetworkData(); + + expect(controller.requests.value, isEmpty); + }); + }); + + group('clear combined with hot restart', () { + test('clear then hot restart then new requests', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'pre-clear', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + await controller.clear(); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + + vmService.appendHttpRequest( + postRestartIsolateId, + createTestHttpRequest( + id: 'after-clear-and-restart', + method: 'PUT', + startTime: 9000000, + ), + ); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('PUT'), + ); + expect(vmService.isHttpLoggingEnabled(postRestartIsolateId), isTrue); + }); + + test('hot restart then clear then new requests', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'pre-restart', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + vmService.appendHttpRequest( + postRestartIsolateId, + createTestHttpRequest( + id: 'after-restart', + method: 'POST', + startTime: 8000000, + ), + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, isNotEmpty); + + await controller.clear(); + expect(controller.requests.value, isEmpty); + + vmService.appendHttpRequest( + postRestartIsolateId, + createTestHttpRequest( + id: 'after-restart-and-clear', + method: 'DELETE', + startTime: 10000000, + ), + ); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('DELETE'), + ); + }); + }); + + group('state after clear', () { + test('clears selected request', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'selected', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + controller.selectedRequest.value = controller.requests.value.first; + + await controller.clear(); + + expect(controller.selectedRequest.value, isNull); + }); + + test('preserves active search text', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest( + id: 'searchable', + method: 'GET', + uri: 'https://example.com/api', + ), + ], + ); + await controller.networkService.refreshNetworkData(); + controller.search = 'example'; + + await controller.clear(); + + expect(controller.search, 'example'); + }); + + test('supports multiple consecutive clears', () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'multi-clear-1', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + await controller.clear(); + await controller.clear(); + + vmService.appendHttpRequest( + isolateId, + createTestHttpRequest( + id: 'after-multi-clear', + method: 'PATCH', + startTime: 3000000, + ), + ); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('PATCH'), + ); + }); + }); + }); +} diff --git a/packages/devtools_app/test/screens/network/network_hot_restart_test.dart b/packages/devtools_app/test/screens/network/network_hot_restart_test.dart new file mode 100644 index 00000000000..452b489bde8 --- /dev/null +++ b/packages/devtools_app/test/screens/network/network_hot_restart_test.dart @@ -0,0 +1,402 @@ +// Copyright 2025 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_test/devtools_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/hot_restart_network_vm_service.dart'; +import 'utils/network_lifecycle_test_utils.dart'; +import 'utils/network_test_utils.dart'; + +void main() { + group('Network View hot restart', () { + late HotRestartNetworkVmService vmService; + late FakeServiceConnectionManager fakeServiceConnection; + + setUp(() { + vmService = HotRestartNetworkVmService(); + fakeServiceConnection = FakeServiceConnectionManager(service: vmService); + }); + + tearDown(disposeNetworkLifecycleControllers); + + group('baseline behavior before hot restart', () { + test('displays HTTP requests after recording starts', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'pre-restart-1', method: 'GET'), + ], + ); + + expect( + vmService.isHttpLoggingEnabled(vmService.currentIsolateId), + isTrue, + ); + await controller.networkService.refreshNetworkData(); + + expect(controller.requests.value, hasLength(1)); + expect( + controller.requests.value.single, + isA().having( + (request) => request.method, + 'method', + 'GET', + ), + ); + }); + + test('continues polling while connected', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + expect(controller.isPolling, isTrue); + expect(controller.recordingNotifier.value, isTrue); + }); + }); + + group('hot restart request visibility', () { + test( + 'continues displaying new HTTP requests after hot restart', + () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'pre-restart', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, hasLength(1)); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + vmService.setHttpProfile(postRestartIsolateId, [ + createTestHttpRequest( + id: 'post-restart', + method: 'POST', + startTime: 8000000, + ), + ]); + + await controller.networkService.refreshNetworkData(); + + final methods = controller.requests.value + .whereType() + .map((request) => request.method) + .toSet(); + expect( + methods, + contains('POST'), + reason: + 'Network View should display requests made on the new isolate ' + 'after a hot restart.', + ); + }, + ); + + test('polling remains active after hot restart', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + expect(controller.isPolling, isTrue); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + + expect(controller.isPolling, isTrue); + expect(controller.recordingNotifier.value, isTrue); + }); + }); + + group('profiling re-registration after hot restart', () { + test( + 're-enables HTTP timeline logging on the new isolate after hot restart', + () async { + final preRestartIsolateId = vmService.currentIsolateId; + await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + expect(vmService.isHttpLoggingEnabled(preRestartIsolateId), isTrue); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + + expect( + vmService.isHttpLoggingEnabled(postRestartIsolateId), + isTrue, + reason: + 'HTTP timeline logging must be re-enabled on the new main ' + 'isolate after a hot restart.', + ); + }, + ); + + test( + 're-enables socket profiling on the new isolate after hot restart', + () async { + final preRestartIsolateId = vmService.currentIsolateId; + await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + expect( + vmService.isSocketProfilingEnabled(preRestartIsolateId), + isTrue, + ); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + + expect( + vmService.isSocketProfilingEnabled(postRestartIsolateId), + isTrue, + reason: + 'Socket profiling must be re-enabled on the new main isolate ' + 'after a hot restart.', + ); + }, + ); + }); + + group('isolate refresh timestamp tracking', () { + test( + 'fetches HTTP profile data for a new isolate after profiling is re-enabled', + () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + await controller.networkService.refreshNetworkData(); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + vmService.setHttpProfile(postRestartIsolateId, [ + createTestHttpRequest( + id: 'new-isolate-request', + method: 'PUT', + startTime: 9000000, + ), + ]); + + await pumpEventQueue(); + await controller.networkService.refreshNetworkData(); + + expect( + controller.networkService.lastHttpDataRefreshTimePerIsolate + .containsKey(postRestartIsolateId), + isTrue, + reason: + 'NetworkService should track refresh timestamps for the new ' + 'isolate after a hot restart.', + ); + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('PUT'), + ); + }, + ); + + test( + 'stale isolate IDs do not prevent fetching from the new isolate', + () async { + final preRestartIsolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + await controller.networkService.refreshNetworkData(); + + controller + .networkService + .lastHttpDataRefreshTimePerIsolate[preRestartIsolateId] = + DateTime.now().microsecondsSinceEpoch; + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + vmService.setHttpProfile(postRestartIsolateId, [ + createTestHttpRequest( + id: 'after-stale-isolate', + method: 'DELETE', + startTime: 10000000, + ), + ]); + await pumpEventQueue(); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('DELETE'), + ); + }, + ); + }); + + group('state restoration across hot restart', () { + test('retains pre-restart requests when not cleared', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'kept-request', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, hasLength(1)); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + vmService.setHttpProfile(postRestartIsolateId, [ + createTestHttpRequest( + id: 'new-request', + method: 'POST', + startTime: 11000000, + ), + ]); + await pumpEventQueue(); + await controller.networkService.refreshNetworkData(); + + final methods = controller.requests.value + .whereType() + .map((request) => request.method) + .toList(); + expect(methods, contains('GET')); + expect(methods, contains('POST')); + }); + + test( + 'clears selected request when it is no longer in filtered data', + () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'selected-request', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + controller.selectedRequest.value = controller.requests.value.first; + + await controller.clear(); + expect(controller.selectedRequest.value, isNull); + }, + ); + }); + }); + + group('Network View service lifecycle', () { + tearDown(disposeNetworkLifecycleControllers); + + test( + 'starts recording when controller initializes while connected', + () async { + final reconnectionService = HotRestartNetworkVmService(); + final connection = FakeServiceConnectionManager( + service: reconnectionService, + ); + final reconnectedController = await initNetworkLifecycleController( + vmService: reconnectionService, + fakeServiceConnection: connection, + ); + + expect(reconnectedController.isPolling, isTrue); + expect( + reconnectionService.isHttpLoggingEnabled( + reconnectionService.currentIsolateId, + ), + isTrue, + ); + }, + ); + + test( + 're-initializes recording after controller is disposed and recreated', + () async { + final vmService = HotRestartNetworkVmService(); + final connection = FakeServiceConnectionManager(service: vmService); + final isolateId = vmService.currentIsolateId; + await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: connection, + ); + + screenControllers.disposeConnectedControllers(); + + final recreatedController = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: connection, + ); + + expect(recreatedController.isPolling, isTrue); + expect(vmService.isHttpLoggingEnabled(isolateId), isTrue); + }, + ); + }); + + group('NetworkService hot restart edge cases', () { + tearDown(disposeNetworkLifecycleControllers); + + test( + 'refreshNetworkData is a no-op when the VM service is unavailable', + () async { + final localVmService = HotRestartNetworkVmService(); + final localConnection = FakeServiceConnectionManager( + service: localVmService, + ); + final localController = await initNetworkLifecycleController( + vmService: localVmService, + fakeServiceConnection: localConnection, + ); + + localConnection.serviceManager.service = null; + await expectLater( + localController.networkService.refreshNetworkData(), + completes, + ); + expect(localController.requests.value, isEmpty); + }, + ); + + test( + 'updateLastHttpDataRefreshTime does not add entries for new isolates', + () async { + final localVmService = HotRestartNetworkVmService(); + final localConnection = FakeServiceConnectionManager( + service: localVmService, + ); + final localController = await initNetworkLifecycleController( + vmService: localVmService, + fakeServiceConnection: localConnection, + ); + + final postRestartIsolateId = localVmService.simulateHotRestart(); + localController.networkService.updateLastHttpDataRefreshTime(); + + expect( + localController.networkService.lastHttpDataRefreshTimePerIsolate + .containsKey(postRestartIsolateId), + isFalse, + reason: + 'updateLastHttpDataRefreshTime only updates existing isolate ' + 'entries; new isolates are registered on the first profile fetch.', + ); + }, + ); + }); +} diff --git a/packages/devtools_app/test/screens/network/network_profiler_test.dart b/packages/devtools_app/test/screens/network/network_profiler_test.dart index ff469552771..78861c43161 100644 --- a/packages/devtools_app/test/screens/network/network_profiler_test.dart +++ b/packages/devtools_app/test/screens/network/network_profiler_test.dart @@ -65,6 +65,8 @@ void main() { group('Network Profiler', () { setUp(() { + socketProfile = loadSocketProfile(); + httpProfile = loadHttpProfile(); fakeServiceConnection = FakeServiceConnectionManager( service: FakeServiceManager.createFakeService( socketProfile: socketProfile, @@ -338,6 +340,62 @@ void main() { // Wait to ensure all the timers have been cancelled. await tester.pumpAndSettle(const Duration(seconds: 2)); }); + + testWidgetsWithWindowSize( + 'search field stays enabled after clear while recording', + windowSize, + (WidgetTester tester) async { + controller = NetworkController(); + await pumpNetworkScreen(tester); + await tester.pumpAndSettle(); + expect(controller.isPolling, isTrue); + + controller.search = 'jsonplaceholder'; + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ClearButton)); + await tester.pumpAndSettle(const Duration(seconds: 2)); + + expect(controller.requests.value, isEmpty); + expect(controller.isPolling, isTrue); + expect(controller.search, 'jsonplaceholder'); + + final searchField = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is TextField && widget.decoration?.hintText == 'Search', + ), + ); + expect(searchField.enabled, isTrue); + + await clearTimeouts(tester); + }, + ); + + testWidgetsWithWindowSize( + 'search field stays enabled when recording is paused with requests', + windowSize, + (WidgetTester tester) async { + controller = NetworkController(); + await pumpNetworkScreen(tester); + await loadRequestsAndCheck(tester); + + await tester.tap(find.byType(StartStopRecordingButton)); + await tester.pumpAndSettle(); + + expect(controller.isPolling, isFalse); + + final searchField = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is TextField && widget.decoration?.hintText == 'Search', + ), + ); + expect(searchField.enabled, isTrue); + + await clearTimeouts(tester); + }, + ); }); group('NetworkRequestOverviewView', () { diff --git a/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart b/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart new file mode 100644 index 00000000000..090c12ad31b --- /dev/null +++ b/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart @@ -0,0 +1,189 @@ +// Copyright 2025 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 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:vm_service/vm_service.dart'; + +/// A fake VM service that models hot-restart isolate lifecycle for Network View +/// tests. +/// +/// After a hot restart, the VM spawns a new main isolate whose HTTP timeline +/// logging and socket profiling are disabled until explicitly re-enabled. +/// [getHttpProfileWrapper] and [getSocketProfileWrapper] return empty data when +/// profiling is disabled for an isolate, matching real VM behavior. +class HotRestartNetworkVmService extends FakeVmServiceWrapper { + HotRestartNetworkVmService() + : super( + _testVmFlagManager, + null, + null, + SocketProfile(sockets: []), + HttpProfile( + requests: [], + timestamp: DateTime.fromMicrosecondsSinceEpoch(0), + ), + null, + null, + null, + null, + null, + null, + null, + ) { + isHttpProfilingAvailableResult = true; + _httpLoggingEnabled[_currentIsolateId] = false; + _socketProfilingEnabled[_currentIsolateId] = false; + } + + static final _testVmFlagManager = VmFlagManager(); + + String _currentIsolateId = 'isolates/1'; + int _timelineMicros = 1000000; + + final _httpProfiles = >{}; + final _socketProfiles = >{}; + final _httpLoggingEnabled = {}; + final _socketProfilingEnabled = {}; + + /// The isolate ID currently returned by [forEachIsolate]. + String get currentIsolateId => _currentIsolateId; + + /// Sets the HTTP profile data that will be returned for [isolateId] when HTTP + /// timeline logging is enabled for that isolate. + void setHttpProfile(String isolateId, List requests) { + _httpProfiles[isolateId] = requests; + } + + /// Sets the socket profile data that will be returned for [isolateId] when + /// socket profiling is enabled for that isolate. + void setSocketProfile(String isolateId, List sockets) { + _socketProfiles[isolateId] = sockets; + } + + /// Whether HTTP timeline logging is enabled for [isolateId]. + bool isHttpLoggingEnabled(String isolateId) => + _httpLoggingEnabled[isolateId] ?? false; + + /// Whether socket profiling is enabled for [isolateId]. + bool isSocketProfilingEnabled(String isolateId) => + _socketProfilingEnabled[isolateId] ?? false; + + /// Simulates a hot restart by replacing the current isolate with a new one + /// that has HTTP logging and socket profiling disabled. + /// + /// Returns the ID of the new isolate. + String simulateHotRestart({String? newIsolateId}) { + final nextId = newIsolateId ?? '${_currentIsolateId}_restarted'; + _currentIsolateId = nextId; + _httpLoggingEnabled[nextId] = false; + _socketProfilingEnabled[nextId] = false; + _timelineMicros += 5000000; + return nextId; + } + + /// Appends [request] to the HTTP profile for [isolateId]. + void appendHttpRequest(String isolateId, HttpProfileRequest request) { + _httpProfiles[isolateId] = [ + ...(_httpProfiles[isolateId] ?? const []), + request, + ]; + } + + @override + Future clearHttpProfileWrapper(String isolateId) { + _httpProfiles[isolateId] = []; + return Future.value(Success()); + } + + @override + Future clearSocketProfileWrapper(String isolateId) { + _socketProfiles[isolateId] = []; + return Future.value(Success()); + } + + @override + Future forEachIsolate(Future Function(IsolateRef) callback) => + callback(IsolateRef.parse({'id': _currentIsolateId, 'name': 'main'})!); + + @override + Future httpEnableTimelineLoggingWrapper( + String isolateId, [ + bool? enabled, + ]) { + if (enabled != null) { + _httpLoggingEnabled[isolateId] = enabled; + return Future.value(HttpTimelineLoggingState(enabled: enabled)); + } + return Future.value( + HttpTimelineLoggingState( + enabled: _httpLoggingEnabled[isolateId] ?? false, + ), + ); + } + + @override + Future socketProfilingEnabledWrapper( + String isolateId, [ + bool? enabled, + ]) { + if (enabled != null) { + _socketProfilingEnabled[isolateId] = enabled; + return Future.value(SocketProfilingState(enabled: enabled)); + } + return Future.value( + SocketProfilingState( + enabled: _socketProfilingEnabled[isolateId] ?? false, + ), + ); + } + + @override + Future getHttpProfileWrapper( + String isolateId, { + DateTime? updatedSince, + }) { + if (!(_httpLoggingEnabled[isolateId] ?? false)) { + return Future.value( + HttpProfile( + requests: [], + timestamp: DateTime.fromMicrosecondsSinceEpoch(_timelineMicros), + ), + ); + } + + var requests = List.from( + _httpProfiles[isolateId] ?? const [], + ); + if (updatedSince != null) { + final sinceMicros = updatedSince.microsecondsSinceEpoch; + requests = requests + .where( + (request) => + request.startTime.microsecondsSinceEpoch >= sinceMicros, + ) + .toList(); + } + return Future.value( + HttpProfile( + requests: requests, + timestamp: DateTime.fromMicrosecondsSinceEpoch(_timelineMicros), + ), + ); + } + + @override + Future getSocketProfileWrapper(String isolateId) { + if (!(_socketProfilingEnabled[isolateId] ?? false)) { + return Future.value(SocketProfile(sockets: [])); + } + return Future.value( + SocketProfile(sockets: _socketProfiles[isolateId] ?? const []), + ); + } + + @override + Future getVMTimelineMicros() => + Future.value(Timestamp(timestamp: _timelineMicros)); +} diff --git a/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart b/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart new file mode 100644 index 00000000000..41e1c3dce63 --- /dev/null +++ b/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart @@ -0,0 +1,51 @@ +// Copyright 2025 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 'package:devtools_app/devtools_app.dart'; +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:vm_service/vm_service.dart'; + +import 'hot_restart_network_vm_service.dart'; + +Future initNetworkLifecycleController({ + required HotRestartNetworkVmService vmService, + required FakeServiceConnectionManager fakeServiceConnection, + List? initialProfile, +}) async { + if (initialProfile != null) { + vmService.setHttpProfile(vmService.currentIsolateId, initialProfile); + } + final controller = setUpNetworkLifecycleController(fakeServiceConnection); + await pumpEventQueue(); + return controller; +} + +NetworkController setUpNetworkLifecycleController( + FakeServiceConnectionManager fakeServiceConnection, +) { + setGlobal(OfflineDataController, OfflineDataController()); + setGlobal(ScreenControllers, ScreenControllers()); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + setGlobal(PreferencesController, PreferencesController()); + screenControllers.register(() => NetworkController()); + return screenControllers.lookup(); +} + +void notifyMainIsolateChanged( + FakeServiceConnectionManager connection, + String newIsolateId, +) { + final isolateManager = + connection.serviceManager.isolateManager as FakeIsolateManager; + final isolateRef = IsolateRef.parse({'id': newIsolateId, 'name': 'main'})!; + (isolateManager.mainIsolate as ValueNotifier).value = isolateRef; + isolateManager.notifyIsolateCreated(isolateRef); +} + +void disposeNetworkLifecycleControllers() { + screenControllers.disposeConnectedControllers(); +} diff --git a/packages/devtools_app/test/screens/network/utils/network_test_utils.dart b/packages/devtools_app/test/screens/network/utils/network_test_utils.dart index 0d9506c787e..be76aafae4c 100644 --- a/packages/devtools_app/test/screens/network/utils/network_test_utils.dart +++ b/packages/devtools_app/test/screens/network/utils/network_test_utils.dart @@ -6,6 +6,32 @@ import 'package:vm_service/vm_service.dart'; import '../../../test_infra/test_data/network.dart'; +/// Creates a minimal [HttpProfileRequest] for use in Network View tests. +HttpProfileRequest createTestHttpRequest({ + required String id, + required String method, + int startTime = 2000000, + String uri = 'https://example.com/test', +}) { + final endTime = startTime + 1000; + return HttpProfileRequest.parse({ + 'type': 'HttpProfileRequest', + 'id': id, + 'isolateId': 'isolates/test', + 'method': method, + 'uri': uri, + 'events': [], + 'startTime': startTime, + 'endTime': endTime, + 'response': { + 'startTime': startTime, + 'endTime': endTime, + 'redirects': [], + 'statusCode': 200, + }, + })!; +} + SocketProfile loadSocketProfile() { return SocketProfile( sockets: [ diff --git a/packages/devtools_app_shared/CHANGELOG.md b/packages/devtools_app_shared/CHANGELOG.md index b2529769d62..6034b57ac24 100644 --- a/packages/devtools_app_shared/CHANGELOG.md +++ b/packages/devtools_app_shared/CHANGELOG.md @@ -6,6 +6,8 @@ found in the LICENSE file or at https://developers.google.com/open-source/licens ## 0.5.2-wip * Fix a `RangeError` thrown by `SplitPane` when the number of children changes between rebuilds. +* Add `IsolateManager.onIsolateCreated` stream for detecting when new isolates + are spawned. * The minimum Dart SDK version is bumped to 3.11.0. * The minimum Flutter SDK version is bumped to 3.41.0. diff --git a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart index 9da67d86278..71ac7d41341 100644 --- a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart @@ -40,6 +40,13 @@ final class IsolateManager with DisposerMixin { final _isolateCreatedController = StreamController.broadcast(); final _isolateExitedController = StreamController.broadcast(); + /// Stream of isolates that have been created since the VM service connection + /// was opened. + /// + /// This can be used to detect when a new isolate is spawned, such as after a + /// hot restart. + Stream get onIsolateCreated => _isolateCreatedController.stream; + ValueListenable get selectedIsolate => _selectedIsolate; final _selectedIsolate = ValueNotifier(null); diff --git a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart index dfc387e72c4..5e9823fdd07 100644 --- a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart +++ b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart @@ -4,6 +4,8 @@ // ignore_for_file: invalid_use_of_visible_for_testing_member, devtools_test is only used in test code. +import 'dart:async'; + import 'package:devtools_app_shared/service.dart'; // ignore: implementation_imports, intentional import from src/ import 'package:devtools_app_shared/src/service/isolate_manager.dart'; @@ -30,6 +32,20 @@ base class FakeIsolateManager extends Fake with TestIsolateManager { IsolateRef.parse({'id': 'fake_main_isolate_id'}), ); + final _isolateCreatedController = StreamController.broadcast(); + + @override + Stream get onIsolateCreated => _isolateCreatedController.stream; + + /// Notifies listeners that a new isolate was created. + /// + /// This can be used in tests to simulate a hot restart spawning a new + /// isolate. + @visibleForTesting + void notifyIsolateCreated(IsolateRef isolate) { + _isolateCreatedController.add(isolate); + } + @override ValueNotifier> get isolates { return _isolates ??= ValueNotifier([?_selectedIsolate.value]);