[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>
This commit is contained in:
Danny Tuppeny 2021-06-12 23:52:41 +00:00 committed by commit-bot@chromium.org
parent aa0e8a5ca6
commit 4ce805bfa7
6 changed files with 977 additions and 12 deletions

View file

@ -16,6 +16,13 @@ import '../protocol_converter.dart';
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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// 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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// `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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// 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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// 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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
));
}
/// 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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// `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 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
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);
}

View file

@ -139,6 +139,12 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
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 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
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 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
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 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
);
} 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 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
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,

View file

@ -43,6 +43,12 @@ class IsolateManager {
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 @@ class IsolateManager {
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 @@ class IsolateManager {
_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 @@ class IsolateManager {
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 @@ class IsolateManager {
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 @@ class IsolateManager {
}
}
/// 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 {

View file

@ -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 @@ class ProtocolConverter {
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 @@ class ProtocolConverter {
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';
}
}

View file

@ -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);
});
}

View file

@ -99,9 +99,11 @@ class DapTestClient {
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 @@ class DapTestClient {
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 @@ class DapTestClient {
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 @@ extension DapTestClientExtension on DapTestClient {
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 @@ extension DapTestClientExtension on DapTestClient {
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;
}
}