diff --git a/packages/devtools_app/lib/src/framework/observer/_memory_web.dart b/packages/devtools_app/lib/src/framework/observer/_memory_web.dart index c6e56a3bbd3..251cfb64071 100644 --- a/packages/devtools_app/lib/src/framework/observer/_memory_web.dart +++ b/packages/devtools_app/lib/src/framework/observer/_memory_web.dart @@ -65,7 +65,8 @@ extension type _UserAgentSpecificMemoryBreakdownAttributionElement._(JSObject _) @JS() extension type _UserAgentSpecificMemoryBreakdownAttributionContainerElement._( JSObject _ -) implements JSObject { +) + implements JSObject { external String get id; external String get url; diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 6893cc7d4f4..f6a37b10965 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -27,7 +27,7 @@ TODO: Remove this section if there are not any updates. ## CPU profiler updates -TODO: Remove this section if there are not any updates. +- Fixed an issue where DevTools would auto-select the test runner isolate instead of the test suite isolate when connecting to a test run, causing CPU profiling and other tools to show no data (#9747). [#9747](https://github.com/flutter/devtools/pull/9747) ## Memory updates @@ -69,4 +69,4 @@ TODO: Remove this section if there are not any updates. ## Full commit history To find a complete list of changes in this release, check out the -[DevTools git log](https://github.com/flutter/devtools/tree/v2.57.0). +[DevTools git log](https://github.com/flutter/devtools/tree/v2.57.0). \ No newline at end of file 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..ce5c527fafe 100644 --- a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart @@ -166,20 +166,25 @@ final class IsolateManager with DisposerMixin { !event.isolate!.isSystemIsolate!) { await _registerIsolate(event.isolate!); _isolateCreatedController.add(event.isolate); - // TODO(jacobr): we assume the first isolate started is the main isolate - // but that may not always be a safe assumption. - if (_mainIsolate.value == null) { + + // Recompute whenever a new isolate starts so test connections can move + // from the runner isolate to the user test-suite isolate when available. + final previousMain = _mainIsolate.value; + final computedMain = await _computeMainIsolate(); + if (computedMain != null) { + _mainIsolate.value = computedMain; + } else if (_mainIsolate.value == null) { _mainIsolate.value = event.isolate; - if (_shouldReselectMainIsolate) { - // Assume the main isolate has come back up after a hot restart, so - // select it. - _shouldReselectMainIsolate = false; - _setSelectedIsolate(event.isolate); - } } - if (_selectedIsolate.value == null) { - _setSelectedIsolate(event.isolate); + if (_mainIsolate.value != null && + (_shouldReselectMainIsolate || + _selectedIsolate.value == null || + _selectedIsolate.value == previousMain)) { + // If the previous main exited and returned (hot restart) or we were + // following the previous main, follow the newly computed main isolate. + _shouldReselectMainIsolate = false; + _setSelectedIsolate(_mainIsolate.value); } } else if (event.kind == EventKind.kServiceExtensionAdded) { // Check to see if there is a new isolate. @@ -223,25 +228,71 @@ final class IsolateManager with DisposerMixin { final service = _service; for (final isolateState in _isolateStates.values) { - if (_selectedIsolate.value == null) { - final isolate = await isolateState.isolate; - if (service != _service) return null; - for (final extensionName in isolate?.extensionRPCs ?? []) { - if (extensions.isFlutterExtension(extensionName)) { - return isolateState.isolateRef; - } + final isolate = await isolateState.isolate; + if (service != _service) return null; + for (final extensionName in isolate?.extensionRPCs ?? []) { + if (extensions.isFlutterExtension(extensionName)) { + return isolateState.isolateRef; } } } final ref = _isolateStates.keys.firstWhereOrNull((IsolateRef ref) { // 'foo.dart:main()' - return ref.name!.contains(':main('); + return ref.name?.contains(':main(') ?? false; }); + if (ref == null) { + final rootLibraryTestSuiteRef = + await _findTestSuiteByRootLibrary(service); + if (rootLibraryTestSuiteRef != null) return rootLibraryTestSuiteRef; + + // When connecting to a test run, the test package (package:test_core) + // spawns each test suite in a separate isolate with a debug name + // prefixed with 'test_suite:'. DevTools should connect to this isolate + // rather than the test runner isolate ('main'), since the test suite + // isolate is where user code actually runs. + // See: https://github.com/flutter/devtools/issues/9747 + final testSuiteRef = _isolateStates.keys.firstWhereOrNull( + (IsolateRef ref) => ref.name?.startsWith('test_suite:') ?? false, + ); + if (testSuiteRef != null) return testSuiteRef; + } + return ref ?? _isolateStates.keys.first; } + Future _findTestSuiteByRootLibrary(VmService? service) async { + for (final isolateState in _isolateStates.values) { + final isolate = await isolateState.isolate; + if (service != _service) return null; + + final rootLibraryUri = isolate?.rootLib?.uri; + if (rootLibraryUri == null) continue; + + if (_isDartTestRunnerRootLibrary(rootLibraryUri)) continue; + + if (_isLikelyUserTestRootLibrary(rootLibraryUri)) { + return isolateState.isolateRef; + } + } + + return null; + } + + bool _isDartTestRunnerRootLibrary(String uri) { + return uri.contains('dart_test.kernel') || + uri.startsWith('package:test_core/') || + uri.startsWith('package:test_api/'); + } + + bool _isLikelyUserTestRootLibrary(String uri) { + return (uri.endsWith('_test.dart') || + uri.contains('/test/') || + uri.contains('\\test\\')) && + !uri.startsWith('dart:'); + } + void _setSelectedIsolate(IsolateRef? ref) { _selectedIsolate.value = ref; } diff --git a/packages/devtools_app_shared/test/service/isolate_manager_test.dart b/packages/devtools_app_shared/test/service/isolate_manager_test.dart new file mode 100644 index 00000000000..ccb1ba17d4a --- /dev/null +++ b/packages/devtools_app_shared/test/service/isolate_manager_test.dart @@ -0,0 +1,252 @@ +// 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_shared/src/service/isolate_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vm_service/vm_service.dart'; + +/// Minimal fake VmService for IsolateManager tests. +class _FakeVmService extends Fake implements VmService { + _FakeVmService(this.isolates); + + /// Map of isolate id -> Isolate to return from getIsolate(). + final Map isolates; + + final _isolateEventController = StreamController.broadcast(); + + @override + Stream get onIsolateEvent => _isolateEventController.stream; + + @override + Stream get onDebugEvent => const Stream.empty(); + + @override + Future getIsolate(String isolateId) async { + return isolates[isolateId] ?? + Isolate.parse({ + 'id': isolateId, + 'runnable': true, + 'extensionRPCs': [], + })!; + } + + @override + Future resume(String isolateId, {String? step, int? frameIndex}) => + Future.value(Success()); + + Future emitIsolateStart(IsolateRef isolateRef) async { + _isolateEventController.add( + Event.parse({ + 'type': 'Event', + 'kind': EventKind.kIsolateStart, + 'isolate': { + 'type': '@Isolate', + 'id': isolateRef.id, + 'name': isolateRef.name, + 'isSystemIsolate': isolateRef.isSystemIsolate, + }, + })!, + ); + await Future.delayed(Duration.zero); + } + + @override + Future dispose() async { + await _isolateEventController.close(); + } +} + +/// Creates a minimal runnable [Isolate] for a given [IsolateRef]. +Isolate _makeIsolate(IsolateRef ref, {String? rootLibraryUri}) { + final json = { + 'id': ref.id, + 'name': ref.name, + 'type': '@Isolate', + 'runnable': true, + 'extensionRPCs': [], + if (rootLibraryUri != null) + 'rootLib': { + 'type': '@Library', + 'id': 'libraries/0', + 'uri': rootLibraryUri, + }, + }; + + return Isolate.parse(json)!; +} + +/// Creates an [IsolateRef] with the given name and id. +IsolateRef _makeRef(String name, String id) { + return IsolateRef.parse({'name': name, 'id': id, 'isSystemIsolate': false})!; +} + +void main() { + group('IsolateManager._computeMainIsolate', () { + late IsolateManager manager; + final fakeServices = <_FakeVmService>[]; + + setUp(() { + manager = IsolateManager(); + }); + + tearDown(() { + manager.handleVmServiceClosed(); + for (final fakeService in fakeServices) { + unawaited(fakeService.dispose()); + } + fakeServices.clear(); + }); + + test( + 'selects test_suite isolate instead of test runner when running tests', + () async { + // Simulates the isolate list seen when connecting to a test run: + // - 'main' is the test runner isolate (wrong choice) + // - 'test_suite:...' is where user code actually runs (correct choice) + // - 'vm-service' is infrastructure + final testRunnerRef = _makeRef('main', 'isolates/1'); + final testSuiteRef = _makeRef( + 'test_suite:file:///tmp/dart_test.kernel.dill', + 'isolates/2', + ); + final vmServiceRef = _makeRef('vm-service', 'isolates/3'); + + final fakeService = _FakeVmService({ + 'isolates/1': _makeIsolate(testRunnerRef), + 'isolates/2': _makeIsolate(testSuiteRef), + 'isolates/3': _makeIsolate(vmServiceRef), + }); + fakeServices.add(fakeService); + + manager.vmServiceOpened(fakeService); + await manager.init([testRunnerRef, testSuiteRef, vmServiceRef]); + + expect( + manager.selectedIsolate.value?.name, + equals('test_suite:file:///tmp/dart_test.kernel.dill'), + reason: + 'Should auto-select the test_suite isolate, not the test runner', + ); + expect( + manager.mainIsolate.value?.name, + equals('test_suite:file:///tmp/dart_test.kernel.dill'), + reason: 'Main isolate should also resolve to the test_suite isolate', + ); + }, + ); + + test('selects main isolate for normal (non-test) app runs', () async { + final mainRef = _makeRef('main', 'isolates/1'); + final vmServiceRef = _makeRef('vm-service', 'isolates/2'); + + final fakeService = _FakeVmService({ + 'isolates/1': _makeIsolate(mainRef), + 'isolates/2': _makeIsolate(vmServiceRef), + }); + fakeServices.add(fakeService); + + manager.vmServiceOpened(fakeService); + await manager.init([mainRef, vmServiceRef]); + + expect( + manager.selectedIsolate.value?.name, + equals('main'), + reason: 'Should select the main isolate for normal app runs', + ); + }); + + test('selects isolate containing :main( for dart scripts', () async { + final scriptRef = _makeRef('foo.dart:main()', 'isolates/1'); + + final fakeService = _FakeVmService({ + 'isolates/1': _makeIsolate(scriptRef), + }); + fakeServices.add(fakeService); + + manager.vmServiceOpened(fakeService); + await manager.init([scriptRef]); + + expect( + manager.selectedIsolate.value?.name, + equals('foo.dart:main()'), + ); + }); + + test( + 'selects test isolate by root library when test_suite prefix is absent', + () async { + final testRunnerRef = _makeRef('main', 'isolates/1'); + final userTestRef = _makeRef('isolate-2', 'isolates/2'); + final vmServiceRef = _makeRef('vm-service', 'isolates/3'); + + final fakeService = _FakeVmService({ + 'isolates/1': _makeIsolate( + testRunnerRef, + rootLibraryUri: 'file:///tmp/dart_test.kernel.abcd/test.dart', + ), + 'isolates/2': _makeIsolate( + userTestRef, + rootLibraryUri: 'package:my_app/foo_test.dart', + ), + 'isolates/3': _makeIsolate( + vmServiceRef, + rootLibraryUri: 'dart:developer', + ), + }); + fakeServices.add(fakeService); + + manager.vmServiceOpened(fakeService); + await manager.init([testRunnerRef, userTestRef, vmServiceRef]); + + expect( + manager.selectedIsolate.value?.name, + equals('isolate-2'), + reason: 'Should choose user test isolate using root library metadata', + ); + expect( + manager.mainIsolate.value?.name, + equals('isolate-2'), + ); + }, + ); + + test( + 'promotes main isolate from test runner to test suite on isolate start', + () async { + final testRunnerRef = _makeRef('main', 'isolates/1'); + final testSuiteRef = _makeRef( + 'test_suite:file:///tmp/dart_test.kernel.dill', + 'isolates/2', + ); + + final fakeService = _FakeVmService({ + 'isolates/1': _makeIsolate(testRunnerRef), + 'isolates/2': _makeIsolate(testSuiteRef), + }); + fakeServices.add(fakeService); + + manager.vmServiceOpened(fakeService); + await manager.init(const []); + + await fakeService.emitIsolateStart(testRunnerRef); + expect(manager.selectedIsolate.value?.name, equals('main')); + expect(manager.mainIsolate.value?.name, equals('main')); + + await fakeService.emitIsolateStart(testSuiteRef); + expect( + manager.selectedIsolate.value?.name, + equals('test_suite:file:///tmp/dart_test.kernel.dill'), + reason: + 'Should switch selection to test_suite isolate once it starts', + ); + expect( + manager.mainIsolate.value?.name, + equals('test_suite:file:///tmp/dart_test.kernel.dill'), + ); + }, + ); + }); +}