diff --git a/pkg/analysis_server/CONTRIBUTING.md b/pkg/analysis_server/CONTRIBUTING.md index 087291b12ff..610e06ec9af 100644 --- a/pkg/analysis_server/CONTRIBUTING.md +++ b/pkg/analysis_server/CONTRIBUTING.md @@ -64,6 +64,14 @@ To run just the analysis server integration tests: ./tools/test.py -mrelease pkg/analysis_server/test/integration/ ``` +To run a single test: + +``` +dart test pkg/analysis_server/test/some_test.dart +``` + +> Note: `dart` may need to point to a Dart SDK built from source +depending on the changes you are testing. [building]: https://github.com/dart-lang/sdk/wiki/Building [contributing]: https://github.com/dart-lang/sdk/blob/master/CONTRIBUTING.md diff --git a/pkg/analysis_server/lib/src/lsp/handlers/custom/handler_experimental_echo.dart b/pkg/analysis_server/lib/src/lsp/handlers/custom/handler_experimental_echo.dart index 59eaa5f2409..df3e3a747fe 100644 --- a/pkg/analysis_server/lib/src/lsp/handlers/custom/handler_experimental_echo.dart +++ b/pkg/analysis_server/lib/src/lsp/handlers/custom/handler_experimental_echo.dart @@ -29,10 +29,10 @@ class ExperimentalEchoHandler extends SharedMessageHandler { MessageInfo message, CancellationToken token, ) async { - // The DTD client automatically converts `null` params to an empty map, but - // (because of a previous bug) we want to test null results. So if the - // params are an empty map, return null. This is tested by - // `test_service_success_echo_nullResponse` in `SharedDtdTests`. + // The DTD client may send an empty map as params. Return null in this case. + // This is tested by + //`test_service_success_echo_nullResponse_with_empty_params` in + // `SharedDtdTests`. if (params is Map && params.isEmpty) { return success(null); } diff --git a/pkg/analysis_server/lib/src/services/dart_tooling_daemon/dtd_services.dart b/pkg/analysis_server/lib/src/services/dart_tooling_daemon/dtd_services.dart index 7a3ed524980..0d542fc09f3 100644 --- a/pkg/analysis_server/lib/src/services/dart_tooling_daemon/dtd_services.dart +++ b/pkg/analysis_server/lib/src/services/dart_tooling_daemon/dtd_services.dart @@ -179,7 +179,7 @@ class DtdServices { var message = IncomingMessage( jsonrpc: jsonRpcVersion, method: method, - params: params.asMap, + params: params.value, ); var scheduler = _server.messageScheduler; var completer = Completer>(); diff --git a/pkg/analysis_server/test/shared/shared_dtd_tests.dart b/pkg/analysis_server/test/shared/shared_dtd_tests.dart index b9ac0a5ea05..2187ec4ba64 100644 --- a/pkg/analysis_server/test/shared/shared_dtd_tests.dart +++ b/pkg/analysis_server/test/shared/shared_dtd_tests.dart @@ -88,7 +88,10 @@ mixin SharedDtdTests /// A list of service/methods that the test client has seen registered (and /// not yet unregistered) over the DTD connection. - final availableMethods = <(String, Method)>[]; + /// + /// The service name is a nullable String because DTD-internal methods and + /// services do not have a service name. + final availableMethods = <(String?, Method)>[]; /// An invalid DTD URI used for testing connection failures. final invalidUri = Uri.parse('ws://invalid:345/invalid'); @@ -207,12 +210,12 @@ mixin SharedDtdTests switch (e.kind) { case 'ServiceRegistered': availableMethods.add(( - e.data['service'] as String, + e.data['service'] as String?, Method(e.data['method'] as String), )); case 'ServiceUnregistered': availableMethods.remove(( - e.data['service'] as String, + e.data['service'] as String?, Method(e.data['method'] as String), )); } @@ -470,7 +473,24 @@ FutureOr? a; expect(result, equals({'a': 'b'})); } - Future test_service_success_echo_nullResponse() async { + Future + test_service_success_echo_nullResponse_with_empty_params() async { + await initializeServer(); + await sendConnectToDtdRequest(registerExperimentalHandlers: true); + + var response = await dtd.connection.call( + lspServiceName, + CustomMethods.experimentalEcho.toString(), + params: const {}, + ); + + var result = response.result['result'] as Map?; + + expect(response.type, 'Null'); + expect(result, isNull); + } + + Future test_service_success_echo_nullResponse_with_null_params() async { await initializeServer(); await sendConnectToDtdRequest(registerExperimentalHandlers: true); @@ -512,7 +532,10 @@ void [!myFun^ction!]() {} await initializeServer(); await sendConnectToDtdRequest(); - expect(availableMethods, isNotEmpty); + var lspMethods = availableMethods.where( + (serviceMethod) => serviceMethod.$1 == 'Lsp', + ); + expect(lspMethods, isNotEmpty); // Send a request to the server to connect to DTD. This will only complete // once all services are registered, however there's no guarantee about the @@ -521,9 +544,9 @@ void [!myFun^ction!]() {} await shutdownServer(); // Wait for the services to be unregistered. - while (availableMethods.isNotEmpty) { + while (lspMethods.isNotEmpty) { await pumpEventQueue(times: 5000); } - expect(availableMethods, isEmpty); + expect(lspMethods, isEmpty); } } diff --git a/pkg/dart_service_protocol_shared/CHANGELOG.md b/pkg/dart_service_protocol_shared/CHANGELOG.md index 3177fff43e7..ad9dc356c8a 100644 --- a/pkg/dart_service_protocol_shared/CHANGELOG.md +++ b/pkg/dart_service_protocol_shared/CHANGELOG.md @@ -1,5 +1,8 @@ -## 0.0.3-wip +## 0.0.3 - Update sdk constraint to '^3.5.0' +- Added `ClientServiceInfo.fromJson` and `ClientServiceInfo.toJson` methods. +- Added `ClientServiceMethodInfo.fromJson` and `ClientServiceMethodInfo.toJson` +methods. ## 0.0.2 - Fixed an issue with streamNotify data type being too specific. diff --git a/pkg/dart_service_protocol_shared/lib/src/client.dart b/pkg/dart_service_protocol_shared/lib/src/client.dart index 59a52a70127..874511612e1 100644 --- a/pkg/dart_service_protocol_shared/lib/src/client.dart +++ b/pkg/dart_service_protocol_shared/lib/src/client.dart @@ -48,6 +48,26 @@ class ClientServiceInfo { ClientServiceInfo(this.name, [Map? methods]) : methods = methods ?? {}; + /// Deserializes a [json] object to create a [ClientServiceInfo] object. + static ClientServiceInfo fromJson(Map json) { + if (json case {_kName: final String name, _kMethods: final List methods}) { + return ClientServiceInfo( + name, + { + for (final method in methods + .cast>() + .map(ClientServiceMethodInfo.fromJson)) + method.name: method + }, + ); + } + throw ArgumentError('Unexpected JSON format: $json'); + } + + static const _kName = 'name'; + + static const _kMethods = 'methods'; + /// The name of the service. /// /// A client can register multiple services each with multiple methods. @@ -58,6 +78,12 @@ class ClientServiceInfo { /// The service methods registered for this service. final Map methods; + + /// Serializes this [ClientServiceInfo] object to JSON. + Map toJson() => { + _kName: name, + _kMethods: methods.values.map((m) => m.toJson()).toList(), + }; } /// Information about an individual method of a service provided by a @@ -65,6 +91,22 @@ class ClientServiceInfo { class ClientServiceMethodInfo { ClientServiceMethodInfo(this.name, [this.capabilities]); + /// Deserializes a [json] object to create a [ClientServiceMethodInfo] object. + static ClientServiceMethodInfo fromJson(Map json) { + try { + return ClientServiceMethodInfo( + json[_kName] as String, + json[_kCapabilities] as Map?, + ); + } catch (e) { + throw ArgumentError('Unexpected JSON format: $json'); + } + } + + static const _kName = 'name'; + + static const _kCapabilities = 'capabilities'; + /// The name of the method. /// /// A client can register multiple methods for each service but can only use @@ -74,6 +116,12 @@ class ClientServiceMethodInfo { /// Optional capabilities of this service method provided by the client. final Map? capabilities; + + /// Serializes this [ClientServiceMethodInfo] object to JSON. + Map toJson() => { + _kName: name, + if (capabilities != null) _kCapabilities: capabilities, + }; } /// Used for keeping track and managing clients that are connected to a given diff --git a/pkg/dart_service_protocol_shared/pubspec.yaml b/pkg/dart_service_protocol_shared/pubspec.yaml index 2e0122ea3d1..ffe5545dd53 100644 --- a/pkg/dart_service_protocol_shared/pubspec.yaml +++ b/pkg/dart_service_protocol_shared/pubspec.yaml @@ -1,7 +1,7 @@ name: dart_service_protocol_shared description: A package that implements service extensions and stream managers. -version: 0.0.3-wip +version: 0.0.3 repository: https://github.com/dart-lang/sdk/tree/main/pkg/dart_service_protocol_shared environment: diff --git a/pkg/dart_service_protocol_shared/test/client_test.dart b/pkg/dart_service_protocol_shared/test/client_test.dart index f25dd5f28d6..5703dd7e94a 100644 --- a/pkg/dart_service_protocol_shared/test/client_test.dart +++ b/pkg/dart_service_protocol_shared/test/client_test.dart @@ -128,4 +128,82 @@ void main() { ); }); }); + + group('ClientServiceInfo', () { + test('toJson and fromJson', () { + final serviceInfo = ClientServiceInfo( + 'testService', + { + 'method1': ClientServiceMethodInfo('method1', {'capability': true}), + 'method2': ClientServiceMethodInfo('method2'), + }, + ); + + final json = serviceInfo.toJson(); + final deserialized = ClientServiceInfo.fromJson(json); + + expect(deserialized.name, 'testService'); + expect(deserialized.methods.length, 2); + expect(deserialized.methods['method1']?.name, 'method1'); + expect( + deserialized.methods['method1']?.capabilities, + {'capability': true}, + ); + expect(deserialized.methods['method2']?.name, 'method2'); + expect(deserialized.methods['method2']?.capabilities, isNull); + }); + + test('fromJson throws with invalid json', () { + expect( + () => ClientServiceInfo.fromJson({ + 'name': 'testService', + 'methods': [ + {'bad': 'format'} + ], + }), + throwsA(isA()), + ); + expect( + () => ClientServiceInfo.fromJson({ + 'methods': [ + {'name': 'method1'} + ], + }), + throwsA(isA()), + reason: 'Missing "name" field in top-level map', + ); + }); + }); + + group('ClientServiceMethodInfo', () { + test('toJson and parse', () { + final methodInfo = + ClientServiceMethodInfo('testMethod', {'capability': 123}); + + final json = methodInfo.toJson(); + final deserialized = ClientServiceMethodInfo.fromJson(json); + + expect(deserialized.name, 'testMethod'); + expect(deserialized.capabilities, {'capability': 123}); + }); + + test('toJson and parse without capabilities', () { + final methodInfo = ClientServiceMethodInfo('testMethod'); + + final json = methodInfo.toJson(); + final deserialized = ClientServiceMethodInfo.fromJson(json); + + expect(deserialized.name, 'testMethod'); + expect(deserialized.capabilities, isNull); + }); + }); + + test('ClientServiceMethodInfo throws with invalid json', () { + expect( + () => ClientServiceMethodInfo.fromJson({ + 'bad': 'format', + }), + throwsA(isA()), + ); + }); } diff --git a/pkg/dtd/CHANGELOG.md b/pkg/dtd/CHANGELOG.md index a9ab9457fb3..c9a2f1f748d 100644 --- a/pkg/dtd/CHANGELOG.md +++ b/pkg/dtd/CHANGELOG.md @@ -1,8 +1,15 @@ -## 2.6.0 +## 3.0.0 -- Add `ConnectedAppService` to store the connections to Dart and Flutter +- Added `ConnectedAppService` to store the connections to Dart and Flutter applications that DTD is aware of. - Log exceptions from invalid `streamNotify` events. +- Added `getRegisteredServices` API. +- Added a new response type `RegisteredServicesResponse`. +- **Breaking Change**: Changed the `serviceName` parameter for the +`DartToolingDaemon.call` method to have type `String?` instead of `String.` +- **Breaking Change**: When the `params` parameter for the +`DartToolingDaemon.call` method is null, pass the null value along to the client +peer request instead of sending an empty Map value. ## 2.5.1 diff --git a/pkg/dtd/example/dtd_service_example.dart b/pkg/dtd/example/dtd_service_example.dart index 3cfbb03d6f3..0199cfabcfe 100644 --- a/pkg/dtd/example/dtd_service_example.dart +++ b/pkg/dtd/example/dtd_service_example.dart @@ -34,9 +34,13 @@ void main(List args) async { clientB.onEvent('Service').listen((e) { switch (e.kind) { case 'ServiceRegistered': - serviceRegisteredCompleted.complete(); + if (e.data['service'] == 'ExampleServer') { + serviceRegisteredCompleted.complete(); + } case 'ServiceUnregistered': - serviceUnregisteredCompleted.complete(); + if (e.data['service'] == 'ExampleServer') { + serviceUnregisteredCompleted.complete(); + } } print(jsonEncode({'stream': e.stream, 'kind': e.kind, 'data': e.data})); }); diff --git a/pkg/dtd/lib/dtd.dart b/pkg/dtd/lib/dtd.dart index 6ff958013cb..cceacb69b42 100644 --- a/pkg/dtd/lib/dtd.dart +++ b/pkg/dtd/lib/dtd.dart @@ -5,10 +5,9 @@ /// Support for communicating with the Dart Tooling Daemon. library; -export 'src/connected_app_service.dart'; export 'src/constants.dart'; export 'src/dart_tooling_daemon.dart'; -export 'src/file_system/file_system_service.dart'; -export 'src/file_system/types.dart'; -export 'src/response_types.dart'; +export 'src/response_types/response_types.dart'; export 'src/rpc_error_codes.dart'; +export 'src/services/connected_app_service.dart'; +export 'src/services/file_system_service.dart'; diff --git a/pkg/dtd/lib/src/dart_tooling_daemon.dart b/pkg/dtd/lib/src/dart_tooling_daemon.dart index 0bd0393d1ff..dd36de5ebbe 100644 --- a/pkg/dtd/lib/src/dart_tooling_daemon.dart +++ b/pkg/dtd/lib/src/dart_tooling_daemon.dart @@ -117,6 +117,17 @@ class DartToolingDaemon { ); } + /// Returns a structured response with all the currently registered services + /// available on this DTD instance. + Future getRegisteredServices() async { + final json = await _clientPeer.sendRequest( + 'getRegisteredServices', + ) as Map; + + final dtdResponse = _dtdResponseFromJson(json); + return RegisteredServicesResponse.fromDTDResponse(dtdResponse); + } + /// Subscribes this client to events posted on [streamId]. /// /// Once called, the Dart Tooling Daemon will then send any events on the @@ -185,26 +196,35 @@ class DartToolingDaemon { } /// Invokes the service method registered with the name - /// `[serviceName].[methodName]`. + /// `[serviceName].[methodName]`, or with `[methodName]` when [serviceName] is + /// null. + /// + /// [serviceName] may be null if the service method is a first party service + /// method registered by DTD or by an internal service. /// /// If provided, [params] will be sent as the set of parameters used when /// invoking the service. /// - /// If `[serviceName].[methodName]` is not a registered service method, an - /// [RpcException] will be thrown with [RpcErrorCodes.kMethodNotFound]. + /// If `[serviceName].[methodName]`, or `[methodName]` when [serviceName] is + /// null, is not a registered service method, an [RpcException] will be thrown + /// with [RpcErrorCodes.kMethodNotFound]. /// /// If the parameters included in [params] are invalid, an [RpcException] will /// be thrown with [RpcErrorCodes.kInvalidParams]. Future call( - String serviceName, + String? serviceName, String methodName, { Map? params, }) async { + final combinedName = [serviceName, methodName].nonNulls.join('.'); final json = await _clientPeer.sendRequest( - '$serviceName.$methodName', - params ?? {}, + combinedName, + params, ) as Map; + return _dtdResponseFromJson(json); + } + DTDResponse _dtdResponseFromJson(Map json) { final type = json['type'] as String?; if (type == null) { throw DartToolingDaemonConnectionException.callResponseMissingType(json); diff --git a/pkg/dtd/lib/src/file_system/types.dart b/pkg/dtd/lib/src/response_types/_file_system.dart similarity index 97% rename from pkg/dtd/lib/src/file_system/types.dart rename to pkg/dtd/lib/src/response_types/_file_system.dart index c44b2d8c44a..7c8808004dd 100644 --- a/pkg/dtd/lib/src/file_system/types.dart +++ b/pkg/dtd/lib/src/response_types/_file_system.dart @@ -2,9 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; - -import '../../dtd.dart'; +part of 'response_types.dart'; /// A list or [uris] on the system where the Dart Tooling Daemon is running. class UriList { diff --git a/pkg/dtd/lib/src/response_types.dart b/pkg/dtd/lib/src/response_types/response_types.dart similarity index 55% rename from pkg/dtd/lib/src/response_types.dart rename to pkg/dtd/lib/src/response_types/response_types.dart index b0140865075..04da8170f94 100644 --- a/pkg/dtd/lib/src/response_types.dart +++ b/pkg/dtd/lib/src/response_types/response_types.dart @@ -2,9 +2,12 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:dart_service_protocol_shared/dart_service_protocol_shared.dart'; import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; -import 'dart_tooling_daemon.dart'; +import '../dart_tooling_daemon.dart'; + +part '_file_system.dart'; /// A DTD response that indicates success. class Success extends _SuccessResponse { @@ -107,3 +110,80 @@ abstract class _SuccessResponse { @override String toString() => '[$type value: $value]'; } + +/// A DTD response that contains information about all the registered services +/// available on the Dart Tooling Daemon, including services provided by DTD +/// itself as well as services registered by DTD clients. +class RegisteredServicesResponse { + const RegisteredServicesResponse({ + required this.dtdServices, + required this.clientServices, + }); + + factory RegisteredServicesResponse.fromDTDResponse(DTDResponse response) { + if (response.result[_kType] != type) { + throw json_rpc.RpcException.invalidParams( + 'Expected $_kType param to be $type, got: ${response.result[_kType]}', + ); + } + return RegisteredServicesResponse._fromDTDResponse(response); + } + + RegisteredServicesResponse._fromDTDResponse(DTDResponse response) + : dtdServices = List.from( + (response.result[_kDtdServices] as List).cast(), + ), + clientServices = List>.from( + (response.result[_kClientServices] as List) + .cast>(), + ).map(ClientServiceInfo.fromJson).toList(); + + /// The key for the type parameter. + static const String _kType = 'type'; + + /// The key for the DTD services parameter. + static const String _kDtdServices = 'dtdServices'; + + /// The key for the client services parameter. + static const String _kClientServices = 'clientServices'; + + /// A list of DTD services. + final List dtdServices; + + /// A list of DTD client services. + final List clientServices; + + static String get type => 'RegisteredServicesResponse'; + + Map toJson() => { + _kType: type, + _kDtdServices: dtdServices, + _kClientServices: + clientServices.map((service) => service.toJson()).toList(), + }; + + @override + String toString() => '[' + '$type ' + 'dtdServices: ${dtdServices.toString()}, ' + 'clientServices: ' + '${clientServices.map((service) => service.display).toList().toString()}' + ']'; +} + +extension on ClientServiceInfo { + String get display { + final sb = StringBuffer() + ..write('$name (') + ..write( + methods.values.map((method) { + final capabilities = method.capabilities != null + ? ' ${method.capabilities.toString()}' + : ''; + return '${method.name}$capabilities'; + }).join(', '), + ) + ..write(')'); + return sb.toString(); + } +} diff --git a/pkg/dtd/lib/src/connected_app_service.dart b/pkg/dtd/lib/src/services/connected_app_service.dart similarity index 97% rename from pkg/dtd/lib/src/connected_app_service.dart rename to pkg/dtd/lib/src/services/connected_app_service.dart index 2a95f5600ad..6cbddff3019 100644 --- a/pkg/dtd/lib/src/connected_app_service.dart +++ b/pkg/dtd/lib/src/services/connected_app_service.dart @@ -4,10 +4,10 @@ import 'package:json_rpc_2/json_rpc_2.dart'; -import 'constants.dart'; -import 'dart_tooling_daemon.dart'; -import 'response_types.dart'; -import 'rpc_error_codes.dart'; +import '../constants.dart'; +import '../dart_tooling_daemon.dart'; +import '../response_types/response_types.dart'; +import '../rpc_error_codes.dart'; /// Extension methods on the [DartToolingDaemon] that call the ConnectedApps /// service. diff --git a/pkg/dtd/lib/src/file_system/file_system_service.dart b/pkg/dtd/lib/src/services/file_system_service.dart similarity index 99% rename from pkg/dtd/lib/src/file_system/file_system_service.dart rename to pkg/dtd/lib/src/services/file_system_service.dart index dd104e29fda..152b924f443 100644 --- a/pkg/dtd/lib/src/file_system/file_system_service.dart +++ b/pkg/dtd/lib/src/services/file_system_service.dart @@ -9,8 +9,8 @@ import 'package:json_rpc_2/json_rpc_2.dart' show RpcException; import '../constants.dart'; import '../dart_tooling_daemon.dart'; +import '../response_types/response_types.dart'; import '../rpc_error_codes.dart' show RpcErrorCodes; -import 'types.dart'; extension FileSystemService on DartToolingDaemon { /// Reads the file at [uri] from disk in the environment where the Dart diff --git a/pkg/dtd/lib/src/unified_analytics_service.dart b/pkg/dtd/lib/src/services/unified_analytics_service.dart similarity index 97% rename from pkg/dtd/lib/src/unified_analytics_service.dart rename to pkg/dtd/lib/src/services/unified_analytics_service.dart index 78a89ccc307..f52377fac50 100644 --- a/pkg/dtd/lib/src/unified_analytics_service.dart +++ b/pkg/dtd/lib/src/services/unified_analytics_service.dart @@ -4,9 +4,9 @@ import 'package:unified_analytics/unified_analytics.dart'; -import 'constants.dart'; -import 'dart_tooling_daemon.dart'; -import 'response_types.dart'; +import '../constants.dart'; +import '../dart_tooling_daemon.dart'; +import '../response_types/response_types.dart'; /// Extension methods on the [DartToolingDaemon] that call the UnifiedAnalytics /// service. diff --git a/pkg/dtd/pubspec.yaml b/pkg/dtd/pubspec.yaml index c12cb3eb0a8..5b3512a5695 100644 --- a/pkg/dtd/pubspec.yaml +++ b/pkg/dtd/pubspec.yaml @@ -1,5 +1,5 @@ name: dtd -version: 2.6.0 +version: 3.0.0 description: A package for communicating with the Dart Tooling Daemon. repository: https://github.com/dart-lang/sdk/tree/main/pkg/dtd @@ -9,6 +9,7 @@ environment: resolution: workspace dependencies: + dart_service_protocol_shared: ^0.0.3 json_rpc_2: '>=3.0.2 <5.0.0' stream_channel: ^2.1.2 unified_analytics: '>=7.0.0 <9.0.0' diff --git a/pkg/dtd/test/dart_tooling_daemon_test.dart b/pkg/dtd/test/dart_tooling_daemon_test.dart index 56d2b07d819..c957497a533 100644 --- a/pkg/dtd/test/dart_tooling_daemon_test.dart +++ b/pkg/dtd/test/dart_tooling_daemon_test.dart @@ -98,6 +98,106 @@ void main() { {'type': 'test', 'data': data, 'params': params}, ); }); + + test('getRegisteredServices', () async { + await clientA.registerService( + 'TestService', + 'foo', + (Parameters params) async { + return { + 'type': 'test', + 'data': data, + 'params': params.asMap, + }; + }, + ); + await clientA.registerService( + 'TestService', + 'bar', + (Parameters params) async { + return { + 'type': 'test', + 'data': data, + 'params': params.asMap, + }; + }, + capabilities: { + 'language': 'french', + }, + ); + await clientB.registerService( + 'OtherService', + 'foo', + (Parameters params) async { + return { + 'type': 'other', + 'data': data, + 'params': params.asMap, + }; + }, + capabilities: { + 'skills': 'baking', + }, + ); + + final response = await clientA.getRegisteredServices(); + expect( + response.toJson(), + { + 'type': 'RegisteredServicesResponse', + 'dtdServices': [ + 'streamListen', + 'streamCancel', + 'postEvent', + 'registerService', + 'getRegisteredServices', + 'ConnectedApp.registerVmService', + 'ConnectedApp.unregisterVmService', + 'ConnectedApp.getVmServiceUris', + 'FileSystem.readFileAsString', + 'FileSystem.writeFileAsString', + 'FileSystem.listDirectoryContents', + 'FileSystem.setIDEWorkspaceRoots', + 'FileSystem.getIDEWorkspaceRoots', + 'FileSystem.getProjectRoots', + 'UnifiedAnalytics.getConsentMessage', + 'UnifiedAnalytics.shouldShowMessage', + 'UnifiedAnalytics.clientShowedMessage', + 'UnifiedAnalytics.telemetryEnabled', + 'UnifiedAnalytics.setTelemetry', + 'UnifiedAnalytics.send', + 'UnifiedAnalytics.listFakeAnalyticsSentEvents', + ], + 'clientServices': [ + { + 'name': 'TestService', + 'methods': [ + { + 'name': 'foo', + }, + { + 'name': 'bar', + 'capabilities': { + 'language': 'french', + }, + } + ], + }, + { + 'name': 'OtherService', + 'methods': [ + { + 'name': 'foo', + 'capabilities': { + 'skills': 'baking', + }, + } + ], + }, + ], + }, + ); + }); }); }); @@ -112,12 +212,6 @@ void main() { 'timestamp': 1, }, }; - const exampleCallToReceive = { - 'jsonrpc': '2.0', - 'method': 'foo.bar', - 'id': 0, - 'params': {}, - }; final clientToServer = StreamController(); final serverToClient = StreamController(); @@ -129,12 +223,57 @@ void main() { final clientReceivedEvent = await client.onEvent('testStream').first; expect(clientReceivedEvent.data['foo'], 'bar'); - // Send a request and ensure it comes on the stream. - // Discard "Connection closed with pending 'foo.bar'"" error because the - // test doesn't respond to it. + // Send requests and ensure they comes over the stream. + // Discard "Connection closed with pending 'foo.bar'" errors because the + // test doesn't respond to these. + + // Call service with no parameters. unawaited( client.call('foo', 'bar').onError((_, __) => DTDResponse('', '', {})), ); - expect(jsonDecode(await clientToServer.stream.first), exampleCallToReceive); + // Call service with parameters. + unawaited( + client.call('foo', 'bar', params: {'test': 'test'}).onError( + (_, __) => DTDResponse('', '', {}), + ), + ); + // Call method with null service. + unawaited( + client.call(null, 'bar').onError((_, __) => DTDResponse('', '', {})), + ); + + const expectedRequests = 3; + final requestsReceived = []; + final allRequestsReceived = Completer(); + + StreamSubscription? sub; + sub = clientToServer.stream.asBroadcastStream().listen((request) { + requestsReceived.add(request); + if (requestsReceived.length == expectedRequests) { + sub!.cancel(); + allRequestsReceived.complete(); + } + }); + addTearDown(() => sub?.cancel()); + await allRequestsReceived.future; + + expect( + jsonDecode(requestsReceived[0]), + {'jsonrpc': '2.0', 'method': 'foo.bar', 'id': 0}, + ); + expect( + jsonDecode(requestsReceived[1]), + { + 'jsonrpc': '2.0', + 'method': 'foo.bar', + 'id': 1, + 'params': {'test': 'test'}, + }, + ); + + expect( + jsonDecode(requestsReceived[2]), + {'jsonrpc': '2.0', 'method': 'bar', 'id': 2}, + ); }); } diff --git a/pkg/dtd/test/response_types_test.dart b/pkg/dtd/test/response_types_test.dart new file mode 100644 index 00000000000..dd61b7c7286 --- /dev/null +++ b/pkg/dtd/test/response_types_test.dart @@ -0,0 +1,85 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:dart_service_protocol_shared/dart_service_protocol_shared.dart'; +import 'package:dtd/dtd.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:test/test.dart'; + +void main() { + // TODO(kenz): add test coverage for other response types. + group('RegisteredServicesResponse', () { + test('fromDTDResponse and toJson', () { + final response = DTDResponse('id', 'method', { + 'type': 'RegisteredServicesResponse', + 'dtdServices': ['service1', 'service2'], + 'clientServices': [ + ClientServiceInfo('client1', { + 'method1': ClientServiceMethodInfo('method1', {'cap1': true}), + }).toJson(), + ], + }); + final servicesResponse = + RegisteredServicesResponse.fromDTDResponse(response); + expect( + RegisteredServicesResponse.type, + equals('RegisteredServicesResponse'), + ); + expect(servicesResponse.dtdServices, equals(['service1', 'service2'])); + expect(servicesResponse.clientServices.length, equals(1)); + expect(servicesResponse.clientServices[0].name, equals('client1')); + + expect( + servicesResponse.toJson(), + equals( + { + 'type': 'RegisteredServicesResponse', + 'dtdServices': ['service1', 'service2'], + 'clientServices': [ + { + 'name': 'client1', + 'methods': [ + { + 'name': 'method1', + 'capabilities': {'cap1': true}, + }, + ], + }, + ], + }, + ), + ); + }); + + test('fromDTDResponse throws on invalid type', () { + final response = DTDResponse('id', 'method', { + 'type': 'InvalidType', + 'dtdServices': ['service1'], + 'clientServices': [], + }); + expect( + () => RegisteredServicesResponse.fromDTDResponse(response), + throwsA(isA()), + ); + }); + + test('toString formats correctly', () { + final response = RegisteredServicesResponse( + dtdServices: ['service1'], + clientServices: [ + ClientServiceInfo('client1', { + 'method1': ClientServiceMethodInfo('method1', {'cap1': true}), + }), + ], + ); + expect( + response.toString(), + equals( + '[RegisteredServicesResponse dtdServices: [service1], ' + 'clientServices: [client1 (method1 {cap1: true})]]', + ), + ); + }); + }); +} diff --git a/pkg/dtd/test/unified_analytics_service_test.dart b/pkg/dtd/test/unified_analytics_service_test.dart index f7dc4e75544..6eb7625f300 100644 --- a/pkg/dtd/test/unified_analytics_service_test.dart +++ b/pkg/dtd/test/unified_analytics_service_test.dart @@ -3,7 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'package:dtd/dtd.dart'; -import 'package:dtd/src/unified_analytics_service.dart'; +import 'package:dtd/src/services/unified_analytics_service.dart'; import 'package:test/test.dart'; import 'package:unified_analytics/unified_analytics.dart'; diff --git a/pkg/dtd_impl/README.md b/pkg/dtd_impl/README.md index c040040ec9c..4043f9ea1e0 100644 --- a/pkg/dtd_impl/README.md +++ b/pkg/dtd_impl/README.md @@ -21,10 +21,21 @@ To run the tooling daemon compiled with the Dart SDK: 2. run `dart tooling-daemon` > :info The dart binary should be the one you just built in step 1. -### Testing changes +### Testing changes locally To quickly test changes to the tooling daemon, start it by running: ```bash dart run bin/dtd.dart ``` + +## Running tests + +To run the tests under the `test/` directory, run `dart test test/`. + +However, if you are testing changes that span `pkg/dtd` and `pkg/dtd_impl`, +you'll need to build the Dart SDK, and then use the built Dart executable to run +the test. + +1. Build the Dart SDK: `./tools/build.py create_platform_sdk` +2. Run the test: `xcodebuild/ReleaseARM64/dart-sdk/bin/dart test test/` diff --git a/pkg/dtd_impl/lib/src/dtd_client.dart b/pkg/dtd_impl/lib/src/dtd_client.dart index 2d415d7d2da..7f07cd54789 100644 --- a/pkg/dtd_impl/lib/src/dtd_client.dart +++ b/pkg/dtd_impl/lib/src/dtd_client.dart @@ -9,7 +9,7 @@ import 'package:sse/server/sse_handler.dart'; import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; import 'package:stream_channel/stream_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -import 'package:dtd/dtd.dart' show RpcErrorCodes; +import 'package:dtd/dtd.dart' show RpcErrorCodes, RegisteredServicesResponse; import 'constants.dart'; import 'dart_tooling_daemon.dart'; @@ -67,7 +67,7 @@ class DTDClient extends Client { return; } - return await _clientPeer.sendRequest(method, parameters.asMap); + return await _clientPeer.sendRequest(method, parameters.value); } @override @@ -82,12 +82,22 @@ class DTDClient extends Client { (_) => dtd.streamManager.onClientDisconnect(this), ); + /// The set of RPC methods registered by DTD itself. + /// + /// This will be a combination of first party DTD methods and methods + /// registered by internal services. + final _dtdRpcMethods = {}; + /// Registers handlers for the Dart Tooling Daemon JSON RPC method endpoints. void _registerJsonRpcMethods() { - _clientPeer.registerMethod('streamListen', _streamListen); - _clientPeer.registerMethod('streamCancel', _streamCancel); - _clientPeer.registerMethod('postEvent', _postEvent); - _clientPeer.registerMethod('registerService', _registerService); + _registerDtdMethod('streamListen', _streamListen); + _registerDtdMethod('streamCancel', _streamCancel); + _registerDtdMethod('postEvent', _postEvent); + _registerDtdMethod('registerService', _registerService); + _registerDtdMethod( + 'getRegisteredServices', + _getRegisteredServices, + ); // Handle service extension invocations. _clientPeer.registerFallback(_fallback); @@ -125,14 +135,36 @@ class DTDClient extends Client { DTDStreamManager.servicesStreamId, DTDStreamManager.serviceRegisteredId, _buildServiceRegisteredData( - service.name, - method.name, - method.capabilities, + service: service.name, + method: method.name, + capabilities: method.capabilities, ), ); } } } + + for (final method in _dtdRpcMethods) { + // If the DTD service method has the form 'service.method', split up the + // two values. Otherwise, leave the service null and use the entire name + // as the method. + String? serviceName; + String methodName; + final parts = method.split('.'); + if (parts.length == 2) { + serviceName = parts[0]; + } + methodName = parts.last; + + _streamNotifyHelper( + DTDStreamManager.servicesStreamId, + DTDStreamManager.serviceRegisteredId, + _buildServiceRegisteredData( + service: serviceName, + method: methodName, + ), + ); + } } return RPCResponses.success; @@ -259,11 +291,31 @@ class DTDClient extends Client { dtd.streamManager.postEventHelper( DTDStreamManager.servicesStreamId, DTDStreamManager.serviceRegisteredId, - _buildServiceRegisteredData(serviceName, methodName, capabilities), + _buildServiceRegisteredData( + service: serviceName, + method: methodName, + capabilities: capabilities, + ), ); return RPCResponses.success; } + /// Returns a structured response with all the currently registered services + /// available on this DTD instance. + Map _getRegisteredServices() { + final clientServices = + dtd.clientManager.clients.map((client) => client.services); + final combinedClientServices = { + // This will not create collisions because [_registerService] ensures + // the uniqueness of service methods across clients. + for (final servicesForClient in clientServices) ...servicesForClient, + }; + return RegisteredServicesResponse( + dtdServices: _dtdRpcMethods.toList(), + clientServices: combinedClientServices.values.toList(), + ).toJson(); + } + /// Cleans up when this client is disconnecting, before it is removed from the /// client manager. void onClientDisconnect() { @@ -279,13 +331,18 @@ class DTDClient extends Client { } } - Map _buildServiceRegisteredData( - String service, - String method, + /// Builds a structured object describing a service method that is being + /// registered on DTD. + /// + /// [service] may be null if this service method is a first party service + /// method registered by DTD or by an internal service. + Map _buildServiceRegisteredData({ + required String? service, + required String method, Map? capabilities, - ) { + }) { return { - 'service': service, + if (service != null) 'service': service, 'method': method, if (capabilities != null) 'capabilities': capabilities, }; @@ -356,6 +413,13 @@ class DTDClient extends Client { void Function(json_rpc.Parameters parameters) callback, ) { final combinedName = '$service.$method'; - _clientPeer.registerMethod(combinedName, callback); + _registerDtdMethod(combinedName, callback); + } + + /// A helper method for registering a service method on DTD and adding the + /// service method to the [_dtdRpcMethods] set. + void _registerDtdMethod(String method, Function callback) { + _dtdRpcMethods.add(method); + _clientPeer.registerMethod(method, callback); } } diff --git a/pkg/dtd_impl/test/dtd_test.dart b/pkg/dtd_impl/test/dtd_test.dart index a1cec582c73..bf959afdc77 100644 --- a/pkg/dtd_impl/test/dtd_test.dart +++ b/pkg/dtd_impl/test/dtd_test.dart @@ -457,7 +457,9 @@ void main() { ); // Expect we had a service registered event. - final event = await serviceStream.stream.first; + final event = await serviceStream.stream.firstWhere( + (event) => (event['eventData'] as Map?)?['service'] == 'foo1', + ); expect(event['streamId'], DTDStreamManager.servicesStreamId); expect(event['eventKind'], DTDStreamManager.serviceRegisteredId); expect( @@ -535,9 +537,12 @@ void main() { // Expect we had a service unregistered event (after the registered // event). - final event = await serviceStream.stream.skip(1).first; + final event = await serviceStream.stream.skip(1).firstWhere((event) { + return event['eventKind'] == + DTDStreamManager.serviceUnregisteredId && + (event['eventData'] as Map?)?['service'] == 'foo1'; + }); expect(event['streamId'], DTDStreamManager.servicesStreamId); - expect(event['eventKind'], DTDStreamManager.serviceUnregisteredId); expect( event['eventData'], {