blob: 1bb15b3c97db43496c53f8eee156a4f6e76c3d76 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:dds/src/dap/logging.dart';
import 'package:dds/src/dap/protocol_generated.dart';
import 'package:dds/src/dap/protocol_stream.dart';
import 'package:flutter_tools/src/debug_adapters/flutter_adapter_args.dart';
import 'test_server.dart';
/// A helper class to simplify acting as a client for interacting with the
/// [DapTestServer] in tests.
///
/// Methods on this class should map directly to protocol methods. Additional
/// helpers are available in [DapTestClientExtension].
class DapTestClient {
DapTestClient._(
this._channel,
this._logger, {
this.captureVmServiceTraffic = false,
}) {
// Set up a future that will complete when the 'dart.debuggerUris' event is
// emitted by the debug adapter so tests have easy access to it.
vmServiceUri = event('dart.debuggerUris').then<Uri?>((Event event) {
final Map<String, Object?> body = event.body! as Map<String, Object?>;
return Uri.parse(body['vmServiceUri']! as String);
}).catchError((Object? e) => null);
_subscription = _channel.listen(
_handleMessage,
onDone: () {
if (_pendingRequests.isNotEmpty) {
_logger?.call(
'Application terminated without a response to ${_pendingRequests.length} requests');
}
_pendingRequests.forEach((int id, _OutgoingRequest request) => request.completer.completeError(
'Application terminated without a response to request $id (${request.name})'));
_pendingRequests.clear();
},
);
}
final ByteStreamServerChannel _channel;
late final StreamSubscription<String> _subscription;
final Logger? _logger;
final bool captureVmServiceTraffic;
final Map<int, _OutgoingRequest> _pendingRequests = <int, _OutgoingRequest>{};
final StreamController<Event> _eventController = StreamController<Event>.broadcast();
int _seq = 1;
late final Future<Uri?> vmServiceUri;
/// Returns a stream of [OutputEventBody] events.
Stream<OutputEventBody> get outputEvents => events('output')
.map((Event e) => OutputEventBody.fromJson(e.body! as Map<String, Object?>));
/// Returns a stream of [StoppedEventBody] events.
Stream<StoppedEventBody> get stoppedEvents => events('stopped')
.map((Event e) => StoppedEventBody.fromJson(e.body! as Map<String, Object?>));
/// Returns a stream of the string output from [OutputEventBody] events.
Stream<String> get output => outputEvents.map((OutputEventBody output) => output.output);
/// Returns a stream of the string output from [OutputEventBody] events with the category 'stdout'.
Stream<String> get stdoutOutput => outputEvents
.where((OutputEventBody output) => output.category == 'stdout')
.map((OutputEventBody output) => output.output);
/// Sends a custom request to the server and waits for a response.
Future<Response> custom(String name, [Object? args]) async {
return sendRequest(args, overrideCommand: name);
}
/// Returns a Future that completes with the next [event] event.
Future<Event> event(String event) => _eventController.stream.firstWhere(
(Event e) => e.event == event,
orElse: () => throw Exception('Did not receive $event event before stream closed'));
/// Returns a stream for [event] events.
Stream<Event> events(String event) {
return _eventController.stream.where((Event e) => e.event == event);
}
/// Returns a stream of progress events.
Stream<Event> progressEvents() {
const Set<String> progressEvents = <String>{'progressStart', 'progressUpdate', 'progressEnd'};
return _eventController.stream.where((Event e) => progressEvents.contains(e.event));
}
/// Returns a stream of custom 'dart.serviceExtensionAdded' events.
Stream<Map<String, Object?>> get serviceExtensionAddedEvents =>
events('dart.serviceExtensionAdded')
.map((Event e) => e.body! as Map<String, Object?>);
/// Returns a stream of custom 'flutter.serviceExtensionStateChanged' events.
Stream<Map<String, Object?>> get serviceExtensionStateChangedEvents =>
events('flutter.serviceExtensionStateChanged')
.map((Event e) => e.body! as Map<String, Object?>);
/// Returns a stream of 'dart.testNotification' custom events from the
/// package:test JSON reporter.
Stream<Map<String, Object?>> get testNotificationEvents =>
events('dart.testNotification')
.map((Event e) => e.body! as Map<String, Object?>);
/// Sends a custom request to the debug adapter to trigger a Hot Reload.
Future<Response> hotReload() {
return custom('hotReload');
}
/// Sends a custom request to the debug adapter to trigger a Hot Restart.
Future<Response> hotRestart() {
return custom('hotRestart');
}
/// Send an initialize request to the server.
///
/// This occurs before the request to start running/debugging a script and is
/// used to exchange capabilities and send breakpoints and other settings.
Future<Response> initialize({
String exceptionPauseMode = 'None',
bool? supportsRunInTerminalRequest,
bool? supportsProgressReporting,
}) async {
final List<ProtocolMessage> responses = await Future.wait(<Future<ProtocolMessage>>[
event('initialized'),
sendRequest(InitializeRequestArguments(
adapterID: 'test',
supportsRunInTerminalRequest: supportsRunInTerminalRequest,
supportsProgressReporting: supportsProgressReporting,
)),
sendRequest(
SetExceptionBreakpointsArguments(
filters: <String>[exceptionPauseMode],
),
),
]);
await sendRequest(ConfigurationDoneArguments());
return responses[1] as Response; // Return the initialize response.
}
/// Send a launchRequest to the server, asking it to start a Flutter app.
Future<Response> launch({
String? program,
List<String>? args,
List<String>? toolArgs,
String? cwd,
bool? noDebug,
List<String>? additionalProjectPaths,
bool? debugSdkLibraries,
bool? debugExternalPackageLibraries,
bool? evaluateGettersInDebugViews,
bool? evaluateToStringInDebugViews,
}) {
return sendRequest(
FlutterLaunchRequestArguments(
noDebug: noDebug,
program: program,
cwd: cwd,
args: args,
toolArgs: toolArgs,
additionalProjectPaths: additionalProjectPaths,
debugSdkLibraries: debugSdkLibraries,
debugExternalPackageLibraries: debugExternalPackageLibraries,
evaluateGettersInDebugViews: evaluateGettersInDebugViews,
evaluateToStringInDebugViews: evaluateToStringInDebugViews,
// When running out of process, VM Service traffic won't be available
// to the client-side logger, so force logging on which sends VM Service
// traffic in a custom event.
sendLogsToClient: captureVmServiceTraffic,
),
// We can't automatically pick the command when using a custom type
// (FlutterLaunchRequestArguments).
overrideCommand: 'launch',
);
}
/// Send an attachRequest to the server, asking it to attach to an already-running Flutter app.
Future<Response> attach({
List<String>? toolArgs,
String? vmServiceUri,
String? cwd,
List<String>? additionalProjectPaths,
bool? debugSdkLibraries,
bool? debugExternalPackageLibraries,
bool? evaluateGettersInDebugViews,
bool? evaluateToStringInDebugViews,
}) {
return sendRequest(
FlutterAttachRequestArguments(
cwd: cwd,
toolArgs: toolArgs,
vmServiceUri: vmServiceUri,
additionalProjectPaths: additionalProjectPaths,
debugSdkLibraries: debugSdkLibraries,
debugExternalPackageLibraries: debugExternalPackageLibraries,
evaluateGettersInDebugViews: evaluateGettersInDebugViews,
evaluateToStringInDebugViews: evaluateToStringInDebugViews,
// When running out of process, VM Service traffic won't be available
// to the client-side logger, so force logging on which sends VM Service
// traffic in a custom event.
sendLogsToClient: captureVmServiceTraffic,
),
// We can't automatically pick the command when using a custom type
// (FlutterAttachRequestArguments).
overrideCommand: 'attach',
);
}
/// Sends an arbitrary request to the server.
///
/// Returns a Future that completes when the server returns a corresponding
/// response.
Future<Response> sendRequest(Object? arguments,
{bool allowFailure = false, String? overrideCommand}) {
final String command = overrideCommand ?? commandTypes[arguments.runtimeType]!;
final Request request =
Request(seq: _seq++, command: command, arguments: arguments);
final Completer<Response> completer = Completer<Response>();
_pendingRequests[request.seq] =
_OutgoingRequest(completer, command, allowFailure);
_channel.sendRequest(request);
return completer.future;
}
/// Returns a Future that completes with the next serviceExtensionAdded
/// event for [extension].
Future<Map<String, Object?>> serviceExtensionAdded(String extension) => serviceExtensionAddedEvents.firstWhere(
(Map<String, Object?> body) => body['extensionRPC'] == extension,
orElse: () => throw Exception('Did not receive $extension extension added event before stream closed'));
/// Returns a Future that completes with the next serviceExtensionStateChanged
/// event for [extension].
Future<Map<String, Object?>> serviceExtensionStateChanged(String extension) => serviceExtensionStateChangedEvents.firstWhere(
(Map<String, Object?> body) => body['extension'] == extension,
orElse: () => throw Exception('Did not receive $extension extension state changed event before stream closed'));
/// Initializes the debug adapter and launches [program]/[cwd] or calls the
/// custom [launch] method.
Future<void> start({
String? program,
String? cwd,
String exceptionPauseMode = 'None',
Future<Object?> Function()? launch,
}) {
return Future.wait(<Future<Object?>>[
initialize(exceptionPauseMode: exceptionPauseMode),
launch?.call() ?? this.launch(program: program, cwd: cwd),
], eagerError: true);
}
Future<void> stop() async {
_channel.close();
await _subscription.cancel();
}
Future<Response> terminate() => sendRequest(TerminateArguments());
/// Handles an incoming message from the server, completing the relevant request
/// of raising the appropriate event.
Future<void> _handleMessage(Object? message) async {
if (message is Response) {
final _OutgoingRequest? pendingRequest = _pendingRequests.remove(message.requestSeq);
if (pendingRequest == null) {
return;
}
final Completer<Response> completer = pendingRequest.completer;
if (message.success || pendingRequest.allowFailure) {
completer.complete(message);
} else {
completer.completeError(message);
}
} else if (message is Event && !_eventController.isClosed) {
_eventController.add(message);
// When we see a terminated event, close the event stream so if any
// tests are waiting on something that will never come, they fail at
// a useful location.
if (message.event == 'terminated') {
unawaited(_eventController.close());
}
}
}
/// Creates a [DapTestClient] that connects the server listening on
/// [host]:[port].
static Future<DapTestClient> connect(
DapTestServer server, {
bool captureVmServiceTraffic = false,
Logger? logger,
}) async {
final ByteStreamServerChannel channel = ByteStreamServerChannel(server.stream, server.sink, logger);
return DapTestClient._(channel, logger,
captureVmServiceTraffic: captureVmServiceTraffic);
}
}
/// Useful events produced by the debug adapter during a debug session.
class TestEvents {
TestEvents({
required this.output,
required this.testNotifications,
});
final List<OutputEventBody> output;
final List<Map<String, Object?>> testNotifications;
}
class _OutgoingRequest {
_OutgoingRequest(this.completer, this.name, this.allowFailure);
final Completer<Response> completer;
final String name;
final bool allowFailure;
}
/// Additional helper method for tests to simplify interaction with [DapTestClient].
///
/// Unlike the methods on [DapTestClient] these methods might not map directly
/// onto protocol methods. They may call multiple protocol methods and/or
/// simplify assertion specific conditions/results.
extension DapTestClientExtension on DapTestClient {
/// Collects all output events until the program terminates.
///
/// These results include all events in the order they are received, including
/// console, stdout and stderr.
///
/// Only one of [start] or [launch] may be provided. Use [start] to customise
/// the whole start of the session (including initialise) or [launch] to only
/// customise the [launchRequest].
Future<List<OutputEventBody>> collectAllOutput({
String? program,
String? cwd,
Future<void> Function()? start,
Future<Response> Function()? launch,
bool skipInitialPubGetOutput = true
}) async {
assert(
start == null || launch == null,
'Only one of "start" or "launch" may be provided',
);
final Future<List<OutputEventBody>> outputEventsFuture = outputEvents.toList();
// Don't await these, in case they don't complete (eg. an error prevents
// the app from starting).
if (start != null) {
unawaited(start());
} else {
unawaited(this.start(program: program, cwd: cwd, launch: launch));
}
final List<OutputEventBody> output = await outputEventsFuture;
// TODO(dantup): Integration tests currently trigger "flutter pub get" at
// the start due to some timestamp manipulation writing the pubspec.
// It may be possible to remove this if
// https://github.com/flutter/flutter/pull/91300 lands.
return skipInitialPubGetOutput
? output.skipWhile((OutputEventBody output) => output.output.startsWith('Running "flutter pub get"')).toList()
: output;
}
/// Collects all output and test events until the program terminates.
///
/// These results include all events in the order they are received, including
/// console, stdout, stderr and test notifications from the test JSON reporter.
///
/// Only one of [start] or [launch] may be provided. Use [start] to customise
/// the whole start of the session (including initialise) or [launch] to only
/// customise the [launchRequest].
Future<TestEvents> collectTestOutput({
String? program,
String? cwd,
Future<Response> Function()? start,
Future<Object?> Function()? launch,
}) async {
assert(
start == null || launch == null,
'Only one of "start" or "launch" may be provided',
);
final Future<List<OutputEventBody>> outputEventsFuture = outputEvents.toList();
final Future<List<Map<String, Object?>>> testNotificationEventsFuture = testNotificationEvents.toList();
if (start != null) {
await start();
} else {
await this.start(program: program, cwd: cwd, launch: launch);
}
return TestEvents(
output: await outputEventsFuture,
testNotifications: await testNotificationEventsFuture,
);
}
/// Sets a breakpoint at [line] in [file].
Future<void> setBreakpoint(String filePath, int line) async {
await sendRequest(
SetBreakpointsArguments(
source: Source(path: filePath),
breakpoints: <SourceBreakpoint>[
SourceBreakpoint(line: line),
],
),
);
}
/// Sends a continue request for the given thread.
///
/// Returns a Future that completes when the server returns a corresponding
/// response.
Future<Response> continue_(int threadId) =>
sendRequest(ContinueArguments(threadId: threadId));
/// Clears breakpoints in [file].
Future<void> clearBreakpoints(String filePath) async {
await sendRequest(
SetBreakpointsArguments(
source: Source(path: filePath),
breakpoints: <SourceBreakpoint>[],
),
);
}
}