blob: a24dd8ab5aa1bae61cf025a90b6c199f1125d959 [file] [log] [blame]
// Copyright 2024 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
/// @docImport 'package:devtools_app/devtools_app.dart';
library;
import 'dart:async';
import 'package:devtools_app/src/shared/editor/api_classes.dart';
import 'package:dtd/dtd.dart';
import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// A class that can simulate the part of an Editor over DTD used for testing
/// (both automated and manually via the "mock editor" stager app).
///
/// Since this class is intended to represent what real IDEs may do, any changes
/// made here to match changes made to [EditorClient] should be considered
/// carefully to ensure they are not breaking changes to already-shipped
/// editors.
class SimulatedEditor {
SimulatedEditor(this._dtdUri) {
// Set up some default devices.
connectDevices();
// Connect editor automatically at launch.
unawaited(connectEditor());
}
/// The URI of the DTD instance we are connecting/connected to.
final Uri _dtdUri;
/// The [DartToolingDaemon] we are connected to.
///
/// `null` if the connection has not yet been established.
DartToolingDaemon? _dtd;
/// A controller for emitting to [log].
final _logger = StreamController<String>();
/// A stream of protocol traffic between the editor and DTD (or postMessage
/// API).
Stream<String> get log => _logger.stream;
Future<void> _registerServices() {
return Future.wait([
_registerService(EditorMethod.getDevices, _getDevices),
_registerService(EditorMethod.selectDevice, _selectDevice),
_registerService(EditorMethod.getDebugSessions, _getDebugSessions),
_registerService(EditorMethod.hotReload, _hotReload),
_registerService(EditorMethod.hotRestart, _hotRestart),
_registerService(
EditorMethod.openDevToolsPage,
_openDevToolsPage,
capabilities: {Field.supportsForceExternal: true},
),
_registerService(EditorMethod.enablePlatformType, _enablePlatformType),
]);
}
/// Whether the editor is currently connected to DTD.
bool get connected => _dtd != null;
/// Simulates an editor being connected to DTD.
Future<void> connectEditor() async {
_logger.add('Connecting editor to $_dtdUri');
final rawChannel = WebSocketChannel.connect(_dtdUri);
await rawChannel.ready;
final rawStringChannel = rawChannel.cast<String>();
/// A helper to create a function that can be used in stream.map() to log
/// traffic with a prefix.
String Function(String) logTraffic(String prefix) {
return (String s) {
_logger.add('$prefix $s'.trim());
return s;
};
}
// Create a channel that logs the data going through it.
final loggedInput = rawStringChannel.stream.map(logTraffic('==>'));
final loggedOutputController = StreamController<String>();
unawaited(
loggedOutputController.stream
.map(logTraffic('<=='))
.pipe(rawStringChannel.sink),
);
final loggingChannel = StreamChannel<String>(
loggedInput,
loggedOutputController.sink,
);
_dtd = DartToolingDaemon.fromStreamChannel(loggingChannel);
await _registerServices();
}
/// Simulates an editor being discconnected from DTD.
Future<void> disconnectEditor() async {
_logger.add('Disconnecting editor...');
await close();
_logger.add('Disconnected!');
}
/// Close the connection to DTD.
Future<void> close() async {
await _dtd?.close();
_dtd = null;
}
Future<void> _registerService(
EditorMethod method,
DTDServiceCallback callback, {
Map<String, Object?>? capabilities,
}) async {
await _dtd?.registerService(
editorServiceName,
method.name,
callback,
capabilities: capabilities,
);
}
static const _successResponse = {'type': 'Success'};
Future<Map<String, Object?>> _getDevices(Parameters params) async {
final result = await getDevices();
return Future.value({'type': 'GetDevicesResult', ...result.toJson()});
}
Future<Map<String, Object?>> _selectDevice(Parameters params) async {
await selectDevice(params[Field.deviceId].valueOr(null) as String?);
return _successResponse;
}
Future<Map<String, Object?>> _getDebugSessions(Parameters params) async {
final result = await getDebugSessions();
return Future.value({'type': 'GetDebugSessionsResult', ...result.toJson()});
}
Future<Map<String, Object?>> _hotReload(Parameters params) async {
await hotReload(params[Field.debugSessionId].asString);
return _successResponse;
}
Future<Map<String, Object?>> _hotRestart(Parameters params) async {
await hotRestart(params[Field.debugSessionId].asString);
return _successResponse;
}
Future<Map<String, Object?>> _openDevToolsPage(Parameters params) async {
await openDevToolsPage(
params[Field.debugSessionId].valueOr(null) as String?,
params[Field.page].valueOr(null) as String?,
params[Field.forceExternal].valueOr(null) as bool? ?? false,
params[Field.requiresDebugSession].valueOr(null) as bool? ?? false,
params[Field.prefersDebugSession].valueOr(null) as bool? ?? false,
);
return _successResponse;
}
Future<Map<String, Object?>> _enablePlatformType(Parameters params) async {
await enablePlatformType(params[Field.platformType].asString);
return _successResponse;
}
Future<void> _postEvent(EditorEvent params) async {
await _dtd?.postEvent(editorStreamName, params.kind.name, params.toJson());
}
void sendDeviceAdded(EditorDevice device) async {
await _postEvent(DeviceAddedEvent(device: device));
}
void sendDeviceChanged(EditorDevice device) async {
await _postEvent(DeviceChangedEvent(device: device));
}
void sendDeviceRemoved(EditorDevice device) async {
await _postEvent(DeviceRemovedEvent(deviceId: device.id));
}
void sendDeviceSelected(EditorDevice? device) async {
await _postEvent(DeviceSelectedEvent(deviceId: device?.id));
}
void sendDebugSessionStarted(EditorDebugSession debugSession) async {
await _postEvent(DebugSessionStartedEvent(debugSession: debugSession));
}
void sendDebugSessionChanged(EditorDebugSession debugSession) async {
await _postEvent(DebugSessionChangedEvent(debugSession: debugSession));
}
void sendDebugSessionStopped(EditorDebugSession debugSession) async {
await _postEvent(DebugSessionStoppedEvent(debugSessionId: debugSession.id));
}
/// The current of devices simulated as connected.
final devices = <String, EditorDevice>{};
/// The current of debug sessions simulated as running.
final debugSessions = <String, EditorDebugSession>{};
/// The number of the next debug session to start.
var _nextDebugSessionNumber = 1;
/// The current device simulated as selected.
String? selectedDeviceId;
/// Simulates devices being connected in the IDE by notifying the embedded
/// panel about a set of test devices.
void connectDevices() {
devices.clear();
for (final device in stubbedDevices) {
devices[device.id] = device;
}
devices.values.forEach(sendDeviceAdded);
sendDeviceSelected(devices.values.lastOrNull);
}
/// Simulates devices being disconnected in the IDE by notifying the embedded
/// panel about a set of test devices.
void disconnectDevices() {
sendDeviceSelected(null);
final devicesToRemove = devices.values.toList();
devices.clear();
devicesToRemove.forEach(sendDeviceRemoved);
}
/// Simulates a debug session starting by sending debug session update events.
void startSession({
required String debuggerType,
required String deviceId,
String? flutterMode,
}) {
final sessionNum = _nextDebugSessionNumber++;
final sessionId = 'debug-$sessionNum';
final session = EditorDebugSession(
id: 'debug-$sessionNum',
name: 'Session $sessionNum ($deviceId)',
vmServiceUri: 'ws://127.0.0.1:1234/ws',
flutterMode: flutterMode,
flutterDeviceId: deviceId,
debuggerType: debuggerType,
projectRootPath: '/mock/root/path',
);
debugSessions[sessionId] = session;
sendDebugSessionStarted(session);
}
/// Simulates ending all active debug sessions.
void stopAllSessions() {
final sessionsToRemove = debugSessions.values.toList();
debugSessions.clear();
sessionsToRemove.forEach(sendDebugSessionStopped);
}
FutureOr<GetDevicesResult> getDevices() {
return GetDevicesResult(
devices: devices.values.toList(),
selectedDeviceId: selectedDeviceId,
);
}
FutureOr<GetDebugSessionsResult> getDebugSessions() {
return GetDebugSessionsResult(debugSessions: debugSessions.values.toList());
}
FutureOr<void> selectDevice(String? deviceId) {
// Find the device the client asked us to select, select it, and then
// send an event back to confirm it is now the selected device.
final device = devices[deviceId];
selectedDeviceId = deviceId;
sendDeviceSelected(device);
}
FutureOr<void> enablePlatformType(String platformType) {
for (var MapEntry(key: id, value: device) in devices.entries) {
if (!device.supported && device.platformType == platformType) {
device =
devices[id] = EditorDevice.fromJson({
...device.toJson(),
'supported': true,
});
sendDeviceChanged(device);
}
}
}
FutureOr<void> hotReload(String _) {}
FutureOr<void> hotRestart(String _) {}
FutureOr<void> openDevToolsPage(
String? _,
String? __,
bool ___,
bool ____,
bool _____,
) {}
}
/// A set of mock devices that can be presented for testing.
final stubbedDevices = [
EditorDevice(
id: 'macos',
name: 'Mac',
category: 'desktop',
emulator: false,
emulatorId: null,
ephemeral: false,
platform: 'darwin-x64',
platformType: 'macos',
supported: true,
),
EditorDevice(
id: 'myPhone',
name: 'My Android Phone',
category: 'mobile',
emulator: false,
emulatorId: null,
ephemeral: true,
platform: 'android-x64',
platformType: 'android',
supported: true,
),
EditorDevice(
id: 'chrome',
name: 'Chrome',
category: 'web',
emulator: false,
emulatorId: null,
ephemeral: true,
platform: 'web-javascript',
platformType: 'web',
supported: true,
),
EditorDevice(
id: 'web-server',
name: 'Web Server',
category: 'web',
emulator: false,
emulatorId: null,
ephemeral: true,
platform: 'web-javascript',
platformType: 'web',
supported: true,
),
EditorDevice(
id: 'my-unsupported-platform',
name: 'My Unsupported Platform',
category: 'desktop',
emulator: false,
emulatorId: null,
ephemeral: true,
platform: 'platform-unknown',
platformType: 'unknown',
supported: false,
),
];