| // Copyright (c) 2024, 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 'dart:async'; | |
| import 'package:analysis_server/lsp_protocol/protocol.dart'; | |
| import 'package:analysis_server/src/lsp/constants.dart'; | |
| import 'package:analyzer/src/test_utilities/test_code_format.dart'; | |
| import 'package:dtd/dtd.dart'; | |
| import 'package:json_rpc_2/json_rpc_2.dart'; | |
| import 'package:test/test.dart'; | |
| import '../../integration_test/support/dart_tooling_daemon.dart'; | |
| import '../../integration_test/support/web_sockets.dart'; | |
| import '../lsp/request_helpers_mixin.dart'; | |
| import '../tool/lsp_spec/matchers.dart'; | |
| import '../utils/lsp_protocol_extensions.dart'; | |
| import '../utils/test_code_extensions.dart'; | |
| /// The name of the DTD service that LSP methods are registered against. | |
| const lspServiceName = 'Lsp'; | |
| /// The name of the DTD stream that events/notifications will be posted to. | |
| const lspStreamName = 'Lsp'; | |
| /// A wrapper around [DartToolingDaemon] that allows using the | |
| /// [LspRequestHelpersMixin] methods to send requests over DTD. | |
| class DtdHelper with LspRequestHelpersMixin { | |
| final DartToolingDaemon connection; | |
| new(this.connection); | |
| @override | |
| Future<T> expectSuccessfulResponseTo<T, R>( | |
| RequestMessage request, | |
| T Function(R) fromJson, | |
| ) async { | |
| var response = await sendRequestToServer(request); | |
| var error = response.error; | |
| if (error != null) { | |
| throw error; | |
| } else { | |
| // response.result should only be null when error != null if T allows null. | |
| return response.result == null | |
| ? null as T | |
| : fromJson(response.result as R); | |
| } | |
| } | |
| @override | |
| Future<ResponseMessage> sendRequestToServer(RequestMessage request) async { | |
| var response = await connection.call( | |
| lspServiceName, | |
| request.method.toString(), | |
| params: request.params as Map<String, Object?>, | |
| ); | |
| return ResponseMessage( | |
| jsonrpc: jsonRpcVersion, | |
| // The LSP result is in the 'result' field, and DTD provides the whole | |
| // result in `response.result`. | |
| result: response.result['result'], | |
| ); | |
| } | |
| } | |
| /// Shared DTD tests that are used by both LSP and legacy server integration | |
| /// tests. | |
| mixin SharedDtdTests | |
| on LspRequestHelpersMixin, LspEditHelpersMixin, LspVerifyEditHelpersMixin { | |
| /// The name of the DTD service that methods will be registered under. | |
| /// The `dart tooling-daemon` process we've spawned to connect to. | |
| late DtdProcess dtdProcess; | |
| /// The URI we can connect to [dtdProcess] using. | |
| late Uri dtdUri; | |
| /// A helper wrapping the tests connection to DTD which can be used to | |
| /// interact with DTD and send LSP requests using the shared request helper | |
| /// methods. | |
| /// | |
| /// Calling methods like `getHover` on this instance will send the request | |
| /// DTD whereas calling [getHover] would send the request directly to the | |
| /// server (via the real or simulated stdin/stdout streams). | |
| late DtdHelper dtd; | |
| /// A list of service/methods that the test client has seen registered (and | |
| /// not yet unregistered) over the DTD connection. | |
| /// | |
| /// 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'); | |
| // TODO(dantup): Support this for LSP-over-Legacy shared tests. | |
| set failTestOnErrorDiagnostic(bool value); | |
| /// Overridden by test subclasses to provide the path of a file for testing. | |
| String get testFile; | |
| /// Overridden by test subclasses to provide a URI for [testFile]. | |
| Uri get testFileUri; | |
| /// Overridden by test subclasses to create a new file. | |
| void createFile(String path, String content); | |
| /// Sets up a file with [code] and expects/returns the [Command] code action | |
| /// with [title]. | |
| Future<Command> expectCommandCodeAction(TestCode code, String title) async { | |
| createFile(testFile, code.code); | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| // Ensure the codeAction service is available. | |
| expectMethod(Method.textDocument_codeAction); | |
| // Fetch code actions at the marked location. | |
| var actions = await dtd.getCodeActions( | |
| testFileUri, | |
| range: code.range.range, | |
| ); | |
| // Ensure all returned actions are Commands not CodeActionLiterals, because | |
| // our simple DTD client capabilities did not advertise CodeActionLiteral | |
| // support (because we cannot accept inline edits at a non-editor caller). | |
| var commands = actions.map((action) => action.asCommand).toList(); | |
| // Find the one with the matching title. | |
| expect(commands.map((command) => command.title), contains(title)); | |
| return commands.singleWhere((command) => command.title == title); | |
| } | |
| Future<void> expectCommandCodeActionEdits( | |
| TestCode code, | |
| String title, | |
| String expected, | |
| ) async { | |
| var command = await expectCommandCodeAction(code, title); | |
| // Invoke the command over DTD, expecting edits to be sent back to us | |
| // (not over DTD). | |
| var verifier = await executeForEdits(() => dtd.executeCommand(command)); | |
| verifier.verifyFiles(expected); | |
| } | |
| void expectMethod(Method method, {bool available = true}) { | |
| if (available) { | |
| expect(availableMethods, contains((lspServiceName, method))); | |
| } else { | |
| expect(availableMethods, isNot(contains((lspServiceName, method)))); | |
| } | |
| } | |
| /// Overridden by test subclasses to initialize the server. | |
| Future<void> initializeServer(); | |
| /// Sends a request to connect to DTD and captures all service methods that | |
| /// are registered/unregistered into [availableMethods] until the `ready` | |
| /// event is posted. | |
| /// | |
| /// [registerExperimentalHandlers] controls whether experimental handlers are | |
| /// registered. | |
| Future<void> sendConnectToDtdRequest({ | |
| Uri? uri, | |
| bool? registerExperimentalHandlers, | |
| }) async { | |
| // Set up a completer to listen for the 'initialized' event on the Lsp | |
| // stream so that we know when the services have finished registering. | |
| var lspInitializedCompleter = Completer<void>(); | |
| var lspEventSub = dtd.connection.onEvent(lspStreamName).listen((e) { | |
| switch (e.kind) { | |
| case 'initialized': | |
| lspInitializedCompleter.complete(); | |
| } | |
| }); | |
| await dtd.connection.streamListen(lspStreamName); | |
| try { | |
| await connectToDtd( | |
| uri ?? dtdUri, | |
| registerExperimentalHandlers: registerExperimentalHandlers, | |
| ); | |
| // Wait for the event. | |
| await lspInitializedCompleter.future; | |
| } finally { | |
| // Unsubscribe. | |
| await lspEventSub.cancel(); | |
| await dtd.connection.streamCancel(lspStreamName); | |
| } | |
| } | |
| Future<void> setUpDtd() async { | |
| // Start the DTD process like an editor would. | |
| dtdProcess = await DtdProcess.start(); | |
| // Create our own (logged) connection to it | |
| dtdUri = await dtdProcess.dtdUri; | |
| dtd = DtdHelper( | |
| DartToolingDaemon.fromStreamChannel( | |
| await createLoggedWebSocketChannel(dtdUri), | |
| ), | |
| ); | |
| // Capture service method registrations/unregistrations. | |
| dtd.connection.onEvent('Service').listen((e) { | |
| switch (e.kind) { | |
| case 'ServiceRegistered': | |
| availableMethods.add(( | |
| e.data['service'] as String?, | |
| Method(e.data['method'] as String), | |
| )); | |
| case 'ServiceUnregistered': | |
| availableMethods.remove(( | |
| e.data['service'] as String?, | |
| Method(e.data['method'] as String), | |
| )); | |
| } | |
| }); | |
| // Start listening to the stream. | |
| await dtd.connection.streamListen('Service'); | |
| } | |
| /// Overridden by test subclasses to instruct the server to shut down (which | |
| /// should result in DTD services being unregistered). | |
| Future<void> shutdownServer(); | |
| Future<void> tearDownDtd() async { | |
| await dtdProcess.dispose(); | |
| } | |
| Future<void> test_connectToDtd_failure_alreadyRegistered() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| await expectLater( | |
| sendConnectToDtdRequest(), | |
| throwsA( | |
| isResponseError( | |
| ServerErrorCodes.stateError, | |
| message: 'Server is already connected to DTD', | |
| ), | |
| ), | |
| ); | |
| } | |
| Future<void> test_connectToDtd_failure_invalidUri() async { | |
| await initializeServer(); | |
| await expectLater( | |
| sendConnectToDtdRequest(uri: invalidUri), | |
| throwsA( | |
| isResponseError( | |
| ErrorCodes.RequestFailed, | |
| message: startsWith( | |
| 'Failed to connect to DTD at ws://invalid:345/invalid\nWebSocketChannelException:', | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| Future<void> test_connectToDtd_success_afterFailureToConnect() async { | |
| await initializeServer(); | |
| // Perform a failed connection. | |
| await expectLater( | |
| sendConnectToDtdRequest(uri: invalidUri), | |
| throwsA( | |
| isResponseError( | |
| ErrorCodes.RequestFailed, | |
| message: startsWith( | |
| 'Failed to connect to DTD at ws://invalid:345/invalid\nWebSocketChannelException:', | |
| ), | |
| ), | |
| ), | |
| ); | |
| // Expect complete with no error. | |
| await sendConnectToDtdRequest(); | |
| } | |
| Future<void> test_connectToDtd_success_afterPreviousDtdShutdown() async { | |
| await initializeServer(); | |
| // Connect to the initial DTD. | |
| await sendConnectToDtdRequest(); | |
| // Shut down the initial DTD process as if it crashed. Server should notice | |
| // this and now allow us to connect a new one. | |
| await tearDownDtd(); | |
| // Start up a new DTD. | |
| await setUpDtd(); | |
| // Connect to the new DTD and ensure completion with no error. | |
| await sendConnectToDtdRequest(); | |
| } | |
| Future<void> | |
| test_connectToDtd_success_doesNotRegister_connectToDtdMethod() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| expectMethod(CustomMethods.connectToDtd, available: false); | |
| } | |
| Future<void> | |
| test_connectToDtd_success_doesNotRegister_experimentalMethods() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| expectMethod(CustomMethods.experimentalEcho, available: false); | |
| } | |
| Future<void> | |
| test_connectToDtd_success_doesNotRegister_fileStateMethods() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| // Methods that modify state that is owned by the server shouldn't be | |
| // registered. | |
| expectMethod(Method.textDocument_didOpen, available: false); | |
| expectMethod(Method.textDocument_didClose, available: false); | |
| expectMethod(Method.textDocument_didChange, available: false); | |
| } | |
| Future<void> | |
| test_connectToDtd_success_doesNotRegister_initializationMethods() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| // No initialization request/notifications should be available. | |
| expectMethod(Method.initialize, available: false); | |
| expectMethod(Method.initialized, available: false); | |
| } | |
| Future<void> test_connectToDtd_success_registers_experimentalMethods() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(registerExperimentalHandlers: true); | |
| expectMethod(CustomMethods.experimentalEcho); | |
| } | |
| Future<void> test_connectToDtd_success_registers_standardLspMethods() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| // Check some known methods that should be available. | |
| expectMethod(Method.textDocument_documentSymbol); | |
| expectMethod(Method.textDocument_hover); | |
| expectMethod(Method.textDocument_formatting); | |
| expectMethod(Method.textDocument_implementation); | |
| expectMethod(Method.textDocument_documentColor); | |
| } | |
| Future<void> test_service_codeAction_assist() async { | |
| setApplyEditSupport(); | |
| var code = TestCode.parseNormalized(''' | |
| var a = [!''!]; | |
| '''); | |
| var title = 'Convert to double quoted string'; | |
| var expected = r''' | |
| >>>>>>>>>> lib/main.dart | |
| var a = ""; | |
| '''; | |
| await expectCommandCodeActionEdits(code, title, expected); | |
| } | |
| Future<void> test_service_codeAction_fix() async { | |
| failTestOnErrorDiagnostic = false; | |
| setApplyEditSupport(); | |
| var code = TestCode.parseNormalized(''' | |
| Future<void> [!f!]() {} | |
| '''); | |
| var title = "Add 'async' modifier"; | |
| var expected = r''' | |
| >>>>>>>>>> lib/main.dart | |
| Future<void> f() async {} | |
| '''; | |
| await expectCommandCodeActionEdits(code, title, expected); | |
| } | |
| Future<void> test_service_codeAction_refactor() async { | |
| setApplyEditSupport(); | |
| var code = TestCode.parseNormalized(''' | |
| void f() { | |
| [!print('');!] | |
| } | |
| '''); | |
| var title = 'Extract Method'; | |
| var expected = r''' | |
| >>>>>>>>>> lib/main.dart | |
| void f() { | |
| newMethod(); | |
| } | |
| void newMethod() { | |
| print(''); | |
| } | |
| '''; | |
| await expectCommandCodeActionEdits(code, title, expected); | |
| } | |
| Future<void> test_service_codeAction_source() async { | |
| setApplyEditSupport(); | |
| var code = TestCode.parseNormalized(''' | |
| [!!]import 'dart:async'; | |
| import 'dart:io'; | |
| FutureOr<void>? a; | |
| '''); | |
| var title = 'Organize Imports'; | |
| var expected = r''' | |
| >>>>>>>>>> lib/main.dart | |
| import 'dart:async'; | |
| FutureOr<void>? a; | |
| '''; | |
| await expectCommandCodeActionEdits(code, title, expected); | |
| } | |
| Future<void> test_service_failure_hover() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| // Attempt an unsuccessful request to textDocument/hover over DTD. | |
| expectMethod(Method.textDocument_hover); | |
| var nonExistantFilePath = testFile.replaceAll('.dart', '.notExist.dart'); | |
| var call = dtd.getHover( | |
| Uri.file(nonExistantFilePath), | |
| Position(line: 1, character: 1), | |
| ); | |
| // Expect a proper RPC Exception with the standard LSP error code/message. | |
| var expectedException = isA<RpcException>() | |
| .having( | |
| (e) => e.code, | |
| 'code', | |
| ServerErrorCodes.invalidFilePath.toJson(), | |
| ) | |
| .having((e) => e.message, 'message', 'File does not exist') | |
| .having((e) => e.data, 'data', nonExistantFilePath); | |
| await expectLater(call, throwsA(expectedException)); | |
| } | |
| Future<void> test_service_success_echo() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(registerExperimentalHandlers: true); | |
| var response = await dtd.connection.call( | |
| lspServiceName, | |
| CustomMethods.experimentalEcho.toString(), | |
| params: {'a': 'b'}, | |
| ); | |
| var result = response.result['result'] as Map<String, Object?>?; | |
| expect(result, equals({'a': 'b'})); | |
| } | |
| Future<void> | |
| 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 <String, Object?>{}, | |
| ); | |
| var result = response.result['result'] as Map<String, Object?>?; | |
| expect(response.type, 'Null'); | |
| expect(result, isNull); | |
| } | |
| Future<void> test_service_success_echo_nullResponse_with_null_params() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(registerExperimentalHandlers: true); | |
| var response = await dtd.connection.call( | |
| lspServiceName, | |
| CustomMethods.experimentalEcho.toString(), | |
| ); | |
| var result = response.result['result'] as Map<String, Object?>?; | |
| expect(response.type, 'Null'); | |
| expect(result, isNull); | |
| } | |
| Future<void> test_service_success_hover() async { | |
| var code = TestCode.parseNormalized(''' | |
| /// A function. | |
| void [!myFun^ction!]() {} | |
| '''); | |
| createFile(testFile, code.code); | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| // Attempt a successful request to textDocument/hover over DTD. | |
| expectMethod(Method.textDocument_hover); | |
| var hoverResult = await dtd.getHover(testFileUri, code.position.position); | |
| // Verify the result. | |
| var hoverStringContent = hoverResult!.contents.map( | |
| (markup) => markup.value, | |
| (string) => string, | |
| ); | |
| expect(hoverResult.range, equals(code.range.range)); | |
| expect(hoverStringContent, contains('A function.')); | |
| } | |
| Future<void> test_service_unregisteredOnShutdown() async { | |
| await initializeServer(); | |
| await sendConnectToDtdRequest(); | |
| 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 | |
| // time DTD takes to forward those service registrations to us, so we also | |
| // need some delay. | |
| await shutdownServer(); | |
| // Wait for the services to be unregistered. | |
| while (lspMethods.isNotEmpty) { | |
| await pumpEventQueue(times: 5000); | |
| } | |
| expect(lspMethods, isEmpty); | |
| } | |
| } |