| // Copyright (c) 2019, 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. |
| |
| // @dart = 2.10 |
| |
| @TestOn('vm') |
| import 'dart:async'; |
| import 'dart:convert'; |
| |
| import 'package:async/async.dart'; |
| import 'package:mockito/mockito.dart'; |
| import 'package:pedantic/pedantic.dart'; |
| import 'package:test/test.dart'; |
| import 'package:vm_service/vm_service.dart'; |
| |
| void main() { |
| MockVmService serviceMock; |
| StreamController<Map<String, Object>> requestsController; |
| StreamController<Map<String, Object>> responsesController; |
| ServiceExtensionRegistry serviceRegistry; |
| |
| setUp(() { |
| serviceMock = MockVmService(); |
| requestsController = StreamController<Map<String, Object>>(); |
| responsesController = StreamController<Map<String, Object>>(); |
| serviceRegistry = ServiceExtensionRegistry(); |
| VmServerConnection(requestsController.stream, responsesController.sink, |
| serviceRegistry, serviceMock); |
| }); |
| |
| tearDown(() { |
| requestsController.close(); |
| responsesController.close(); |
| }); |
| |
| group('method delegation', () { |
| test('works for simple methods', () { |
| var request = rpcRequest("getVersion"); |
| var version = Version(major: 1, minor: 0); |
| when(serviceMock.getVersion()).thenAnswer((_) => Future.value(version)); |
| expect(responsesController.stream, emits(rpcResponse(version))); |
| requestsController.add(request); |
| }); |
| |
| test('works for methods with parameters', () { |
| var isolate = Isolate( |
| name: 'isolate', |
| exceptionPauseMode: ExceptionPauseMode.kNone, |
| id: '123', |
| number: '0', |
| startTime: 1, |
| runnable: true, |
| livePorts: 2, |
| isolateFlags: [], |
| pauseOnExit: false, |
| pauseEvent: Event( |
| kind: EventKind.kResume, |
| timestamp: 3, |
| ), |
| libraries: [], |
| breakpoints: [], |
| isSystemIsolate: false, |
| ); |
| var request = rpcRequest("getIsolate", params: {'isolateId': isolate.id}); |
| when(serviceMock.getIsolate(isolate.id)) |
| .thenAnswer((Invocation invocation) { |
| expect(invocation.positionalArguments, equals([isolate.id])); |
| return Future.value(isolate); |
| }); |
| expect(responsesController.stream, emits(rpcResponse(isolate))); |
| requestsController.add(request); |
| }); |
| |
| test('works for methods with list parameters', () { |
| var isolate = Isolate( |
| name: 'isolate', |
| exceptionPauseMode: ExceptionPauseMode.kNone, |
| id: '123', |
| number: '0', |
| startTime: 1, |
| runnable: true, |
| livePorts: 2, |
| isolateFlags: [], |
| pauseOnExit: false, |
| pauseEvent: Event( |
| kind: EventKind.kResume, |
| timestamp: 3, |
| ), |
| libraries: [], |
| breakpoints: [], |
| isSystemIsolate: false, |
| ); |
| var request = rpcRequest("setVMTimelineFlags", params: { |
| 'isolateId': isolate.id, |
| // Note: the dynamic list below is intentional in order to exercise the |
| // code under test. |
| 'recordedStreams': <dynamic>['GC', 'Dart', 'Embedder'], |
| }); |
| var response = Success(); |
| when(serviceMock.getIsolate(isolate.id)) |
| .thenAnswer((Invocation invocation) { |
| expect(invocation.namedArguments, |
| equals({Symbol('isolateId'): null, Symbol('args'): null})); |
| return Future.value(isolate); |
| }); |
| expect(responsesController.stream, emits(rpcResponse(response))); |
| requestsController.add(request); |
| }); |
| |
| group('custom service extensions', () { |
| test('with no params or isolateId', () { |
| var extension = 'ext.cool'; |
| var request = rpcRequest(extension, params: null); |
| var response = Response()..json = {"hello": "world"}; |
| when(serviceMock.callServiceExtension( |
| extension, |
| isolateId: argThat(isNull, named: 'isolateId'), |
| args: argThat(isNull, named: 'args'), |
| )).thenAnswer((Invocation invocation) { |
| expect(invocation.namedArguments, |
| equals({Symbol('isolateId'): null, Symbol('args'): null})); |
| return Future.value(response); |
| }); |
| expect(responsesController.stream, emits(rpcResponse(response))); |
| requestsController.add(request); |
| }); |
| |
| test('with isolateId and no other params', () { |
| var extension = 'ext.cool'; |
| var request = rpcRequest(extension, params: {'isolateId': '1'}); |
| var response = Response()..json = {"hello": "world"}; |
| when(serviceMock.callServiceExtension( |
| extension, |
| isolateId: argThat(equals('1'), named: 'isolateId'), |
| args: argThat(equals({}), named: 'args'), |
| )).thenAnswer((Invocation invocation) { |
| expect(invocation.namedArguments, |
| equals({Symbol('isolateId'): '1', Symbol('args'): {}})); |
| return Future.value(response); |
| }); |
| expect(responsesController.stream, emits(rpcResponse(response))); |
| requestsController.add(request); |
| }); |
| |
| test('with params and no isolateId', () { |
| var extension = 'ext.cool'; |
| var params = {'cool': 'option'}; |
| var request = rpcRequest(extension, params: params); |
| var response = Response()..json = {"hello": "world"}; |
| when(serviceMock.callServiceExtension( |
| extension, |
| isolateId: argThat(isNull, named: 'isolateId'), |
| args: argThat(equals(params), named: 'args'), |
| )).thenAnswer((Invocation invocation) { |
| expect(invocation.namedArguments, |
| equals({Symbol('isolateId'): null, Symbol('args'): params})); |
| return Future.value(response); |
| }); |
| expect(responsesController.stream, emits(rpcResponse(response))); |
| requestsController.add(request); |
| }); |
| |
| test('with params and isolateId', () { |
| var extension = 'ext.cool'; |
| var params = {'cool': 'option'}; |
| var request = |
| rpcRequest(extension, params: Map.of(params)..['isolateId'] = '1'); |
| var response = Response()..json = {"hello": "world"}; |
| when(serviceMock.callServiceExtension( |
| extension, |
| isolateId: argThat(equals("1"), named: 'isolateId'), |
| args: argThat(equals(params), named: 'args'), |
| )).thenAnswer((Invocation invocation) { |
| expect(invocation.namedArguments, |
| equals({Symbol('isolateId'): '1', Symbol('args'): params})); |
| return Future.value(response); |
| }); |
| expect(responsesController.stream, emits(rpcResponse(response))); |
| requestsController.add(request); |
| }); |
| }); |
| }); |
| |
| group('error handling', () { |
| test('special cases RPCError instances', () { |
| var request = rpcRequest("getVersion"); |
| var error = |
| RPCError('getVersion', 1234, 'custom message', {'custom': 'data'}); |
| when(serviceMock.getVersion()).thenAnswer((_) => Future.error(error)); |
| expect(responsesController.stream, emits(rpcErrorResponse(error))); |
| requestsController.add(request); |
| }); |
| |
| test('has a fallback for generic exceptions', () { |
| var request = rpcRequest("getVersion"); |
| var error = UnimplementedError(); |
| when(serviceMock.getVersion()).thenAnswer((_) => Future.error(error)); |
| expect( |
| responsesController.stream.map((response) => '$response'), |
| emits(startsWith( |
| '{jsonrpc: 2.0, id: 1, error: {code: -32603, message: getVersion: UnimplementedError'))); |
| requestsController.add(request); |
| }); |
| }); |
| |
| group('streams', () { |
| test('can be listened to and canceled', () async { |
| var streamId = 'Isolate'; |
| var responseQueue = StreamQueue(responsesController.stream); |
| StreamController<Event> eventController; |
| { |
| var request = |
| rpcRequest('streamListen', params: {'streamId': streamId}); |
| var response = Success(); |
| when(serviceMock.streamListen(streamId)) |
| .thenAnswer((_) => Future.value(response)); |
| requestsController.add(request); |
| await expectLater(responseQueue, emitsThrough(rpcResponse(response))); |
| |
| eventController = serviceMock.streamControllers[streamId]; |
| |
| var events = [ |
| Event( |
| kind: EventKind.kIsolateStart, |
| timestamp: 0, |
| ), |
| Event( |
| kind: EventKind.kIsolateExit, |
| timestamp: 1, |
| ) |
| ]; |
| events.forEach(eventController.add); |
| await expectLater( |
| responseQueue, |
| emitsInOrder( |
| events.map((event) => streamNotifyResponse(streamId, event)))); |
| } |
| { |
| var request = |
| rpcRequest('streamCancel', params: {'streamId': streamId}); |
| var response = Success(); |
| when(serviceMock.streamListen(streamId)) |
| .thenAnswer((_) => Future.value(response)); |
| requestsController.add(request); |
| await expectLater(responseQueue, emitsThrough(rpcResponse(response))); |
| |
| var nextEvent = Event( |
| kind: EventKind.kIsolateReload, |
| timestamp: 2, |
| ); |
| eventController.add(nextEvent); |
| expect(responseQueue, |
| neverEmits(streamNotifyResponse(streamId, nextEvent))); |
| |
| await pumpEventQueue(); |
| await eventController.close(); |
| await responsesController.close(); |
| } |
| }); |
| test("can't be listened to twice", () { |
| var streamId = 'Isolate'; |
| var responseQueue = StreamQueue(responsesController.stream); |
| { |
| var request = |
| rpcRequest('streamListen', params: {'streamId': streamId}); |
| var response = Success(); |
| when(serviceMock.streamListen(streamId)) |
| .thenAnswer((_) => Future.value(response)); |
| requestsController.add(request); |
| expect(responseQueue, emitsThrough(rpcResponse(response))); |
| } |
| { |
| var request = |
| rpcRequest('streamListen', params: {'streamId': streamId}); |
| var response = Success(); |
| when(serviceMock.streamListen(streamId)) |
| .thenAnswer((_) => Future.value(response)); |
| requestsController.add(request); |
| expect( |
| responseQueue, |
| emitsThrough(rpcErrorResponse( |
| RPCError('streamSubcribe', 103, 'Stream already subscribed', { |
| 'details': "The stream '$streamId' is already subscribed", |
| })))); |
| } |
| }); |
| |
| test("can't cancel a stream that isn't being listened to", () { |
| var streamId = 'Isolate'; |
| var responseQueue = StreamQueue(responsesController.stream); |
| |
| var request = rpcRequest('streamCancel', params: {'streamId': streamId}); |
| var response = Success(); |
| when(serviceMock.streamListen(streamId)) |
| .thenAnswer((_) => Future.value(response)); |
| requestsController.add(request); |
| expect( |
| responseQueue, |
| emitsThrough(rpcErrorResponse( |
| RPCError('streamCancel', 104, 'Stream not subscribed', { |
| 'details': "The stream '$streamId' is not subscribed", |
| })))); |
| }); |
| |
| group('Service', () { |
| final serviceStream = 'Service'; |
| |
| test('gives register and unregister events', () async { |
| var serviceId = 'ext.test.service'; |
| var serviceRegisteredEvent = streamNotifyResponse( |
| serviceStream, |
| Event( |
| kind: EventKind.kServiceRegistered, |
| timestamp: 0, |
| method: serviceId, |
| service: serviceId, |
| ), |
| ); |
| var serviceUnRegisteredEvent = streamNotifyResponse( |
| serviceStream, |
| Event( |
| kind: EventKind.kServiceUnregistered, |
| timestamp: 0, |
| method: serviceId, |
| service: serviceId, |
| ), |
| ); |
| |
| requestsController.add( |
| rpcRequest('streamListen', params: {'streamId': serviceStream})); |
| requestsController |
| .add(rpcRequest('registerService', params: {'service': serviceId})); |
| await expectLater( |
| responsesController.stream |
| .map((Map response) => stripEventTimestamp(response)), |
| emitsThrough(serviceRegisteredEvent)); |
| |
| // Connect another client to get the previous register events and the |
| // unregister event. |
| var requestsController2 = StreamController<Map<String, Object>>(); |
| var responsesController2 = StreamController<Map<String, Object>>(); |
| addTearDown(() { |
| requestsController2.close(); |
| responsesController2.close(); |
| }); |
| |
| VmServerConnection( |
| requestsController2.stream, |
| responsesController2.sink, |
| serviceRegistry, |
| VmService(Stream.empty(), (String _) => null), |
| ); |
| |
| expect( |
| responsesController2.stream |
| .map((Map response) => stripEventTimestamp(response)), |
| emitsThrough(emitsInOrder( |
| [serviceRegisteredEvent, serviceUnRegisteredEvent]))); |
| |
| // Should get the previously registered extension event, as well as |
| // the unregister event when the client disconnects. |
| requestsController2.add( |
| rpcRequest('streamListen', params: {'streamId': serviceStream})); |
| // Need to give the client a chance to subscribe. |
| await pumpEventQueue(); |
| unawaited(requestsController.close()); |
| // Give the old client a chance to shut down |
| await pumpEventQueue(); |
| |
| // Connect yet another client, it should get zero registration or |
| // unregistration events. |
| var requestsController3 = StreamController<Map<String, Object>>(); |
| var responsesController3 = StreamController<Map<String, Object>>(); |
| |
| VmServerConnection( |
| requestsController3.stream, |
| responsesController3.sink, |
| serviceRegistry, |
| VmService(Stream.empty(), (String _) => null), |
| ); |
| expect( |
| responsesController3.stream, |
| neverEmits( |
| anyOf(serviceRegisteredEvent, serviceUnRegisteredEvent))); |
| // Give it a chance to deliver events. |
| await pumpEventQueue(); |
| // Disconnect the client so the test can shut down. |
| unawaited(requestsController3.close()); |
| unawaited(responsesController3.close()); |
| }); |
| }); |
| }); |
| |
| group('registerService', () { |
| test('can delegate requests between clients', () async { |
| var serviceId = 'ext.test.service'; |
| var responseQueue = StreamQueue(responsesController.stream); |
| |
| var clientInputController = |
| StreamController<Map<String, Object>>.broadcast(); |
| var clientOutputController = |
| StreamController<Map<String, Object>>.broadcast(); |
| var client = VmService(clientInputController.stream.map(jsonEncode), |
| (String message) => clientOutputController.add(jsonDecode(message)), |
| disposeHandler: () async { |
| await clientInputController.close(); |
| await clientOutputController.close(); |
| }); |
| |
| var clientConnection = VmServerConnection(clientOutputController.stream, |
| clientInputController.sink, serviceRegistry, serviceMock); |
| |
| var requestParams = {'foo': 'bar'}; |
| var expectedResponse = Response()..json = {'zap': 'zip'}; |
| await client.registerService(serviceId, 'service'); |
| // Duplicate registrations should fail. |
| expect(client.registerService(serviceId, 'service'), |
| throwsA(const TypeMatcher<RPCError>())); |
| |
| client.registerServiceCallback(serviceId, (request) async { |
| expect(request, equals(requestParams)); |
| return {'result': expectedResponse.toJson()}; |
| }); |
| |
| var serviceRequest = rpcRequest(serviceId, params: requestParams); |
| |
| requestsController.add(serviceRequest); |
| expect(await responseQueue.next, rpcResponse(expectedResponse)); |
| |
| // Kill the client that registered the handler, it should now fall back |
| // on `callServiceExtension`. |
| await client.dispose(); |
| // This should complete as well. |
| await clientConnection.done; |
| |
| var mockResponse = Response()..json = {'mock': 'response'}; |
| when(serviceMock.callServiceExtension(serviceId, |
| args: argThat(equals(requestParams), named: 'args'), |
| isolateId: argThat(isNull, named: 'isolateId'))) |
| .thenAnswer((_) async => mockResponse); |
| |
| requestsController.add(serviceRequest); |
| expect(await responseQueue.next, rpcResponse(mockResponse)); |
| }); |
| }); |
| } |
| |
| Map<String, Object> rpcRequest(String method, |
| {Map<String, Object> params = const {}, String id = "1"}) => |
| { |
| "jsonrpc": "2.0", |
| "method": method, |
| "params": params, |
| "id": id, |
| }; |
| |
| Map<String, Object> rpcResponse(Response response, {String id = "1"}) => { |
| 'jsonrpc': '2.0', |
| 'id': id, |
| 'result': response.toJson(), |
| }; |
| |
| Map<String, Object> rpcErrorResponse(Object error, {String id = "1"}) { |
| Map<String, Object> errorJson; |
| if (error is RPCError) { |
| errorJson = { |
| 'code': error.code, |
| 'message': error.message, |
| }; |
| if (error.data != null) { |
| errorJson['data'] = error.data; |
| } |
| } else { |
| errorJson = { |
| 'code': -32603, |
| 'message': error.toString(), |
| }; |
| } |
| return { |
| 'jsonrpc': '2.0', |
| 'id': id, |
| 'error': errorJson, |
| }; |
| } |
| |
| Map<String, Object> streamNotifyResponse(String streamId, Event event) { |
| return { |
| 'jsonrpc': '2.0', |
| 'method': 'streamNotify', |
| 'params': { |
| 'streamId': '$streamId', |
| 'event': event.toJson(), |
| }, |
| }; |
| } |
| |
| Map<String, Object> stripEventTimestamp(Map response) { |
| if (response.containsKey('params') && |
| response['params'].containsKey('event')) { |
| response['params']['event']['timestamp'] = 0; |
| } |
| return response as Map<String, Object>; |
| } |
| |
| class MockVmService extends Mock implements VmServiceInterface { |
| final streamControllers = <String, StreamController<Event>>{}; |
| |
| @override |
| Stream<Event> onEvent(String streamId) => streamControllers |
| .putIfAbsent(streamId, () => StreamController<Event>()) |
| .stream; |
| |
| @override |
| Future<Success> setVMTimelineFlags(List<String> recordedStreams) async { |
| return Success(); |
| } |
| } |