diff --git a/dev/devicelab/bin/tasks/flutter_attach_test.dart b/dev/devicelab/bin/tasks/flutter_attach_test.dart new file mode 100644 index 0000000000..e1333c7021 --- /dev/null +++ b/dev/devicelab/bin/tasks/flutter_attach_test.dart @@ -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 testReload(Process process, { Future Function() onListening }) async { + section('Testing hot reload, restart and quit'); + final Completer listening = new Completer(); + final Completer ready = new Completer(); + final Completer reloaded = new Completer(); + final Completer restarted = new Completer(); + final Completer finished = new Completer(); + final List stdout = []; + final List stderr = []; + + 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 eventOrExit(Future event) { + return Future.any(>[ 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'), + ['--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(['install', apkPath]); + + try { + section('Launching attach.'); + Process attachProcess = await startProcess( + path.join(flutterDirectory.path, 'bin', 'flutter'), + ['--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', ['start', '-n', kActivityId]); + }); + + final String currentTime = (await device.shellEval('date', ['"+%F %R:%S.000"'])).trim(); + print('Start time on device: $currentTime'); + section('Launching app'); + await device.shellExec('am', ['start', '-n', kActivityId]); + + final String observatoryLine = await device.adb(['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'), + ['--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(['uninstall', kAppId]); + } + }); + return new TaskResult.success(null); + }); +} diff --git a/dev/devicelab/lib/framework/adb.dart b/dev/devicelab/lib/framework/adb.dart index 958f78438d..de65020a0f 100644 --- a/dev/devicelab/lib/framework/adb.dart +++ b/dev/devicelab/lib/framework/adb.dart @@ -248,12 +248,17 @@ class AndroidDevice implements Device { /// Executes [command] on `adb shell` and returns its exit code. Future shellExec(String command, List arguments, { Map environment }) async { - await exec(adbPath, ['shell', command]..addAll(arguments), environment: environment, canFail: false); + await adb(['shell', command]..addAll(arguments), environment: environment); } /// Executes [command] on `adb shell` and returns its standard output as a [String]. Future shellEval(String command, List arguments, { Map environment }) { - return eval(adbPath, ['shell', command]..addAll(arguments), environment: environment, canFail: false); + return adb(['shell', command]..addAll(arguments), environment: environment); + } + + /// Runs `adb` with the given [arguments], selecting this device. + Future adb(List arguments, { Map environment }) { + return eval(adbPath, ['-s', deviceId]..addAll(arguments), environment: environment, canFail: false); } @override diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml index 08a488c7a8..148faf59c6 100644 --- a/dev/devicelab/manifest.yaml +++ b/dev/devicelab/manifest.yaml @@ -269,6 +269,12 @@ tasks: stage: devicelab 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 flavors_test_ios: diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart index 2d466d4010..b07ba18a54 100644 --- a/packages/flutter_tools/lib/executable.dart +++ b/packages/flutter_tools/lib/executable.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'runner.dart' as runner; import 'src/commands/analyze.dart'; +import 'src/commands/attach.dart'; import 'src/commands/build.dart'; import 'src/commands/channel.dart'; import 'src/commands/clean.dart'; @@ -48,6 +49,7 @@ Future main(List args) async { await runner.run(args, [ new AnalyzeCommand(verboseHelp: verboseHelp), + new AttachCommand(verboseHelp: verboseHelp), new BuildCommand(verboseHelp: verboseHelp), new ChannelCommand(verboseHelp: verboseHelp), new CleanCommand(), diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart new file mode 100644 index 0000000000..13fe2e1a4f --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/attach.dart @@ -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 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 = [ observatoryUri ]; + final HotRunner hotRunner = new HotRunner( + [flutterDevice], + debuggingOptions: new DebuggingOptions.enabled(getBuildInfo()), + packagesFilePath: globalResults['packages'], + ); + await hotRunner.attach(); + } finally { + device.portForwarder.forwardedPorts.forEach(device.portForwarder.unforward); + } + } + + Future _validateArguments() async {} +} diff --git a/packages/flutter_tools/test/commands/attach_test.dart b/packages/flutter_tools/test/commands/attach_test.dart new file mode 100644 index 0000000000..e550b93952 --- /dev/null +++ b/packages/flutter_tools/test/commands/attach_test.dart @@ -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([new ForwardedPort(hostPort, devicePort)]); + when(portForwarder.unforward).thenReturn((ForwardedPort _) async => null); + testDeviceManager.addDevice(device); + + final AttachCommand command = new AttachCommand(); + + await createTestCommandRunner(command).run(['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([new ForwardedPort(hostPort, devicePort)]); + when(portForwarder.unforward).thenReturn((ForwardedPort _) async => null); + testDeviceManager.addDevice(device); + + final AttachCommand command = new AttachCommand(); + + await createTestCommandRunner(command).run(['attach', '--debug-port', '$devicePort']); + + verify(portForwarder.forward(devicePort)).called(1); + }); + }); +} + +class MockPortForwarder extends Mock implements DevicePortForwarder {}