Flutter attach (#18677)
Add `flutter attach` command. This command allows Flutter to connect to applications that haven't been launched using `flutter run`.
This commit is contained in:
parent
5fbec7b812
commit
d248725e15
145
dev/devicelab/bin/tasks/flutter_attach_test.dart
Normal file
145
dev/devicelab/bin/tasks/flutter_attach_test.dart
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
// Copyright (c) 2018 The Chromium 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 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:flutter_devicelab/framework/adb.dart';
|
||||||
|
import 'package:flutter_devicelab/framework/framework.dart';
|
||||||
|
import 'package:flutter_devicelab/framework/utils.dart';
|
||||||
|
|
||||||
|
Future<void> testReload(Process process, { Future<void> Function() onListening }) async {
|
||||||
|
section('Testing hot reload, restart and quit');
|
||||||
|
final Completer<Null> listening = new Completer<Null>();
|
||||||
|
final Completer<Null> ready = new Completer<Null>();
|
||||||
|
final Completer<Null> reloaded = new Completer<Null>();
|
||||||
|
final Completer<Null> restarted = new Completer<Null>();
|
||||||
|
final Completer<Null> finished = new Completer<Null>();
|
||||||
|
final List<String> stdout = <String>[];
|
||||||
|
final List<String> stderr = <String>[];
|
||||||
|
|
||||||
|
if (onListening == null)
|
||||||
|
listening.complete();
|
||||||
|
|
||||||
|
int exitCode;
|
||||||
|
process.stdout
|
||||||
|
.transform(utf8.decoder)
|
||||||
|
.transform(const LineSplitter())
|
||||||
|
.listen((String line) {
|
||||||
|
print('attach:stdout: $line');
|
||||||
|
stdout.add(line);
|
||||||
|
if (line.contains('Listening') && onListening != null) {
|
||||||
|
listening.complete(onListening());
|
||||||
|
}
|
||||||
|
if (line.contains('To quit, press "q".'))
|
||||||
|
ready.complete();
|
||||||
|
if (line.contains('Reloaded '))
|
||||||
|
reloaded.complete();
|
||||||
|
if (line.contains('Restarted app in '))
|
||||||
|
restarted.complete();
|
||||||
|
if (line.contains('Application finished'))
|
||||||
|
finished.complete();
|
||||||
|
});
|
||||||
|
process.stderr
|
||||||
|
.transform(utf8.decoder)
|
||||||
|
.transform(const LineSplitter())
|
||||||
|
.listen((String line) {
|
||||||
|
print('run:stderr: $line');
|
||||||
|
stdout.add(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.exitCode.then((int processExitCode) { exitCode = processExitCode; });
|
||||||
|
|
||||||
|
Future<dynamic> eventOrExit(Future<Null> event) {
|
||||||
|
return Future.any<dynamic>(<Future<dynamic>>[ event, process.exitCode ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await eventOrExit(listening.future);
|
||||||
|
await eventOrExit(ready.future);
|
||||||
|
|
||||||
|
if (exitCode != null)
|
||||||
|
throw 'Failed to attach to test app; command unexpected exited, with exit code $exitCode.';
|
||||||
|
|
||||||
|
process.stdin.write('r');
|
||||||
|
process.stdin.flush();
|
||||||
|
await eventOrExit(reloaded.future);
|
||||||
|
process.stdin.write('R');
|
||||||
|
process.stdin.flush();
|
||||||
|
await eventOrExit(restarted.future);
|
||||||
|
process.stdin.write('q');
|
||||||
|
process.stdin.flush();
|
||||||
|
await eventOrExit(finished.future);
|
||||||
|
|
||||||
|
await process.exitCode;
|
||||||
|
|
||||||
|
if (stderr.isNotEmpty)
|
||||||
|
throw 'flutter attach had output on standard error.';
|
||||||
|
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw 'exit code was not 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const String kAppId = 'com.yourcompany.integration_ui';
|
||||||
|
const String kActivityId = '$kAppId/com.yourcompany.integration_ui.MainActivity';
|
||||||
|
|
||||||
|
task(() async {
|
||||||
|
final AndroidDevice device = await devices.workingDevice;
|
||||||
|
await device.unlock();
|
||||||
|
final Directory appDir = dir(path.join(flutterDirectory.path, 'dev/integration_tests/ui'));
|
||||||
|
await inDirectory(appDir, () async {
|
||||||
|
section('Build: starting...');
|
||||||
|
final String buildStdout = await eval(
|
||||||
|
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
||||||
|
<String>['--suppress-analytics', 'build', 'apk', '--debug', 'lib/main.dart'],
|
||||||
|
);
|
||||||
|
final String lastLine = buildStdout.split('\n').last;
|
||||||
|
final RegExp builtRegExp = new RegExp(r'Built (.+)( \(|\.$)');
|
||||||
|
final String apkPath = builtRegExp.firstMatch(lastLine)[1];
|
||||||
|
|
||||||
|
section('Installing $apkPath');
|
||||||
|
|
||||||
|
await device.adb(<String>['install', apkPath]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
section('Launching attach.');
|
||||||
|
Process attachProcess = await startProcess(
|
||||||
|
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
||||||
|
<String>['--suppress-analytics', 'attach', '-d', device.deviceId],
|
||||||
|
isBot: false, // we just want to test the output, not have any debugging info
|
||||||
|
);
|
||||||
|
|
||||||
|
await testReload(attachProcess, onListening: () async {
|
||||||
|
section('Launching app.');
|
||||||
|
await device.shellExec('am', <String>['start', '-n', kActivityId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
final String currentTime = (await device.shellEval('date', <String>['"+%F %R:%S.000"'])).trim();
|
||||||
|
print('Start time on device: $currentTime');
|
||||||
|
section('Launching app');
|
||||||
|
await device.shellExec('am', <String>['start', '-n', kActivityId]);
|
||||||
|
|
||||||
|
final String observatoryLine = await device.adb(<String>['logcat', '-e', 'Observatory listening on http:', '-m', '1', '-T', currentTime]);
|
||||||
|
print('Found observatory line: $observatoryLine');
|
||||||
|
final String observatoryPort = new RegExp(r'Observatory listening on http://.*:([0-9]+)').firstMatch(observatoryLine)[1];
|
||||||
|
print('Extracted observatory port: $observatoryPort');
|
||||||
|
|
||||||
|
section('Launching attach with given port.');
|
||||||
|
attachProcess = await startProcess(
|
||||||
|
path.join(flutterDirectory.path, 'bin', 'flutter'),
|
||||||
|
<String>['--suppress-analytics', 'attach', '--debug-port', observatoryPort, '-d', device.deviceId],
|
||||||
|
isBot: false, // we just want to test the output, not have any debugging info
|
||||||
|
);
|
||||||
|
await testReload(attachProcess);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
section('Uninstalling');
|
||||||
|
await device.adb(<String>['uninstall', kAppId]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return new TaskResult.success(null);
|
||||||
|
});
|
||||||
|
}
|
@ -248,12 +248,17 @@ class AndroidDevice implements Device {
|
|||||||
|
|
||||||
/// Executes [command] on `adb shell` and returns its exit code.
|
/// Executes [command] on `adb shell` and returns its exit code.
|
||||||
Future<Null> shellExec(String command, List<String> arguments, { Map<String, String> environment }) async {
|
Future<Null> shellExec(String command, List<String> arguments, { Map<String, String> environment }) async {
|
||||||
await exec(adbPath, <String>['shell', command]..addAll(arguments), environment: environment, canFail: false);
|
await adb(<String>['shell', command]..addAll(arguments), environment: environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes [command] on `adb shell` and returns its standard output as a [String].
|
/// Executes [command] on `adb shell` and returns its standard output as a [String].
|
||||||
Future<String> shellEval(String command, List<String> arguments, { Map<String, String> environment }) {
|
Future<String> shellEval(String command, List<String> arguments, { Map<String, String> environment }) {
|
||||||
return eval(adbPath, <String>['shell', command]..addAll(arguments), environment: environment, canFail: false);
|
return adb(<String>['shell', command]..addAll(arguments), environment: environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `adb` with the given [arguments], selecting this device.
|
||||||
|
Future<String> adb(List<String> arguments, { Map<String, String> environment }) {
|
||||||
|
return eval(adbPath, <String>['-s', deviceId]..addAll(arguments), environment: environment, canFail: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -269,6 +269,12 @@ tasks:
|
|||||||
stage: devicelab
|
stage: devicelab
|
||||||
required_agent_capabilities: ["linux/android"]
|
required_agent_capabilities: ["linux/android"]
|
||||||
|
|
||||||
|
flutter_attach_test:
|
||||||
|
description: >
|
||||||
|
Tests the `flutter attach` command.
|
||||||
|
stage: devicelab
|
||||||
|
required_agent_capabilities: ["linux/android"]
|
||||||
|
|
||||||
# iOS on-device tests
|
# iOS on-device tests
|
||||||
|
|
||||||
flavors_test_ios:
|
flavors_test_ios:
|
||||||
|
@ -6,6 +6,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'runner.dart' as runner;
|
import 'runner.dart' as runner;
|
||||||
import 'src/commands/analyze.dart';
|
import 'src/commands/analyze.dart';
|
||||||
|
import 'src/commands/attach.dart';
|
||||||
import 'src/commands/build.dart';
|
import 'src/commands/build.dart';
|
||||||
import 'src/commands/channel.dart';
|
import 'src/commands/channel.dart';
|
||||||
import 'src/commands/clean.dart';
|
import 'src/commands/clean.dart';
|
||||||
@ -48,6 +49,7 @@ Future<Null> main(List<String> args) async {
|
|||||||
|
|
||||||
await runner.run(args, <FlutterCommand>[
|
await runner.run(args, <FlutterCommand>[
|
||||||
new AnalyzeCommand(verboseHelp: verboseHelp),
|
new AnalyzeCommand(verboseHelp: verboseHelp),
|
||||||
|
new AttachCommand(verboseHelp: verboseHelp),
|
||||||
new BuildCommand(verboseHelp: verboseHelp),
|
new BuildCommand(verboseHelp: verboseHelp),
|
||||||
new ChannelCommand(verboseHelp: verboseHelp),
|
new ChannelCommand(verboseHelp: verboseHelp),
|
||||||
new CleanCommand(),
|
new CleanCommand(),
|
||||||
|
105
packages/flutter_tools/lib/src/commands/attach.dart
Normal file
105
packages/flutter_tools/lib/src/commands/attach.dart
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// Copyright 2018 The Chromium 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 '../base/common.dart';
|
||||||
|
import '../base/io.dart';
|
||||||
|
import '../cache.dart';
|
||||||
|
import '../device.dart';
|
||||||
|
import '../protocol_discovery.dart';
|
||||||
|
import '../resident_runner.dart';
|
||||||
|
import '../run_hot.dart';
|
||||||
|
import '../runner/flutter_command.dart';
|
||||||
|
|
||||||
|
final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
|
||||||
|
|
||||||
|
/// A Flutter-command that attaches to applications that have been launched
|
||||||
|
/// without `flutter run`.
|
||||||
|
///
|
||||||
|
/// With an application already running, a HotRunner can be attached to it
|
||||||
|
/// with:
|
||||||
|
/// ```
|
||||||
|
/// $ flutter attach --debug-port 12345
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Alternatively, the attach command can start listening and scan for new
|
||||||
|
/// programs that become active:
|
||||||
|
/// ```
|
||||||
|
/// $ flutter attach
|
||||||
|
/// ```
|
||||||
|
/// As soon as a new observatory is detected the command attaches to it and
|
||||||
|
/// enables hot reloading.
|
||||||
|
class AttachCommand extends FlutterCommand {
|
||||||
|
AttachCommand({bool verboseHelp = false}) {
|
||||||
|
addBuildModeFlags(defaultToRelease: false);
|
||||||
|
argParser.addOption(
|
||||||
|
'debug-port',
|
||||||
|
help: 'Local port where the observatory is listening.',
|
||||||
|
);
|
||||||
|
argParser.addFlag(
|
||||||
|
'preview-dart-2',
|
||||||
|
defaultsTo: true,
|
||||||
|
hide: !verboseHelp,
|
||||||
|
help: 'Preview Dart 2.0 functionality.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String name = 'attach';
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String description = 'Attach to a running application.';
|
||||||
|
|
||||||
|
int get observatoryPort {
|
||||||
|
if (argResults['debug-port'] == null)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
return int.parse(argResults['debug-port']);
|
||||||
|
} catch (error) {
|
||||||
|
throwToolExit('Invalid port for `--debug-port`: $error');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Null> runCommand() async {
|
||||||
|
Cache.releaseLockEarly();
|
||||||
|
|
||||||
|
await _validateArguments();
|
||||||
|
|
||||||
|
final Device device = await findTargetDevice();
|
||||||
|
final int devicePort = observatoryPort;
|
||||||
|
Uri observatoryUri;
|
||||||
|
if (devicePort == null) {
|
||||||
|
ProtocolDiscovery observatoryDiscovery;
|
||||||
|
try {
|
||||||
|
observatoryDiscovery = new ProtocolDiscovery.observatory(
|
||||||
|
device.getLogReader(), portForwarder: device.portForwarder);
|
||||||
|
print('Listening.');
|
||||||
|
observatoryUri = await observatoryDiscovery.uri;
|
||||||
|
} finally {
|
||||||
|
await observatoryDiscovery?.cancel();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final int localPort = await device.portForwarder.forward(devicePort);
|
||||||
|
observatoryUri = Uri.parse('http://$ipv4Loopback:$localPort/');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final FlutterDevice flutterDevice =
|
||||||
|
new FlutterDevice(device, trackWidgetCreation: false, previewDart2: argResults['preview-dart-2']);
|
||||||
|
flutterDevice.observatoryUris = <Uri>[ observatoryUri ];
|
||||||
|
final HotRunner hotRunner = new HotRunner(
|
||||||
|
<FlutterDevice>[flutterDevice],
|
||||||
|
debuggingOptions: new DebuggingOptions.enabled(getBuildInfo()),
|
||||||
|
packagesFilePath: globalResults['packages'],
|
||||||
|
);
|
||||||
|
await hotRunner.attach();
|
||||||
|
} finally {
|
||||||
|
device.portForwarder.forwardedPorts.forEach(device.portForwarder.unforward);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _validateArguments() async {}
|
||||||
|
}
|
74
packages/flutter_tools/test/commands/attach_test.dart
Normal file
74
packages/flutter_tools/test/commands/attach_test.dart
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2018 The Chromium 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:flutter_tools/src/cache.dart';
|
||||||
|
import 'package:flutter_tools/src/commands/attach.dart';
|
||||||
|
import 'package:flutter_tools/src/device.dart';
|
||||||
|
import 'package:mockito/mockito.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../src/common.dart';
|
||||||
|
import '../src/context.dart';
|
||||||
|
import '../src/mocks.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('attach', () {
|
||||||
|
setUpAll(() {
|
||||||
|
Cache.disableLocking();
|
||||||
|
});
|
||||||
|
|
||||||
|
testUsingContext('finds observatory port and forwards', () async {
|
||||||
|
const int devicePort = 499;
|
||||||
|
const int hostPort = 42;
|
||||||
|
final MockDeviceLogReader mockLogReader = new MockDeviceLogReader();
|
||||||
|
final MockPortForwarder portForwarder = new MockPortForwarder();
|
||||||
|
final MockAndroidDevice device = new MockAndroidDevice();
|
||||||
|
when(device.getLogReader()).thenAnswer((_) {
|
||||||
|
// Now that the reader is used, start writing messages to it.
|
||||||
|
Timer.run(() {
|
||||||
|
mockLogReader.addLine('Foo');
|
||||||
|
mockLogReader.addLine('Observatory listening on http://127.0.0.1:$devicePort');
|
||||||
|
});
|
||||||
|
|
||||||
|
return mockLogReader;
|
||||||
|
});
|
||||||
|
when(device.portForwarder).thenReturn(portForwarder);
|
||||||
|
when(portForwarder.forward(devicePort, hostPort: anyNamed('hostPort'))).thenAnswer((_) async => hostPort);
|
||||||
|
when(portForwarder.forwardedPorts).thenReturn(<ForwardedPort>[new ForwardedPort(hostPort, devicePort)]);
|
||||||
|
when(portForwarder.unforward).thenReturn((ForwardedPort _) async => null);
|
||||||
|
testDeviceManager.addDevice(device);
|
||||||
|
|
||||||
|
final AttachCommand command = new AttachCommand();
|
||||||
|
|
||||||
|
await createTestCommandRunner(command).run(<String>['attach']);
|
||||||
|
|
||||||
|
verify(portForwarder.forward(devicePort, hostPort: anyNamed('hostPort'))).called(1);
|
||||||
|
|
||||||
|
mockLogReader.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testUsingContext('forwards to given port', () async {
|
||||||
|
const int devicePort = 499;
|
||||||
|
const int hostPort = 42;
|
||||||
|
final MockPortForwarder portForwarder = new MockPortForwarder();
|
||||||
|
final MockAndroidDevice device = new MockAndroidDevice();
|
||||||
|
|
||||||
|
when(device.portForwarder).thenReturn(portForwarder);
|
||||||
|
when(portForwarder.forward(devicePort)).thenAnswer((_) async => hostPort);
|
||||||
|
when(portForwarder.forwardedPorts).thenReturn(<ForwardedPort>[new ForwardedPort(hostPort, devicePort)]);
|
||||||
|
when(portForwarder.unforward).thenReturn((ForwardedPort _) async => null);
|
||||||
|
testDeviceManager.addDevice(device);
|
||||||
|
|
||||||
|
final AttachCommand command = new AttachCommand();
|
||||||
|
|
||||||
|
await createTestCommandRunner(command).run(<String>['attach', '--debug-port', '$devicePort']);
|
||||||
|
|
||||||
|
verify(portForwarder.forward(devicePort)).called(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockPortForwarder extends Mock implements DevicePortForwarder {}
|
Loading…
x
Reference in New Issue
Block a user