| // Copyright (c) 2021, 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 'dart:io'; |
| |
| import 'package:collection/collection.dart'; |
| import 'package:dds/src/dap/adapters/dart.dart'; |
| import 'package:dds/src/dap/logging.dart'; |
| import 'package:dds/src/dap/protocol_generated.dart'; |
| import 'package:dds/src/dap/protocol_stream.dart'; |
| import 'package:test/test.dart'; |
| import 'package:vm_service/vm_service.dart' as vm; |
| |
| import 'test_server.dart'; |
| |
| /// A helper class to simplify acting as a client for interacting with the |
| /// [DapTestServer] in tests. |
| /// |
| /// Methods on this class should map directly to protocol methods. Additional |
| /// helpers are available in [DapTestClientExtension]. |
| class DapTestClient { |
| final ByteStreamServerChannel _channel; |
| late final StreamSubscription<String> _subscription; |
| |
| final Logger? _logger; |
| final bool captureVmServiceTraffic; |
| final _requestWarningDuration = const Duration(seconds: 10); |
| final Map<int, _OutgoingRequest> _pendingRequests = {}; |
| final _eventController = StreamController<Event>.broadcast(); |
| int _seq = 1; |
| |
| /// Functions provided by tests to handle requests that may come from the |
| /// server (such as `runInTerminal`). |
| final _serverRequestHandlers = |
| <String, FutureOr<Object?> Function(Object?)>{}; |
| |
| late final Future<Uri?> vmServiceUri; |
| |
| DapTestClient._( |
| this._channel, |
| this._logger, { |
| this.captureVmServiceTraffic = false, |
| }) { |
| // Set up a future that will complete when the 'dart.debuggerUris' event is |
| // emitted by the debug adapter so tests have easy access to it. |
| vmServiceUri = event('dart.debuggerUris').then<Uri?>((event) { |
| final body = event.body as Map<String, Object?>; |
| return Uri.parse(body['vmServiceUri'] as String); |
| }).catchError((e) => null); |
| |
| _subscription = _channel.listen( |
| _handleMessage, |
| onDone: () { |
| if (_pendingRequests.isNotEmpty) { |
| _logger?.call( |
| 'Application terminated without a response to ${_pendingRequests.length} requests'); |
| } |
| _pendingRequests.forEach((id, request) => request.completer.completeError( |
| 'Application terminated without a response to request $id (${request.name})')); |
| _pendingRequests.clear(); |
| }, |
| ); |
| } |
| |
| /// Returns a stream of [OutputEventBody] events. |
| Stream<OutputEventBody> get outputEvents => events('output') |
| .map((e) => OutputEventBody.fromJson(e.body as Map<String, Object?>)); |
| |
| /// Returns a stream of custom 'dart.serviceExtensionAdded' events. |
| Stream<Map<String, Object?>> get serviceExtensionAddedEvents => |
| events('dart.serviceExtensionAdded') |
| .map((e) => e.body as Map<String, Object?>); |
| |
| /// Returns a stream of custom 'dart.serviceRegistered' events. |
| Stream<Map<String, Object?>> get serviceRegisteredEvents => |
| events('dart.serviceRegistered') |
| .map((e) => e.body as Map<String, Object?>); |
| |
| /// Returns a stream of 'dart.testNotification' custom events from the |
| /// package:test JSON reporter. |
| Stream<Map<String, Object?>> get testNotificationEvents => |
| events('dart.testNotification') |
| .map((e) => e.body as Map<String, Object?>); |
| |
| /// Send an attachRequest to the server, asking it to attach to an existing |
| /// Dart program. |
| Future<Response> attach({ |
| required bool autoResume, |
| String? vmServiceUri, |
| String? vmServiceInfoFile, |
| String? cwd, |
| List<String>? additionalProjectPaths, |
| bool? debugSdkLibraries, |
| bool? debugExternalPackageLibraries, |
| bool? evaluateGettersInDebugViews, |
| bool? evaluateToStringInDebugViews, |
| }) async { |
| assert( |
| (vmServiceUri == null) != (vmServiceInfoFile == null), |
| 'Provide exactly one of vmServiceUri/vmServiceInfoFile', |
| ); |
| |
| // When attaching, the paused VM will not be automatically unpaused, but |
| // instead send a Stopped(reason: 'entry') event. Respond to this by |
| // resuming (if requested). |
| final resumeFuture = autoResume |
| ? expectStop('entry').then((event) => continue_(event.threadId!)) |
| : null; |
| |
| final attachResponse = sendRequest( |
| DartAttachRequestArguments( |
| vmServiceUri: vmServiceUri, |
| vmServiceInfoFile: vmServiceInfoFile, |
| cwd: cwd, |
| additionalProjectPaths: additionalProjectPaths, |
| debugSdkLibraries: debugSdkLibraries, |
| debugExternalPackageLibraries: debugExternalPackageLibraries, |
| evaluateGettersInDebugViews: evaluateGettersInDebugViews, |
| evaluateToStringInDebugViews: evaluateToStringInDebugViews, |
| // When running out of process, VM Service traffic won't be available |
| // to the client-side logger, so force logging on which sends VM Service |
| // traffic in a custom event. |
| sendLogsToClient: captureVmServiceTraffic, |
| ), |
| // We can't automatically pick the command when using a custom type |
| // (DartAttachRequestArguments). |
| overrideCommand: 'attach', |
| ); |
| |
| // If we were expecting a pause and to resume, ensure that happens. |
| await resumeFuture; |
| |
| return attachResponse; |
| } |
| |
| /// Calls a service method via a custom request. |
| Future<Response> callService(String name, Object? params) { |
| return custom('callService', {'method': name, 'params': params}); |
| } |
| |
| /// Sends a continue request for the given thread. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> continue_(int threadId) => |
| sendRequest(ContinueArguments(threadId: threadId)); |
| |
| /// Sends a custom request to the server and waits for a response. |
| Future<Response> custom(String name, [Object? args]) async { |
| return sendRequest(args, overrideCommand: name); |
| } |
| |
| Future<Response> disconnect() => sendRequest(DisconnectArguments()); |
| |
| /// Sends an evaluate request for the given [expression], optionally for a |
| /// specific [frameId]. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> evaluate( |
| String expression, { |
| int? frameId, |
| String? context, |
| }) { |
| return sendRequest(EvaluateArguments( |
| expression: expression, |
| frameId: frameId, |
| context: context, |
| )); |
| } |
| |
| /// Returns a Future that completes with the next [event] event. |
| Future<Event> event(String event) => _logIfSlow( |
| 'Event "$event"', |
| _eventController.stream.firstWhere((e) => e.event == event, |
| orElse: () => |
| throw 'Did not recieve $event event before stream closed')); |
| |
| /// Returns a stream for [event] events. |
| Stream<Event> events(String event) { |
| return _eventController.stream.where((e) => e.event == event); |
| } |
| |
| /// Records a handler for when the server sends a [request] request. |
| void handleRequest( |
| String request, |
| FutureOr<Object?> Function(Object?) handler, |
| ) { |
| _serverRequestHandlers[request] = handler; |
| } |
| |
| /// Send a custom 'hotReload' request to the server. |
| Future<Response> hotReload() async { |
| return custom('hotReload'); |
| } |
| |
| /// Send an initialize request to the server. |
| /// |
| /// This occurs before the request to start running/debugging a script and is |
| /// used to exchange capabilities and send breakpoints and other settings. |
| Future<Response> initialize({ |
| String exceptionPauseMode = 'None', |
| bool? supportsRunInTerminalRequest, |
| }) async { |
| final responses = await Future.wait([ |
| event('initialized'), |
| sendRequest(InitializeRequestArguments( |
| adapterID: 'test', |
| supportsRunInTerminalRequest: supportsRunInTerminalRequest, |
| )), |
| sendRequest( |
| SetExceptionBreakpointsArguments( |
| filters: [exceptionPauseMode], |
| ), |
| ), |
| ]); |
| await sendRequest(ConfigurationDoneArguments()); |
| return responses[1] as Response; // Return the initialize response. |
| } |
| |
| /// Send a launchRequest to the server, asking it to start a Dart program. |
| Future<Response> launch( |
| String program, { |
| List<String>? args, |
| String? cwd, |
| bool? noDebug, |
| List<String>? additionalProjectPaths, |
| String? console, |
| bool? debugSdkLibraries, |
| bool? debugExternalPackageLibraries, |
| bool? evaluateGettersInDebugViews, |
| bool? evaluateToStringInDebugViews, |
| bool? sendLogsToClient, |
| }) { |
| return sendRequest( |
| DartLaunchRequestArguments( |
| noDebug: noDebug, |
| program: program, |
| cwd: cwd, |
| args: args, |
| additionalProjectPaths: additionalProjectPaths, |
| console: console, |
| debugSdkLibraries: debugSdkLibraries, |
| debugExternalPackageLibraries: debugExternalPackageLibraries, |
| evaluateGettersInDebugViews: evaluateGettersInDebugViews, |
| evaluateToStringInDebugViews: evaluateToStringInDebugViews, |
| // When running out of process, VM Service traffic won't be available |
| // to the client-side logger, so force logging on which sends VM Service |
| // traffic in a custom event. |
| sendLogsToClient: sendLogsToClient ?? captureVmServiceTraffic, |
| ), |
| // We can't automatically pick the command when using a custom type |
| // (DartLaunchRequestArguments). |
| overrideCommand: 'launch', |
| ); |
| } |
| |
| /// Sends a next (step over) request for the given thread. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> next(int threadId) => |
| sendRequest(NextArguments(threadId: threadId)); |
| |
| /// Sends a request to the server for variables scopes available for a given |
| /// stack frame. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> scopes(int frameId) { |
| return sendRequest(ScopesArguments(frameId: frameId)); |
| } |
| |
| /// Sends an arbitrary request to the server. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> sendRequest(Object? arguments, |
| {bool allowFailure = false, String? overrideCommand}) { |
| final command = overrideCommand ?? commandTypes[arguments.runtimeType]!; |
| final request = |
| Request(seq: _seq++, command: command, arguments: arguments); |
| final completer = Completer<Response>(); |
| _pendingRequests[request.seq] = |
| _OutgoingRequest(completer, command, allowFailure); |
| _channel.sendRequest(request); |
| return _logIfSlow('Request "$command"', completer.future); |
| } |
| |
| /// Sends a response to the server. |
| /// |
| /// This is used to respond to server-to-client requests such as |
| /// `runInTerminal`. |
| void sendResponse(Request request, Object? responseBody) { |
| final response = Response( |
| success: true, |
| requestSeq: request.seq, |
| seq: _seq++, |
| command: request.command, |
| body: responseBody, |
| ); |
| _channel.sendResponse(response); |
| } |
| |
| /// Sends a source request to the server to request source code for a [source] |
| /// reference that may have come from a stack frame or similar. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> source(Source source) => sendRequest( |
| SourceArguments( |
| source: source, |
| sourceReference: source.sourceReference!, |
| ), |
| ); |
| |
| /// Sends a stackTrace request to the server to request the call stack for a |
| /// given thread. |
| /// |
| /// If [startFrame] and/or [numFrames] are supplied, only a slice of the |
| /// frames will be returned. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> stackTrace(int threadId, |
| {int? startFrame, int? numFrames}) => |
| sendRequest(StackTraceArguments( |
| threadId: threadId, startFrame: startFrame, levels: numFrames)); |
| |
| /// Initializes the debug adapter and launches [file] or calls the custom |
| /// [launch] method. |
| Future<void> start({ |
| File? file, |
| Future<Object?> Function()? launch, |
| }) { |
| return Future.wait([ |
| initialize(), |
| launch?.call() ?? this.launch(file!.path), |
| ], eagerError: true); |
| } |
| |
| /// Sends a stepIn request for the given thread. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> stepIn(int threadId) => |
| sendRequest(StepInArguments(threadId: threadId)); |
| |
| /// Sends a stepOut request for the given thread. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> stepOut(int threadId) => |
| sendRequest(StepOutArguments(threadId: threadId)); |
| |
| Future<void> stop() async { |
| _channel.close(); |
| await _subscription.cancel(); |
| } |
| |
| Future<Response> terminate() => sendRequest(TerminateArguments()); |
| |
| /// Sends a threads request to the server to request the list of active |
| /// threads (isolates). |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> threads() => sendRequest(null, overrideCommand: 'threads'); |
| |
| /// Sends a request for child variables (fields/list elements/etc.) for the |
| /// variable with reference [variablesReference]. |
| /// |
| /// If [start] and/or [count] are supplied, only a slice of the variables will |
| /// be returned. This is used to allow the client to page through large Lists |
| /// or Maps without needing all of the data immediately. |
| /// |
| /// Returns a Future that completes when the server returns a corresponding |
| /// response. |
| Future<Response> variables( |
| int variablesReference, { |
| int? start, |
| int? count, |
| }) { |
| return sendRequest(VariablesArguments( |
| variablesReference: variablesReference, |
| start: start, |
| count: count, |
| )); |
| } |
| |
| /// Handles an incoming message from the server, completing the relevant request |
| /// of raising the appropriate event. |
| Future<void> _handleMessage(message) async { |
| if (message is Response) { |
| final pendingRequest = _pendingRequests.remove(message.requestSeq); |
| if (pendingRequest == null) { |
| return; |
| } |
| final completer = pendingRequest.completer; |
| if (message.success || pendingRequest.allowFailure) { |
| completer.complete(message); |
| } else { |
| completer.completeError(message); |
| } |
| } else if (message is Event && !_eventController.isClosed) { |
| _eventController.add(message); |
| |
| // When we see a terminated event, close the event stream so if any |
| // tests are waiting on something that will never come, they fail at |
| // a useful location. |
| if (message.event == 'terminated') { |
| unawaited(_eventController.close()); |
| } |
| } else if (message is Request) { |
| // The server sent a request to the client. Call the handler and then send |
| // back its result in a response. |
| final command = message.command; |
| final args = message.arguments; |
| final handler = _serverRequestHandlers[command]; |
| if (handler == null) { |
| throw 'Test did not configure a handler for servers request: $command'; |
| } |
| final result = await handler(args); |
| sendResponse(message, result); |
| } |
| } |
| |
| /// Prints a warning if [future] takes longer than [_requestWarningDuration] |
| /// to complete. |
| /// |
| /// Returns [future]. |
| Future<T> _logIfSlow<T>(String name, Future<T> future) { |
| var didComplete = false; |
| Future.delayed(_requestWarningDuration).then((_) { |
| if (!didComplete) { |
| print( |
| '$name has taken longer than ${_requestWarningDuration.inSeconds}s'); |
| } |
| }); |
| return future.whenComplete(() => didComplete = true); |
| } |
| |
| /// Creates a [DapTestClient] that connects the server listening on |
| /// [host]:[port]. |
| static Future<DapTestClient> connect( |
| DapTestServer server, { |
| bool captureVmServiceTraffic = false, |
| Logger? logger, |
| }) async { |
| final channel = ByteStreamServerChannel(server.stream, server.sink, logger); |
| return DapTestClient._(channel, logger, |
| captureVmServiceTraffic: captureVmServiceTraffic); |
| } |
| } |
| |
| /// Useful events produced by the debug adapter during a debug session. |
| class TestEvents { |
| final List<OutputEventBody> output; |
| final List<Map<String, Object?>> testNotifications; |
| |
| TestEvents({ |
| required this.output, |
| required this.testNotifications, |
| }); |
| } |
| |
| class _OutgoingRequest { |
| final Completer<Response> completer; |
| final String name; |
| final bool allowFailure; |
| |
| _OutgoingRequest(this.completer, this.name, this.allowFailure); |
| } |
| |
| /// Additional helper method for tests to simplify interaction with [DapTestClient]. |
| /// |
| /// Unlike the methods on [DapTestClient] these methods might not map directly |
| /// onto protocol methods. They may call multiple protocol methods and/or |
| /// simplify assertion specific conditions/results. |
| extension DapTestClientExtension on DapTestClient { |
| /// Sets a breakpoint at [line] in [file] and expects to hit it after running |
| /// the script. |
| /// |
| /// Launch options can be customised by passing a custom [launch] function that |
| /// will be used instead of calling `launch(file.path)`. |
| Future<StoppedEventBody> hitBreakpoint( |
| File file, |
| int line, { |
| File? entryFile, |
| String? condition, |
| String? cwd, |
| List<String>? args, |
| Future<Response> Function()? launch, |
| }) async { |
| entryFile ??= file; |
| final stop = expectStop('breakpoint', file: file, line: line); |
| |
| await Future.wait([ |
| initialize(), |
| setBreakpoint(file, line, condition: condition), |
| launch?.call() ?? this.launch(entryFile.path, cwd: cwd, args: args), |
| ], eagerError: true); |
| |
| return stop; |
| } |
| |
| /// Sets a breakpoint at [line] in [file]. |
| Future<void> setBreakpoint(File file, int line, {String? condition}) async { |
| await sendRequest( |
| SetBreakpointsArguments( |
| source: Source(path: file.path), |
| breakpoints: [SourceBreakpoint(line: line, condition: condition)], |
| ), |
| ); |
| } |
| |
| /// Sets the exception pause mode to [pauseMode] and expects to pause after |
| /// running the script. |
| /// |
| /// Launch options can be customised by passing a custom [launch] function that |
| /// will be used instead of calling `launch(file.path)`. |
| Future<StoppedEventBody> pauseOnException( |
| File file, { |
| String? exceptionPauseMode, // All, Unhandled, None |
| Future<Response> Function()? launch, |
| }) async { |
| final stop = expectStop('exception', file: file); |
| |
| await Future.wait([ |
| initialize(), |
| sendRequest( |
| SetExceptionBreakpointsArguments( |
| filters: [if (exceptionPauseMode != null) exceptionPauseMode], |
| ), |
| ), |
| launch?.call() ?? this.launch(file.path), |
| ], eagerError: true); |
| |
| return stop; |
| } |
| |
| /// Sets a breakpoint at [line] in [file] and expects _not_ to hit it after |
| /// running the script (instead the script is expected to terminate). |
| /// |
| /// Launch options can be customised by passing a custom [launch] function that |
| /// will be used instead of calling `launch(file.path)`. |
| Future<void> doNotHitBreakpoint( |
| File file, |
| int line, { |
| String? condition, |
| String? logMessage, |
| Future<Response> Function()? launch, |
| }) async { |
| await Future.wait([ |
| event('terminated'), |
| initialize(), |
| sendRequest( |
| SetBreakpointsArguments( |
| source: Source(path: file.path), |
| breakpoints: [ |
| SourceBreakpoint( |
| line: line, |
| condition: condition, |
| logMessage: logMessage, |
| ) |
| ], |
| ), |
| ), |
| launch?.call() ?? this.launch(file.path), |
| ], eagerError: true); |
| } |
| |
| /// Returns whether DDS is available for the VM Service the debug adapter |
| /// is connected to. |
| Future<bool> get ddsAvailable async { |
| final response = await custom( |
| '_getSupportedProtocols', |
| null, |
| ); |
| |
| // For convenience, use the ProtocolList to deserialise the custom |
| // response to check if included DDS. |
| final protocolList = |
| vm.ProtocolList.parse(response.body as Map<String, Object?>?); |
| |
| final ddsProtocol = protocolList?.protocols?.singleWhereOrNull( |
| (protocol) => protocol.protocolName == 'DDS', |
| ); |
| return ddsProtocol != null; |
| } |
| |
| /// Runs a script and expects to pause at an exception in [file]. |
| Future<StoppedEventBody> hitException( |
| File file, [ |
| String exceptionPauseMode = 'Unhandled', |
| int? line, |
| ]) async { |
| final stop = expectStop('exception', file: file, line: line); |
| |
| await Future.wait([ |
| initialize(exceptionPauseMode: exceptionPauseMode), |
| launch(file.path), |
| ], eagerError: true); |
| |
| return stop; |
| } |
| |
| /// Expects a 'stopped' event for [reason]. |
| /// |
| /// If [file] or [line] are provided, they will be checked against the stop |
| /// location for the top stack frame. |
| Future<StoppedEventBody> expectStop( |
| String reason, { |
| File? file, |
| int? line, |
| String? sourceName, |
| }) async { |
| final e = await event('stopped'); |
| final stop = StoppedEventBody.fromJson(e.body as Map<String, Object?>); |
| expect(stop.reason, equals(reason)); |
| |
| final result = |
| await getValidStack(stop.threadId!, startFrame: 0, numFrames: 1); |
| |
| if (file != null || line != null || sourceName != null) { |
| expect(result.stackFrames, hasLength(1)); |
| final frame = result.stackFrames[0]; |
| |
| if (file != null) { |
| expect(frame.source?.path, equals(file.path)); |
| } |
| if (sourceName != null) { |
| expect(frame.source?.name, equals(sourceName)); |
| } |
| if (line != null) { |
| expect(frame.line, equals(line)); |
| } |
| } |
| |
| return stop; |
| } |
| |
| /// Fetches a stack trace and asserts it was a valid response. |
| Future<StackTraceResponseBody> getValidStack(int threadId, |
| {required int startFrame, required int numFrames}) async { |
| final response = await stackTrace(threadId, |
| startFrame: startFrame, numFrames: numFrames); |
| expect(response.success, isTrue); |
| expect(response.command, equals('stackTrace')); |
| return StackTraceResponseBody.fromJson( |
| response.body as Map<String, Object?>); |
| } |
| |
| /// Fetches source for a sourceReference and asserts it was a valid response. |
| Future<SourceResponseBody> getValidSource(Source source) async { |
| final response = await this.source(source); |
| expect(response.success, isTrue); |
| expect(response.command, equals('source')); |
| return SourceResponseBody.fromJson(response.body as Map<String, Object?>); |
| } |
| |
| /// Fetches threads and asserts a valid response. |
| Future<ThreadsResponseBody> getValidThreads() async { |
| final response = await threads(); |
| expect(response.success, isTrue); |
| expect(response.command, equals('threads')); |
| return ThreadsResponseBody.fromJson(response.body as Map<String, Object?>); |
| } |
| |
| /// Collects all output events until the program terminates. |
| /// |
| /// These results include all events in the order they are recieved, including |
| /// console, stdout and stderr. |
| /// |
| /// Only one of [start] or [launch] may be provided. Use [start] to customise |
| /// the whole start of the session (including initialise) or [launch] to only |
| /// customise the [launchRequest]. |
| Future<List<OutputEventBody>> collectOutput({ |
| File? file, |
| Future<Response> Function()? start, |
| Future<Response> Function()? launch, |
| }) async { |
| assert( |
| start == null || launch == null, |
| 'Only one of "start" or "launch" may be provided', |
| ); |
| final outputEventsFuture = outputEvents.toList(); |
| |
| if (start != null) { |
| await start(); |
| } else { |
| await this.start(file: file, launch: launch); |
| } |
| |
| return outputEventsFuture; |
| } |
| |
| /// Collects all output and test events until the program terminates. |
| /// |
| /// These results include all events in the order they are recieved, including |
| /// console, stdout, stderr and test notifications from the test JSON reporter. |
| /// |
| /// Only one of [start] or [launch] may be provided. Use [start] to customise |
| /// the whole start of the session (including initialise) or [launch] to only |
| /// customise the [launchRequest]. |
| Future<TestEvents> collectTestOutput({ |
| File? file, |
| Future<Response> Function()? start, |
| Future<Object?> Function()? launch, |
| }) async { |
| assert( |
| start == null || launch == null, |
| 'Only one of "start" or "launch" may be provided', |
| ); |
| |
| final outputEventsFuture = outputEvents.toList(); |
| final testNotificationEventsFuture = testNotificationEvents.toList(); |
| |
| if (start != null) { |
| await start(); |
| } else { |
| await this.start(file: file, launch: launch); |
| } |
| |
| return TestEvents( |
| output: await outputEventsFuture, |
| testNotifications: await testNotificationEventsFuture, |
| ); |
| } |
| |
| /// A helper that fetches scopes for a frame, checks for one with the name |
| /// [expectedName] and verifies its variables. |
| Future<Scope> expectScopeVariables( |
| int frameId, |
| String expectedName, |
| String expectedVariables, { |
| bool ignorePrivate = true, |
| Set<String>? ignore, |
| }) async { |
| final scope = await getValidScope(frameId, expectedName); |
| await expectVariables( |
| scope.variablesReference, |
| expectedVariables, |
| ignorePrivate: ignorePrivate, |
| ignore: ignore, |
| ); |
| return scope; |
| } |
| |
| /// Requests variables scopes for a frame returns one with a specific name. |
| Future<Scope> getValidScope(int frameId, String name) async { |
| final scopes = await getValidScopes(frameId); |
| return scopes.scopes.singleWhere( |
| (s) => s.name == name, |
| orElse: () => throw 'Did not find scope with name $name', |
| ); |
| } |
| |
| /// A helper that finds a named variable in the Variables scope for the top |
| /// frame and asserts its child variables (fields/getters/etc) match. |
| Future<VariablesResponseBody> expectLocalVariable( |
| int threadId, { |
| required String expectedName, |
| required String expectedDisplayString, |
| required String expectedVariables, |
| int? start, |
| int? count, |
| bool ignorePrivate = true, |
| Set<String>? ignore, |
| }) async { |
| final stack = await getValidStack( |
| threadId, |
| startFrame: 0, |
| numFrames: 1, |
| ); |
| final topFrame = stack.stackFrames.first; |
| |
| final variablesScope = await getValidScope(topFrame.id, 'Locals'); |
| final variables = |
| await getValidVariables(variablesScope.variablesReference); |
| final expectedVariable = variables.variables |
| .singleWhere((variable) => variable.name == expectedName); |
| |
| // Check the display string. |
| expect(expectedVariable.value, equals(expectedDisplayString)); |
| |
| // Check the child fields. |
| return expectVariables( |
| expectedVariable.variablesReference, |
| expectedVariables, |
| start: start, |
| count: count, |
| ignorePrivate: ignorePrivate, |
| ignore: ignore, |
| ); |
| } |
| |
| /// Requests variables scopes for a frame and asserts a valid response. |
| Future<ScopesResponseBody> getValidScopes(int frameId) async { |
| final response = await scopes(frameId); |
| expect(response.success, isTrue); |
| expect(response.command, equals('scopes')); |
| return ScopesResponseBody.fromJson(response.body as Map<String, Object?>); |
| } |
| |
| /// Requests variables by reference and asserts a valid response. |
| Future<VariablesResponseBody> getValidVariables( |
| int variablesReference, { |
| int? start, |
| int? count, |
| }) async { |
| final response = await variables( |
| variablesReference, |
| start: start, |
| count: count, |
| ); |
| expect(response.success, isTrue); |
| expect(response.command, equals('variables')); |
| return VariablesResponseBody.fromJson( |
| response.body as Map<String, Object?>); |
| } |
| |
| /// A helper that verifies the variables list matches [expectedVariables]. |
| /// |
| /// [expectedVariables] is a simple text format of `name: value` for each |
| /// variable with some additional annotations to simplify writing tests. |
| Future<VariablesResponseBody> expectVariables( |
| int variablesReference, |
| String expectedVariables, { |
| int? start, |
| int? count, |
| bool ignorePrivate = true, |
| Set<String>? ignore, |
| }) async { |
| final expectedLines = |
| expectedVariables.trim().split('\n').map((l) => l.trim()).toList(); |
| |
| final variables = await getValidVariables( |
| variablesReference, |
| start: start, |
| count: count, |
| ); |
| |
| // If a variable was set to be ignored but wasn't in the list, that's |
| // likely an error in the test. |
| if (ignore != null) { |
| final variableNames = variables.variables.map((v) => v.name).toSet(); |
| for (final ignored in ignore) { |
| expect( |
| variableNames.contains(ignored), |
| isTrue, |
| reason: 'Variable "$ignored" should be ignored but was ' |
| 'not in the results ($variableNames)', |
| ); |
| } |
| } |
| |
| /// Helper to format the variables into a simple text representation that's |
| /// easy to maintain in tests. |
| String toSimpleTextRepresentation(Variable v) { |
| final buffer = StringBuffer(); |
| final evaluateName = v.evaluateName; |
| final indexedVariables = v.indexedVariables; |
| final namedVariables = v.namedVariables; |
| final value = v.value; |
| final type = v.type; |
| final presentationHint = v.presentationHint; |
| |
| buffer.write('${v.name}: $value'); |
| if (evaluateName != null) { |
| buffer.write(', eval: $evaluateName'); |
| } |
| if (indexedVariables != null) { |
| buffer.write(', $indexedVariables items'); |
| } |
| if (namedVariables != null) { |
| buffer.write(', $namedVariables named items'); |
| } |
| if (type != null) { |
| buffer.write(', $type'); |
| } |
| if (presentationHint != null) { |
| buffer.write(', $presentationHint'); |
| } |
| |
| return buffer.toString(); |
| } |
| |
| final actual = variables.variables |
| .where((v) => ignorePrivate ? !v.name.startsWith('_') : true) |
| .where((v) => !(ignore?.contains(v.name) ?? false)) |
| // Always exclude hashCode because its value is not guaranteed. |
| .where((v) => v.name != 'hashCode') |
| .map(toSimpleTextRepresentation); |
| |
| expect(actual.join('\n'), equals(expectedLines.join('\n'))); |
| |
| return variables; |
| } |
| |
| Future<int> getTopFrameId( |
| int threadId, |
| ) async { |
| final stack = await getValidStack(threadId, startFrame: 0, numFrames: 1); |
| return stack.stackFrames.first.id; |
| } |
| |
| /// Evalutes [expression] in frame [frameId] and expects a specific |
| /// [expectedResult]. |
| Future<EvaluateResponseBody> expectEvalResult( |
| int frameId, |
| String expression, |
| String expectedResult, |
| ) async { |
| final response = await evaluate(expression, frameId: frameId); |
| expect(response.success, isTrue); |
| expect(response.command, equals('evaluate')); |
| final body = |
| EvaluateResponseBody.fromJson(response.body as Map<String, Object?>); |
| |
| expect(body.result, equals(expectedResult)); |
| |
| return body; |
| } |
| } |