[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;
+  }
 }