mirror of
https://github.com/dart-lang/sdk
synced 2024-09-16 00:39:49 +00:00
[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:
parent
aa0e8a5ca6
commit
4ce805bfa7
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
236
pkg/dds/test/dap/integration/debug_variables_test.dart
Normal file
236
pkg/dds/test/dap/integration/debug_variables_test.dart
Normal 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);
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue