[dds] Add DAP support for Scopes/Variables
Change-Id: Idaaa08693824c389ebc83bd5f7e29d61d70cbb84
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/202700
Reviewed-by: Ben Konyi <bkonyi@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
diff --git a/pkg/dds/lib/src/dap/adapters/dart.dart b/pkg/dds/lib/src/dap/adapters/dart.dart
index 693b9c0..50f5e74 100644
--- a/pkg/dds/lib/src/dap/adapters/dart.dart
+++ b/pkg/dds/lib/src/dap/adapters/dart.dart
@@ -16,6 +16,13 @@
import '../protocol_generated.dart';
import '../protocol_stream.dart';
+/// Maximum number of toString()s to be called when responding to variables
+/// requests from the client.
+///
+/// Setting this too high can have a performance impact, for example if the
+/// client requests 500 items in a variablesRequest for a list.
+const maxToStringsPerEvaluation = 10;
+
/// A base DAP Debug Adapter implementation for running and debugging Dart-based
/// applications (including Flutter and Tests).
///
@@ -94,7 +101,7 @@
/// processed its initial paused state).
Future<void> get debuggerInitialized => _debuggerInitializedCompleter.future;
- /// attachRequest is called by the client when it wants us to to attach to
+ /// [attachRequest] is called by the client when it wants us to to attach to
/// an existing app. This will only be called once (and only one of this or
/// launchRequest will be called).
@override
@@ -242,7 +249,7 @@
/// `disconnectRequest` (a forceful request to shut down).
Future<void> disconnectImpl();
- /// disconnectRequest is called by the client when it wants to forcefully shut
+ /// [disconnectRequest] is called by the client when it wants to forcefully shut
/// us down quickly. This comes after the `terminateRequest` which is intended
/// to allow a graceful shutdown.
///
@@ -261,7 +268,7 @@
sendResponse();
}
- /// initializeRequest is the first request send by the client during
+ /// [initializeRequest] is the first call from the client during
/// initialization and allows exchanging capabilities and configuration
/// between client and server.
///
@@ -310,7 +317,7 @@
/// to this request.
Future<void> launchImpl();
- /// launchRequest is called by the client when it wants us to to start the app
+ /// [launchRequest] is called by the client when it wants us to to start the app
/// to be run/debug. This will only be called once (and only one of this or
/// attachRequest will be called).
@override
@@ -343,6 +350,40 @@
sendResponse();
}
+ /// [scopesRequest] is called by the client to request all of the variables
+ /// scopes available for a given stack frame.
+ @override
+ Future<void> scopesRequest(
+ Request request,
+ ScopesArguments args,
+ void Function(ScopesResponseBody) sendResponse,
+ ) async {
+ final scopes = <Scope>[];
+
+ // For local variables, we can just reuse the frameId as variablesReference
+ // as variablesRequest handles stored data of type `Frame` directly.
+ scopes.add(Scope(
+ name: 'Variables',
+ presentationHint: 'locals',
+ variablesReference: args.frameId,
+ expensive: false,
+ ));
+
+ // If the top frame has an exception, add an additional section to allow
+ // that to be inspected.
+ final data = _isolateManager.getStoredData(args.frameId);
+ final exceptionReference = data?.thread.exceptionReference;
+ if (exceptionReference != null) {
+ scopes.add(Scope(
+ name: 'Exceptions',
+ variablesReference: exceptionReference,
+ expensive: false,
+ ));
+ }
+
+ sendResponse(ScopesResponseBody(scopes: scopes));
+ }
+
/// Sends an OutputEvent (without a newline, since calls to this method
/// may be used by buffered data).
void sendOutput(String category, String message) {
@@ -370,7 +411,7 @@
/// The VM requires breakpoints to be set per-isolate so these will be passed
/// to [_isolateManager] that will fan them out to each isolate.
///
- /// When new isolates are registered, it is [isolateManager]s responsibility
+ /// When new isolates are registered, it is [isolateManager]'s responsibility
/// to ensure all breakpoints are given to them (and like at startup, this
/// must happen before they are resumed).
@override
@@ -394,6 +435,34 @@
));
}
+ /// Handles a request from the client to set exception pause modes.
+ ///
+ /// This method can be called at any time (before the app is launched or while
+ /// the app is running).
+ ///
+ /// The VM requires exception modes to be set per-isolate so these will be
+ /// passed to [_isolateManager] that will fan them out to each isolate.
+ ///
+ /// When new isolates are registered, it is [isolateManager]'s responsibility
+ /// to ensure the pause mode is given to them (and like at startup, this
+ /// must happen before they are resumed).
+ @override
+ Future<void> setExceptionBreakpointsRequest(
+ Request request,
+ SetExceptionBreakpointsArguments args,
+ void Function(SetExceptionBreakpointsResponseBody) sendResponse,
+ ) async {
+ final mode = args.filters.contains('All')
+ ? 'All'
+ : args.filters.contains('Unhandled')
+ ? 'Unhandled'
+ : 'None';
+
+ await _isolateManager.setExceptionPauseMode(mode);
+
+ sendResponse(SetExceptionBreakpointsResponseBody());
+ }
+
/// Handles a request from the client for the call stack for [args.threadId].
///
/// This is usually called after we sent a [StoppedEvent] to the client
@@ -516,7 +585,7 @@
/// `terminateRequest` (a request for a graceful shut down).
Future<void> terminateImpl();
- /// terminateRequest is called by the client when it wants us to gracefully
+ /// [terminateRequest] is called by the client when it wants us to gracefully
/// shut down.
///
/// It's not very obvious from the names, but `terminateRequest` is sent first
@@ -534,6 +603,86 @@
sendResponse();
}
+ /// [variablesRequest] is called by the client to request child variables for
+ /// a given variables variablesReference.
+ ///
+ /// The variablesReference provided by the client will be a reference the
+ /// server has previously provided, for example in response to a scopesRequest
+ /// or an evaluateRequest.
+ ///
+ /// We use the reference to look up the stored data and then create variables
+ /// based on the type of data. For a Frame, we will return the local
+ /// variables, for a List/MapAssociation we will return items from it, and for
+ /// an instance we will return the fields (and possibly getters) for that
+ /// instance.
+ @override
+ Future<void> variablesRequest(
+ Request request,
+ VariablesArguments args,
+ void Function(VariablesResponseBody) sendResponse,
+ ) async {
+ final childStart = args.start;
+ final childCount = args.count;
+ final storedData = _isolateManager.getStoredData(args.variablesReference);
+ if (storedData == null) {
+ throw StateError('variablesReference is no longer valid');
+ }
+ final thread = storedData.thread;
+ final data = storedData.data;
+ final vmData = data is vm.Response ? data : null;
+ final variables = <Variable>[];
+
+ if (vmData is vm.Frame) {
+ final vars = vmData.vars;
+ if (vars != null) {
+ Future<Variable> convert(int index, vm.BoundVariable variable) {
+ return _converter.convertVmResponseToVariable(
+ thread,
+ variable.value,
+ name: variable.name,
+ allowCallingToString: index <= maxToStringsPerEvaluation,
+ );
+ }
+
+ variables.addAll(await Future.wait(vars.mapIndexed(convert)));
+ }
+ } else if (vmData is vm.MapAssociation) {
+ // TODO(dantup): Maps
+ } else if (vmData is vm.ObjRef) {
+ final object =
+ await _isolateManager.getObject(storedData.thread.isolate, vmData);
+
+ if (object is vm.Sentinel) {
+ variables.add(Variable(
+ name: '<eval error>',
+ value: object.valueAsString.toString(),
+ variablesReference: 0,
+ ));
+ } else if (object is vm.Instance) {
+ // TODO(dantup): evaluateName
+ // should be built taking the parent into account, for ex. if
+ // args.variablesReference == thread.exceptionReference then we need to
+ // use some sythensized variable name like $e.
+ variables.addAll(await _converter.convertVmInstanceToVariablesList(
+ thread,
+ object,
+ startItem: childStart,
+ numItems: childCount,
+ ));
+ } else {
+ variables.add(Variable(
+ name: '<eval error>',
+ value: object.runtimeType.toString(),
+ variablesReference: 0,
+ ));
+ }
+ }
+
+ variables.sortBy((v) => v.name);
+
+ sendResponse(VariablesResponseBody(variables: variables));
+ }
+
void _handleDebugEvent(vm.Event event) {
_isolateManager.handleEvent(event);
}
diff --git a/pkg/dds/lib/src/dap/base_debug_adapter.dart b/pkg/dds/lib/src/dap/base_debug_adapter.dart
index 9750cd6..cff12c2 100644
--- a/pkg/dds/lib/src/dap/base_debug_adapter.dart
+++ b/pkg/dds/lib/src/dap/base_debug_adapter.dart
@@ -139,6 +139,12 @@
void Function() sendResponse,
);
+ Future<void> scopesRequest(
+ Request request,
+ ScopesArguments args,
+ void Function(ScopesResponseBody) sendResponse,
+ );
+
/// Sends an event, lookup up the event type based on the runtimeType of
/// [body].
void sendEvent(EventBody body) {
@@ -166,6 +172,12 @@
SetBreakpointsArguments args,
void Function(SetBreakpointsResponseBody) sendResponse);
+ Future<void> setExceptionBreakpointsRequest(
+ Request request,
+ SetExceptionBreakpointsArguments args,
+ void Function(SetExceptionBreakpointsResponseBody) sendResponse,
+ );
+
Future<void> stackTraceRequest(
Request request,
StackTraceArguments args,
@@ -190,6 +202,12 @@
void Function() sendResponse,
);
+ Future<void> variablesRequest(
+ Request request,
+ VariablesArguments args,
+ void Function(VariablesResponseBody) sendResponse,
+ );
+
/// Wraps a fromJson handler for requests that allow null arguments.
_NullableFromJsonHandler<T> _allowNullArg<T extends RequestArguments>(
_FromJsonHandler<T> fromJson,
@@ -236,6 +254,12 @@
);
} else if (request.command == 'setBreakpoints') {
handle(request, setBreakpointsRequest, SetBreakpointsArguments.fromJson);
+ } else if (request.command == 'setExceptionBreakpoints') {
+ handle(
+ request,
+ setExceptionBreakpointsRequest,
+ SetExceptionBreakpointsArguments.fromJson,
+ );
} else if (request.command == 'continue') {
handle(request, continueRequest, ContinueArguments.fromJson);
} else if (request.command == 'next') {
@@ -251,6 +275,10 @@
StepOutArguments.fromJson);
} else if (request.command == 'stackTrace') {
handle(request, stackTraceRequest, StackTraceArguments.fromJson);
+ } else if (request.command == 'scopes') {
+ handle(request, scopesRequest, ScopesArguments.fromJson);
+ } else if (request.command == 'variables') {
+ handle(request, variablesRequest, VariablesArguments.fromJson);
} else {
final response = Response(
success: false,
diff --git a/pkg/dds/lib/src/dap/isolate_manager.dart b/pkg/dds/lib/src/dap/isolate_manager.dart
index b2273ed..672f4a1 100644
--- a/pkg/dds/lib/src/dap/isolate_manager.dart
+++ b/pkg/dds/lib/src/dap/isolate_manager.dart
@@ -43,6 +43,12 @@
final Map<String, Map<String, List<vm.Breakpoint>>>
_vmBreakpointsByIsolateIdAndUri = {};
+ /// The exception pause mode last provided by the client.
+ ///
+ /// This will be sent to isolates as they are created, and to all existing
+ /// isolates at start or when changed.
+ String _exceptionPauseMode = 'None';
+
/// An incrementing number used as the reference for [_storedData].
var _nextStoredDataId = 1;
@@ -72,6 +78,12 @@
return res as T;
}
+ /// Retrieves some basic data indexed by an integer for use in "reference"
+ /// fields that are round-tripped to the client.
+ _StoredData? getStoredData(int id) {
+ return _storedData[id];
+ }
+
ThreadInfo? getThread(int threadId) => _threadsByThreadId[threadId];
/// Handles Isolate and Debug events
@@ -225,6 +237,17 @@
_debug = debug;
}
+ /// Records exception pause mode as one of 'None', 'Unhandled' or 'All'. All
+ /// existing isolates will be updated to reflect the new setting.
+ Future<void> setExceptionPauseMode(String mode) async {
+ _exceptionPauseMode = mode;
+
+ // Send to all existing threads.
+ await Future.wait(_threadsByThreadId.values.map(
+ (isolate) => _sendExceptionPauseMode(isolate.isolate),
+ ));
+ }
+
/// Stores some basic data indexed by an integer for use in "reference" fields
/// that are round-tripped to the client.
int storeData(ThreadInfo thread, Object data) {
@@ -241,8 +264,7 @@
Future<void> _configureIsolate(vm.IsolateRef isolate) async {
await Future.wait([
_sendLibraryDebuggables(isolate),
- // TODO(dantup): Implement this...
- // _sendExceptionPauseMode(isolate),
+ _sendExceptionPauseMode(isolate),
_sendBreakpoints(isolate),
], eagerError: true);
}
@@ -307,7 +329,12 @@
reason = 'exception';
}
- // TODO(dantup): Store exception.
+ // If we stopped at an exception, capture the exception instance so we
+ // can add a variables scope for it so it can be examined.
+ final exception = event.exception;
+ if (exception != null) {
+ thread.exceptionReference = thread.storeData(exception);
+ }
// Notify the client.
_adapter.sendEvent(
@@ -395,6 +422,16 @@
}
}
+ /// Sets the exception pause mode for an individual isolate.
+ Future<void> _sendExceptionPauseMode(vm.IsolateRef isolate) async {
+ final service = _adapter.vmService;
+ if (!_debug || service == null) {
+ return;
+ }
+
+ await service.setExceptionPauseMode(isolate.id!, _exceptionPauseMode);
+ }
+
/// Calls setLibraryDebuggable for all libraries in the given isolate based
/// on the debug settings.
Future<void> _sendLibraryDebuggables(vm.IsolateRef isolateRef) async {
diff --git a/pkg/dds/lib/src/dap/protocol_converter.dart b/pkg/dds/lib/src/dap/protocol_converter.dart
index 1fa00fe..7291ce8 100644
--- a/pkg/dds/lib/src/dap/protocol_converter.dart
+++ b/pkg/dds/lib/src/dap/protocol_converter.dart
@@ -5,6 +5,7 @@
import 'dart:async';
import 'dart:io';
+import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'package:vm_service/vm_service.dart' as vm;
@@ -40,6 +41,226 @@
return !rel.startsWith('..') ? rel : sourcePath;
}
+ /// Converts a [vm.InstanceRef] into a user-friendly display string.
+ ///
+ /// This may be shown in the collapsed view of a complex type.
+ ///
+ /// If [allowCallingToString] is true, the toString() method may be called on
+ /// the object for a display string.
+ ///
+ /// Strings are usually wrapped in quotes to indicate their type. This can be
+ /// controlled with [includeQuotesAroundString] (for example to suppress them
+ /// if the context indicates the user is copying the value to the clipboard).
+ Future<String> convertVmInstanceRefToDisplayString(
+ ThreadInfo thread,
+ vm.InstanceRef ref, {
+ required bool allowCallingToString,
+ bool includeQuotesAroundString = true,
+ }) async {
+ final canCallToString = allowCallingToString &&
+ (_adapter.args.evaluateToStringInDebugViews ?? false);
+
+ if (ref.kind == 'String' || ref.valueAsString != null) {
+ var stringValue = ref.valueAsString.toString();
+ if (ref.valueAsStringIsTruncated ?? false) {
+ stringValue = '$stringValue…';
+ }
+ if (ref.kind == 'String' && includeQuotesAroundString) {
+ stringValue = '"$stringValue"';
+ }
+ return stringValue;
+ } else if (ref.kind == 'PlainInstance') {
+ var stringValue = ref.classRef?.name ?? '<unknown instance>';
+ if (canCallToString) {
+ final toStringValue = await _callToString(
+ thread,
+ ref,
+ includeQuotesAroundString: false,
+ );
+ stringValue += ' ($toStringValue)';
+ }
+ return stringValue;
+ } else if (ref.kind == 'List') {
+ return 'List (${ref.length} ${ref.length == 1 ? "item" : "items"})';
+ } else if (ref.kind == 'Map') {
+ return 'Map (${ref.length} ${ref.length == 1 ? "item" : "items"})';
+ } else if (ref.kind == 'Type') {
+ return 'Type (${ref.name})';
+ } else {
+ return ref.kind ?? '<unknown result>';
+ }
+ }
+
+ /// Converts a [vm.Instace] to a list of [dap.Variable]s, one for each
+ /// field/member/element/association.
+ ///
+ /// If [startItem] and/or [numItems] are supplied, only a slice of the
+ /// items will be returned to allow the client to page.
+ Future<List<dap.Variable>> convertVmInstanceToVariablesList(
+ ThreadInfo thread,
+ vm.Instance instance, {
+ int? startItem = 0,
+ int? numItems,
+ }) async {
+ final elements = instance.elements;
+ final associations = instance.associations;
+ final fields = instance.fields;
+
+ if (_isSimpleKind(instance.kind)) {
+ // For simple kinds, just return a single variable with their value.
+ return [
+ await convertVmResponseToVariable(
+ thread,
+ instance,
+ allowCallingToString: true,
+ )
+ ];
+ } else if (elements != null) {
+ // For lists, map each item (in the requested subset) to a variable.
+ final start = startItem ?? 0;
+ return Future.wait(elements
+ .cast<vm.Response>()
+ .sublist(start, numItems != null ? start + numItems : null)
+ .mapIndexed((index, response) async => convertVmResponseToVariable(
+ thread, response,
+ name: '${start + index}',
+ allowCallingToString: index <= maxToStringsPerEvaluation)));
+ } else if (associations != null) {
+ // For maps, create a variable for each entry (in the requested subset).
+ // Use the keys and values to create a display string in the form
+ // "Key -> Value".
+ // Both the key and value will be expandable (handled by variablesRequest
+ // detecting the MapAssociation type).
+ final start = startItem ?? 0;
+ return Future.wait(associations
+ .sublist(start, numItems != null ? start + numItems : null)
+ .mapIndexed((index, mapEntry) async {
+ final allowCallingToString = index <= maxToStringsPerEvaluation;
+ final keyDisplay = await convertVmResponseToDisplayString(
+ thread, mapEntry.key,
+ allowCallingToString: allowCallingToString);
+ final valueDisplay = await convertVmResponseToDisplayString(
+ thread, mapEntry.value,
+ allowCallingToString: allowCallingToString);
+ return dap.Variable(
+ name: '${start + index}',
+ value: '$keyDisplay -> $valueDisplay',
+ variablesReference: thread.storeData(mapEntry),
+ );
+ }));
+ } else if (fields != null) {
+ // Otherwise, show the fields from the instance.
+ final variables = await Future.wait(fields.mapIndexed(
+ (index, field) async => convertVmResponseToVariable(
+ thread, field.value,
+ name: field.decl?.name ?? '<unnamed field>',
+ allowCallingToString: index <= maxToStringsPerEvaluation)));
+
+ // Also evaluate the getters if evaluateGettersInDebugViews=true enabled.
+ final service = _adapter.vmService;
+ if (service != null &&
+ (_adapter.args.evaluateGettersInDebugViews ?? false)) {
+ // Collect getter names for this instances class and its supers.
+ final getterNames =
+ await _getterNamesForClassHierarchy(thread, instance.classRef);
+
+ /// Helper to evaluate each getter and convert the response to a
+ /// variable.
+ Future<dap.Variable> evaluate(int index, String getterName) async {
+ final response = await service.evaluate(
+ thread.isolate.id!,
+ instance.id!,
+ getterName,
+ );
+ // Convert results to variables.
+ return convertVmResponseToVariable(
+ thread,
+ response,
+ name: getterName,
+ allowCallingToString: index <= maxToStringsPerEvaluation,
+ );
+ }
+
+ variables.addAll(await Future.wait(getterNames.mapIndexed(evaluate)));
+ }
+
+ return variables;
+ } else {
+ // For any other type that we don't produce variables for, return an empty
+ // list.
+ return [];
+ }
+ }
+
+ /// Converts a [vm.Response] into a user-friendly display string.
+ ///
+ /// This may be shown in the collapsed view of a complex type.
+ ///
+ /// If [allowCallingToString] is true, the toString() method may be called on
+ /// the object for a display string.
+ Future<String> convertVmResponseToDisplayString(
+ ThreadInfo thread,
+ vm.Response response, {
+ required bool allowCallingToString,
+ bool includeQuotesAroundString = true,
+ }) async {
+ if (response is vm.InstanceRef) {
+ return convertVmInstanceRefToDisplayString(
+ thread,
+ response,
+ allowCallingToString: allowCallingToString,
+ includeQuotesAroundString: includeQuotesAroundString,
+ );
+ } else if (response is vm.Sentinel) {
+ return '<sentinel>';
+ } else {
+ return '<unknown: ${response.type}>';
+ }
+ }
+
+ /// Converts a [vm.Response] into to a [dap.Variable].
+ ///
+ /// If provided, [name] is used as the variables name (for example the field
+ /// name holding this variable).
+ ///
+ /// If [allowCallingToString] is true, the toString() method may be called on
+ /// the object for a display string.
+ Future<dap.Variable> convertVmResponseToVariable(
+ ThreadInfo thread,
+ vm.Response response, {
+ String? name,
+ required bool allowCallingToString,
+ }) async {
+ if (response is vm.InstanceRef) {
+ // For non-simple variables, store them and produce a new reference that
+ // can be used to access their fields/items/associations.
+ final variablesReference =
+ _isSimpleKind(response.kind) ? 0 : thread.storeData(response);
+
+ return dap.Variable(
+ name: name ?? response.kind.toString(),
+ value: await convertVmResponseToDisplayString(
+ thread,
+ response,
+ allowCallingToString: allowCallingToString,
+ ),
+ variablesReference: variablesReference,
+ );
+ } else if (response is vm.Sentinel) {
+ return dap.Variable(
+ name: '<sentinel>',
+ value: response.valueAsString.toString(),
+ variablesReference: 0,
+ );
+ } else {
+ return dap.Variable(
+ name: '<error>',
+ value: response.runtimeType.toString(),
+ variablesReference: 0,
+ );
+ }
+ }
+
/// Converts a VM Service stack frame to a DAP stack frame.
Future<dap.StackFrame> convertVmToDapStackFrame(
ThreadInfo thread,
@@ -149,4 +370,79 @@
return null;
}
}
+
+ /// Invokes the toString() method on a [vm.InstanceRef] and converts the
+ /// response to a user-friendly display string.
+ ///
+ /// Strings are usually wrapped in quotes to indicate their type. This can be
+ /// controlled with [includeQuotesAroundString] (for example to suppress them
+ /// if the context indicates the user is copying the value to the clipboard).
+ Future<String?> _callToString(
+ ThreadInfo thread,
+ vm.InstanceRef ref, {
+ bool includeQuotesAroundString = true,
+ }) async {
+ final service = _adapter.vmService;
+ if (service == null) {
+ return null;
+ }
+ final result = await service.invoke(
+ thread.isolate.id!,
+ ref.id!,
+ 'toString',
+ [],
+ disableBreakpoints: true,
+ );
+
+ return convertVmResponseToDisplayString(
+ thread,
+ result,
+ allowCallingToString: false,
+ includeQuotesAroundString: includeQuotesAroundString,
+ );
+ }
+
+ /// Collect a list of all getter names for [classRef] and its super classes.
+ ///
+ /// This is used to show/evaluate getters in debug views like hovers and
+ /// variables/watch panes.
+ Future<Set<String>> _getterNamesForClassHierarchy(
+ ThreadInfo thread,
+ vm.ClassRef? classRef,
+ ) async {
+ final getterNames = <String>{};
+ final service = _adapter.vmService;
+ while (service != null && classRef != null) {
+ final classResponse =
+ await service.getObject(thread.isolate.id!, classRef.id!);
+ if (classResponse is! vm.Class) {
+ break;
+ }
+ final functions = classResponse.functions;
+ if (functions != null) {
+ final instanceFields = functions.where((f) =>
+ // TODO(dantup): Update this to use something better that bkonyi is
+ // adding to the protocol.
+ f.json?['_kind'] == 'GetterFunction' &&
+ !(f.isStatic ?? false) &&
+ !(f.isConst ?? false));
+ getterNames.addAll(instanceFields.map((f) => f.name!));
+ }
+
+ classRef = classResponse.superClass;
+ }
+
+ return getterNames;
+ }
+
+ /// Whether [kind] is a simple kind, and does not need to be mapped to a variable.
+ bool _isSimpleKind(String? kind) {
+ return kind == 'String' ||
+ kind == 'Bool' ||
+ kind == 'Int' ||
+ kind == 'Num' ||
+ kind == 'Double' ||
+ kind == 'Null' ||
+ kind == 'Closure';
+ }
}
diff --git a/pkg/dds/test/dap/integration/debug_variables_test.dart b/pkg/dds/test/dap/integration/debug_variables_test.dart
new file mode 100644
index 0000000..e04e36e
--- /dev/null
+++ b/pkg/dds/test/dap/integration/debug_variables_test.dart
@@ -0,0 +1,236 @@
+// 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 'package:test/test.dart';
+
+import 'test_client.dart';
+import 'test_support.dart';
+
+main() {
+ testDap((dap) async {
+ group('debug mode variables', () {
+ test('provides variable list for frames', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ final myVariable = 1;
+ foo();
+}
+
+void foo() {
+ final b = 2;
+ print('Hello!'); // BREAKPOINT
+}
+ ''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(testFile, breakpointLine);
+ final stack = await client.getValidStack(
+ stop.threadId!,
+ startFrame: 0,
+ numFrames: 2,
+ );
+
+ // Check top two frames (in `foo` and in `main`).
+ await client.expectScopeVariables(
+ stack.stackFrames[0].id, // Top frame: foo
+ 'Variables',
+ '''
+ b: 2
+ ''',
+ );
+ await client.expectScopeVariables(
+ stack.stackFrames[1].id, // Second frame: main
+ 'Variables',
+ '''
+ args: List (0 items)
+ myVariable: 1
+ ''',
+ );
+ });
+
+ test('provides simple exception types for frames', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ throw 'my error';
+}
+ ''');
+
+ final stop = await client.hitException(testFile);
+ final stack = await client.getValidStack(
+ stop.threadId!,
+ startFrame: 0,
+ numFrames: 1,
+ );
+ final topFrameId = stack.stackFrames.first.id;
+
+ // Check for an additional Scope named "Exceptions" that includes the
+ // exception.
+ await client.expectScopeVariables(
+ topFrameId,
+ 'Exceptions',
+ '''
+ String: "my error"
+ ''',
+ );
+ });
+
+ test('provides complex exception types frames', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ throw ArgumentError.notNull('args');
+}
+ ''');
+
+ final stop = await client.hitException(testFile);
+ final stack = await client.getValidStack(
+ stop.threadId!,
+ startFrame: 0,
+ numFrames: 1,
+ );
+ final topFrameId = stack.stackFrames.first.id;
+
+ // Check for an additional Scope named "Exceptions" that includes the
+ // exception.
+ await client.expectScopeVariables(
+ topFrameId,
+ 'Exceptions',
+ // TODO(dantup): evaluateNames
+ '''
+ invalidValue: null
+ message: "Must not be null"
+ name: "args"
+ ''',
+ );
+ });
+
+ test('includes simple variable fields', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ final myVariable = DateTime(2000, 1, 1);
+ print('Hello!'); // BREAKPOINT
+}
+ ''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(testFile, breakpointLine);
+ await client.expectLocalVariable(
+ stop.threadId!,
+ expectedName: 'myVariable',
+ expectedDisplayString: 'DateTime',
+ expectedVariables: '''
+ isUtc: false
+ ''',
+ );
+ });
+
+ test('includes variable getters when evaluateGettersInDebugViews=true',
+ () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ final myVariable = DateTime(2000, 1, 1);
+ print('Hello!'); // BREAKPOINT
+}
+ ''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(
+ testFile,
+ breakpointLine,
+ launch: () => client.launch(
+ testFile.path,
+ evaluateGettersInDebugViews: true,
+ ),
+ );
+ await client.expectLocalVariable(
+ stop.threadId!,
+ expectedName: 'myVariable',
+ expectedDisplayString: 'DateTime',
+ expectedVariables: '''
+ day: 1
+ hour: 0
+ isUtc: false
+ microsecond: 0
+ millisecond: 0
+ minute: 0
+ month: 1
+ runtimeType: Type (DateTime)
+ second: 0
+ timeZoneOffset: Duration
+ weekday: 6
+ year: 2000
+ ''',
+ ignore: {
+ // Don't check fields that may very based on timezone as it'll make
+ // these tests fragile, and this isn't really what's being tested.
+ 'timeZoneName',
+ 'microsecondsSinceEpoch',
+ 'millisecondsSinceEpoch',
+ },
+ );
+ });
+
+ test('renders a simple list', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ final myVariable = ["first", "second", "third"];
+ print('Hello!'); // BREAKPOINT
+}
+ ''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(testFile, breakpointLine);
+ await client.expectLocalVariable(
+ stop.threadId!,
+ expectedName: 'myVariable',
+ expectedDisplayString: 'List (3 items)',
+ // TODO(dantup): evaluateNames
+ expectedVariables: '''
+ 0: "first"
+ 1: "second"
+ 2: "third"
+ ''',
+ );
+ });
+
+ test('renders a simple list subset', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ final myVariable = ["first", "second", "third"];
+ print('Hello!'); // BREAKPOINT
+}
+ ''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(testFile, breakpointLine);
+ await client.expectLocalVariable(
+ stop.threadId!,
+ expectedName: 'myVariable',
+ expectedDisplayString: 'List (3 items)',
+ // TODO(dantup): evaluateNames
+ expectedVariables: '''
+ 1: "second"
+ ''',
+ start: 1,
+ count: 1,
+ );
+ });
+
+ test('renders a simple map', () {
+ // TODO(dantup): Implement this (inc evaluateNames)
+ }, skip: true);
+
+ test('renders a simple map subset', () {
+ // TODO(dantup): Implement this (inc evaluateNames)
+ }, skip: true);
+ // These tests can be slow due to starting up the external server process.
+ }, timeout: Timeout.none);
+ });
+}
diff --git a/pkg/dds/test/dap/integration/test_client.dart b/pkg/dds/test/dap/integration/test_client.dart
index 434bef6..f6c2a78 100644
--- a/pkg/dds/test/dap/integration/test_client.dart
+++ b/pkg/dds/test/dap/integration/test_client.dart
@@ -99,9 +99,11 @@
final responses = await Future.wait([
event('initialized'),
sendRequest(InitializeRequestArguments(adapterID: 'test')),
- // TODO(dantup): Support setting exception pause modes.
- // sendRequest(
- // SetExceptionBreakpointsArguments(filters: [exceptionPauseMode])),
+ sendRequest(
+ SetExceptionBreakpointsArguments(
+ filters: [exceptionPauseMode],
+ ),
+ ),
]);
await sendRequest(ConfigurationDoneArguments());
return responses[1] as Response; // Return the initialize response.
@@ -146,6 +148,15 @@
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
@@ -197,6 +208,27 @@
Future<Response> terminate() => sendRequest(TerminateArguments());
+ /// 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.
void _handleMessage(message) {
@@ -292,6 +324,22 @@
return stop;
}
+ /// 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
@@ -330,4 +378,175 @@
return StackTraceResponseBody.fromJson(
response.body as Map<String, Object?>);
}
+
+ /// 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<void> 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, 'Variables');
+ 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.
+ await 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);
+ if (evaluateName != null) {
+ buffer.write(', eval: $evaluateName');
+ }
+ if (indexedVariables != null) {
+ buffer.write(', $indexedVariables items');
+ }
+ if (namedVariables != null) {
+ buffer.write(', $namedVariables named items');
+ }
+ buffer.write(': $value');
+ 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;
+ }
}