// 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> requestsController; StreamController> responsesController; ServiceExtensionRegistry serviceRegistry; setUp(() { serviceMock = MockVmService(); requestsController = StreamController>(); responsesController = StreamController>(); 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': ['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 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>(); var responsesController2 = StreamController>(); 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>(); var responsesController3 = StreamController>(); 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>.broadcast(); var clientOutputController = StreamController>.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())); 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 rpcRequest(String method, {Map params = const {}, String id = "1"}) => { "jsonrpc": "2.0", "method": method, "params": params, "id": id, }; Map rpcResponse(Response response, {String id = "1"}) => { 'jsonrpc': '2.0', 'id': id, 'result': response.toJson(), }; Map rpcErrorResponse(Object error, {String id = "1"}) { Map 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 streamNotifyResponse(String streamId, Event event) { return { 'jsonrpc': '2.0', 'method': 'streamNotify', 'params': { 'streamId': '$streamId', 'event': event.toJson(), }, }; } Map stripEventTimestamp(Map response) { if (response.containsKey('params') && response['params'].containsKey('event')) { response['params']['event']['timestamp'] = 0; } return response as Map; } class MockVmService extends Mock implements VmServiceInterface { final streamControllers = >{}; @override Stream onEvent(String streamId) => streamControllers .putIfAbsent(streamId, () => StreamController()) .stream; @override Future setVMTimelineFlags(List recordedStreams) async { return Success(); } }