diff --git a/dev/devicelab/bin/tasks/flutter_attach_test.dart b/dev/devicelab/bin/tasks/flutter_attach_test_android.dart similarity index 100% rename from dev/devicelab/bin/tasks/flutter_attach_test.dart rename to dev/devicelab/bin/tasks/flutter_attach_test_android.dart diff --git a/dev/devicelab/bin/tasks/flutter_attach_test_fuchsia.dart b/dev/devicelab/bin/tasks/flutter_attach_test_fuchsia.dart new file mode 100644 index 0000000000..f7156000fb --- /dev/null +++ b/dev/devicelab/bin/tasks/flutter_attach_test_fuchsia.dart @@ -0,0 +1,237 @@ +// 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 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +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'; + +void generateMain(Directory appDir, String sentinel) { + final String mainCode = ''' +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_driver/driver_extension.dart'; + +class ReassembleListener extends StatefulWidget { + const ReassembleListener({Key key, this.child}) + : super(key: key); + + final Widget child; + + @override + _ReassembleListenerState createState() => _ReassembleListenerState(); +} + +class _ReassembleListenerState extends State { + @override + initState() { + super.initState(); + print('$sentinel'); + } + + @override + void reassemble() { + super.reassemble(); + print('$sentinel'); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +void main() { + runApp( + ReassembleListener( + child: Text( + 'Hello, word!', + textDirection: TextDirection.rtl, + ) + ) + ); +} +'''; + File(path.join(appDir.path, 'lib', 'fuchsia_main.dart')) + .writeAsStringSync(mainCode, flush: true); +} + +void main() { + deviceOperatingSystem = DeviceOperatingSystem.fuchsia; + + task(() async { + section('Checking environment variables'); + + if (Platform.environment['FUCHSIA_SSH_CONFIG'] == null && + Platform.environment['FUCHSIA_BUILD_DIR'] == null) { + throw Exception('No FUCHSIA_SSH_CONFIG or FUCHSIA_BUILD_DIR set'); + } + + final String flutterBinary = path.join(flutterDirectory.path, 'bin', 'flutter'); + + section('Downloading Fuchsia SDK and flutter runner'); + + // Download the Fuchsia SDK. + final int precacheResult = await exec( + flutterBinary, + [ + 'precache', + '--fuchsia', + '--flutter_runner', + ] + ); + + if (precacheResult != 0) { + throw Exception('flutter precache failed with exit code $precacheResult'); + } + + final Directory fuchsiaToolDirectory = + Directory(path.join(flutterDirectory.path, 'bin', 'cache', 'artifacts', 'fuchsia', 'tools')); + if (!fuchsiaToolDirectory.existsSync()) { + throw Exception('Expected Fuchsia tool directory at ${fuchsiaToolDirectory.path}'); + } + + final Device device = await devices.workingDevice; + final Directory appDir = dir(path.join( + flutterDirectory.path, + 'dev', + 'integration_tests', + 'ui', + )); + + await inDirectory(appDir, () async { + final Random random = Random(); + final Map> sentinelMessage = >{ + 'sentinel-${random.nextInt(1<<32)}': Completer(), + 'sentinel-${random.nextInt(1<<32)}': Completer(), + }; + + Process runProcess; + Process logsProcess; + + try { + section('Creating lib/fuchsia_main.dart'); + + generateMain(appDir, sentinelMessage.keys.toList()[0]); + + section('Launching `flutter run` in ${appDir.path}'); + + runProcess = await startProcess( + flutterBinary, + [ + 'run', + '--suppress-analytics', + '-d', device.deviceId, + '-t', 'lib/fuchsia_main.dart', + ], + isBot: false, // We just want to test the output, not have any debugging info. + ); + + logsProcess = await startProcess( + flutterBinary, + ['logs', '--suppress-analytics', '-d', device.deviceId], + isBot: false, // We just want to test the output, not have any debugging info. + ); + + Future eventOrExit(Future event) { + return Future.any(>[ + event, + runProcess.exitCode, + logsProcess.exitCode, + ]); + } + + logsProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String log) { + print('logs:stdout: $log'); + for (final String sentinel in sentinelMessage.keys) { + if (log.contains(sentinel)) { + if (sentinelMessage[sentinel].isCompleted) { + throw Exception( + 'Expected a single `$sentinel` message in the device log, but found more than one' + ); + } + sentinelMessage[sentinel].complete(); + break; + } + } + }); + + final Completer hotReloadCompleter = Completer(); + final Completer reloadedCompleter = Completer(); + final RegExp observatoryRegexp = RegExp('An Observatory debugger and profiler on .+ is available at'); + runProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('run:stdout: $line'); + if (observatoryRegexp.hasMatch(line)) { + hotReloadCompleter.complete(); + } else if (line.contains('Reloaded')) { + reloadedCompleter.complete(); + } + }); + + final List runStderr = []; + runProcess.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + runStderr.add(line); + print('run:stderr: $line'); + }); + + section('Waiting for hot reload availability'); + await eventOrExit(hotReloadCompleter.future); + + section('Waiting for Dart VM'); + // Wait for the first message in the log from the Dart VM. + await eventOrExit(sentinelMessage.values.toList()[0].future); + + // Change the dart file. + generateMain(appDir, sentinelMessage.keys.toList()[1]); + + section('Hot reload'); + runProcess.stdin.write('r'); + runProcess.stdin.flush(); + await eventOrExit(reloadedCompleter.future); + + section('Waiting for Dart VM'); + // Wait for the second message in the log from the Dart VM. + await eventOrExit(sentinelMessage.values.toList()[1].future); + + section('Quitting flutter run'); + + runProcess.stdin.write('q'); + runProcess.stdin.flush(); + + final int runExitCode = await runProcess.exitCode; + if (runExitCode != 0 || runStderr.isNotEmpty) { + throw Exception( + 'flutter run exited with code $runExitCode and errors: ${runStderr.join('\n')}.' + ); + } + } finally { + runProcess.kill(); + logsProcess.kill(); + File(path.join(appDir.path, 'lib', 'fuchsia_main.dart')).deleteSync(); + } + + for (final String sentinel in sentinelMessage.keys) { + if (!sentinelMessage[sentinel].isCompleted) { + throw Exception('Expected $sentinel in the device logs.'); + } + } + }); + + return TaskResult.success(null); + }); +} diff --git a/dev/devicelab/lib/framework/adb.dart b/dev/devicelab/lib/framework/adb.dart index ead9883959..2f7aa569cb 100644 --- a/dev/devicelab/lib/framework/adb.dart +++ b/dev/devicelab/lib/framework/adb.dart @@ -12,11 +12,22 @@ import 'package:path/path.dart' as path; import 'utils.dart'; + +/// Gets the artifact path relative to the current directory. +String getArtifactPath() { + return path.normalize( + path.join( + path.current, + '../../bin/cache/artifacts', + ) + ); +} + /// The root of the API for controlling devices. DeviceDiscovery get devices => DeviceDiscovery(); /// Device operating system the test is configured to test. -enum DeviceOperatingSystem { android, ios } +enum DeviceOperatingSystem { android, ios, fuchsia } /// Device OS to test on. DeviceOperatingSystem deviceOperatingSystem = DeviceOperatingSystem.android; @@ -29,6 +40,8 @@ abstract class DeviceDiscovery { return AndroidDeviceDiscovery(); case DeviceOperatingSystem.ios: return IosDeviceDiscovery(); + case DeviceOperatingSystem.fuchsia: + return FuchsiaDeviceDiscovery(); default: throw StateError('Unsupported device operating system: {config.deviceOperatingSystem}'); } @@ -198,6 +211,91 @@ class AndroidDeviceDiscovery implements DeviceDiscovery { } } +class FuchsiaDeviceDiscovery implements DeviceDiscovery { + factory FuchsiaDeviceDiscovery() { + return _instance ??= FuchsiaDeviceDiscovery._(); + } + + FuchsiaDeviceDiscovery._(); + + static FuchsiaDeviceDiscovery _instance; + + FuchsiaDevice _workingDevice; + + String get _devFinder { + final String devFinder = path.join(getArtifactPath(), 'fuchsia', 'tools', 'dev_finder'); + if (!File(devFinder).existsSync()) { + throw FileSystemException('Couldn\'t find dev_finder at location $devFinder'); + } + return devFinder; + } + + @override + Future get workingDevice async { + if (_workingDevice == null) { + await chooseWorkingDevice(); + } + return _workingDevice; + } + + /// Picks the first connected Fuchsia device. + @override + Future chooseWorkingDevice() async { + final List allDevices = (await discoverDevices()) + .map((String id) => FuchsiaDevice(deviceId: id)) + .toList(); + + if (allDevices.isEmpty) { + throw Exception('No Fuchsia devices detected'); + } + _workingDevice = allDevices.first; + } + + @override + Future> discoverDevices() async { + final List output = (await eval(_devFinder, ['list', '-full'])) + .trim() + .split('\n'); + + final List devices = []; + for (final String line in output) { + final List parts = line.split(' '); + assert(parts.length == 2); + devices.add(parts.last); // The device id. + } + return devices; + } + + @override + Future> checkDevices() async { + final Map results = {}; + for (final String deviceId in await discoverDevices()) { + try { + final int resolveResult = await exec( + _devFinder, + [ + 'resolve', + '-device-limit', + '1', + deviceId, + ] + ); + if (resolveResult == 0) { + results['fuchsia-device-$deviceId'] = HealthCheckResult.success(); + } else { + results['fuchsia-device-$deviceId'] = HealthCheckResult.failure('Cannot resolve device $deviceId'); + } + } catch (error, stacktrace) { + results['fuchsia-device-$deviceId'] = HealthCheckResult.error(error, stacktrace); + } + } + return results; + } + + @override + Future performPreflightTasks() async {} +} + class AndroidDevice implements Device { AndroidDevice({@required this.deviceId}); @@ -392,16 +490,6 @@ class IosDeviceDiscovery implements DeviceDiscovery { _workingDevice = allDevices[math.Random().nextInt(allDevices.length)]; } - // Returns the path to cached binaries relative to devicelab directory - String get _artifactDirPath { - return path.normalize( - path.join( - path.current, - '../../bin/cache/artifacts', - ) - ); - } - // Returns a colon-separated environment variable that contains the paths // of linked libraries for idevice_id Map get _ideviceIdEnvironment { @@ -413,13 +501,13 @@ class IosDeviceDiscovery implements DeviceDiscovery { 'ideviceinstaller', 'ios-deploy', 'libzip', - ].map((String packageName) => path.join(_artifactDirPath, packageName)).join(':'); + ].map((String packageName) => path.join(getArtifactPath(), packageName)).join(':'); return {'DYLD_LIBRARY_PATH': libPath}; } @override Future> discoverDevices() async { - final String ideviceIdPath = path.join(_artifactDirPath, 'libimobiledevice', 'idevice_id'); + final String ideviceIdPath = path.join(getArtifactPath(), 'libimobiledevice', 'idevice_id'); final List iosDeviceIDs = LineSplitter.split(await eval(ideviceIdPath, ['-l'], environment: _ideviceIdEnvironment)) .map((String line) => line.trim()) .where((String line) => line.isNotEmpty) @@ -494,6 +582,49 @@ class IosDevice implements Device { Future stop(String packageName) async {} } +/// Fuchsia device. +class FuchsiaDevice implements Device { + const FuchsiaDevice({ @required this.deviceId }); + + @override + final String deviceId; + + // TODO(egarciad): Implement these for Fuchsia. + @override + Future isAwake() async => true; + + @override + Future isAsleep() async => false; + + @override + Future wakeUp() async {} + + @override + Future sendToSleep() async {} + + @override + Future togglePower() async {} + + @override + Future unlock() async {} + + @override + Future tap(int x, int y) async {} + + @override + Future stop(String packageName) async {} + + @override + Future> getMemoryStats(String packageName) async { + throw 'Not implemented'; + } + + @override + Stream get logcat { + throw 'Not implemented'; + } +} + /// Path to the `adb` executable. String get adbPath { final String androidHome = Platform.environment['ANDROID_HOME'] ?? Platform.environment['ANDROID_SDK_ROOT']; diff --git a/dev/devicelab/lib/tasks/perf_tests.dart b/dev/devicelab/lib/tasks/perf_tests.dart index 7ced54f559..9e9aaf0f41 100644 --- a/dev/devicelab/lib/tasks/perf_tests.dart +++ b/dev/devicelab/lib/tasks/perf_tests.dart @@ -380,6 +380,8 @@ class CompileTest { case DeviceOperatingSystem.android: options.add('android-arm'); break; + case DeviceOperatingSystem.fuchsia: + throw Exception('Unsupported option for Fuchsia devices'); } final String compileLog = await evalFlutter('build', options: options); watch.stop(); @@ -434,6 +436,8 @@ class CompileTest { if (reportPackageContentSizes) metrics.addAll(await getSizesFromApk(apkPath)); break; + case DeviceOperatingSystem.fuchsia: + throw Exception('Unsupported option for Fuchsia devices'); } metrics.addAll({ @@ -456,6 +460,8 @@ class CompileTest { options.insert(0, 'apk'); options.add('--target-platform=android-arm'); break; + case DeviceOperatingSystem.fuchsia: + throw Exception('Unsupported option for Fuchsia devices'); } watch.start(); await flutter('build', options: options); diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml index a04ef0017d..7bb7cd2813 100644 --- a/dev/devicelab/manifest.yaml +++ b/dev/devicelab/manifest.yaml @@ -340,7 +340,7 @@ tasks: # required_agent_capabilities: ["linux/android"] # flaky: true - flutter_attach_test: + flutter_attach_test_android: description: > Tests the `flutter attach` command. stage: devicelab diff --git a/dev/integration_tests/ui/fuchsia/meta/integration_ui.cmx b/dev/integration_tests/ui/fuchsia/meta/integration_ui.cmx new file mode 100644 index 0000000000..06187c4352 --- /dev/null +++ b/dev/integration_tests/ui/fuchsia/meta/integration_ui.cmx @@ -0,0 +1,22 @@ +{ + "program": { + "data": "data/integration_ui" + }, + "sandbox": { + "services": [ + "fuchsia.cobalt.LoggerFactory", + "fuchsia.fonts.Provider", + "fuchsia.logger.LogSink", + "fuchsia.modular.Clipboard", + "fuchsia.modular.ContextWriter", + "fuchsia.modular.DeviceMap", + "fuchsia.modular.ModuleContext", + "fuchsia.sys.Environment", + "fuchsia.sys.Launcher", + "fuchsia.testing.runner.TestRunner", + "fuchsia.ui.input.ImeService", + "fuchsia.ui.policy.Presenter", + "fuchsia.ui.scenic.Scenic" + ] + } +} diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart index 8e7a1799b7..10e0787ae3 100644 --- a/packages/flutter_tools/lib/src/commands/attach.dart +++ b/packages/flutter_tools/lib/src/commands/attach.dart @@ -218,7 +218,7 @@ class AttachCommand extends FlutterCommand { if (module == null) { throwToolExit('\'--module\' is required for attaching to a Fuchsia device'); } - usesIpv6 = await device.ipv6; + usesIpv6 = device.ipv6; FuchsiaIsolateDiscoveryProtocol isolateDiscoveryProtocol; try { isolateDiscoveryProtocol = device.getIsolateDiscoveryProtocol(module); diff --git a/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart b/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart index 21e1fbe398..75410494d2 100644 --- a/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart +++ b/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart @@ -153,7 +153,7 @@ class FuchsiaDevices extends PollingDeviceDiscovery { if (text == null || text.isEmpty) { return []; } - final List devices = parseListDevices(text); + final List devices = await parseListDevices(text); return devices; } @@ -162,7 +162,7 @@ class FuchsiaDevices extends PollingDeviceDiscovery { } @visibleForTesting -List parseListDevices(String text) { +Future> parseListDevices(String text) async { final List devices = []; for (final String rawLine in text.trim().split('\n')) { final String line = rawLine.trim(); @@ -172,8 +172,15 @@ List parseListDevices(String text) { continue; } final String name = words[1]; - final String id = words[0]; - devices.add(FuchsiaDevice(id, name: name)); + final String resolvedHost = await fuchsiaSdk.fuchsiaDevFinder.resolve( + name, + local: false, + ); + if (resolvedHost == null) { + globals.printError('Failed to resolve host for Fuchsia device `$name`'); + continue; + } + devices.add(FuchsiaDevice(resolvedHost, name: name)); } return devices; } @@ -240,7 +247,6 @@ class FuchsiaDevice extends Device { } // Stop the app if it's currently running. await stopApp(package); - // Find out who the device thinks we are. final String host = await fuchsiaSdk.fuchsiaDevFinder.resolve( name, local: true, @@ -249,6 +255,7 @@ class FuchsiaDevice extends Device { globals.printError('Failed to resolve host for Fuchsia device'); return LaunchResult.failed(); } + // Find out who the device thinks we are. final int port = await os.findFreePort(); if (port == 0) { globals.printError('Failed to find a free port'); @@ -475,11 +482,9 @@ class FuchsiaDevice extends Device { @override bool get supportsScreenshot => false; - Future get ipv6 async { - // Workaround for https://github.com/dart-lang/sdk/issues/29456 - final String fragment = (await _resolvedIp).split('%').first; + bool get ipv6 { try { - Uri.parseIPv6Address(fragment); + Uri.parseIPv6Address(id); return true; } on FormatException { return false; @@ -525,15 +530,6 @@ class FuchsiaDevice extends Device { return ports; } - String _cachedResolvedIp; - - Future get _resolvedIp async { - return _cachedResolvedIp ??= await fuchsiaSdk.fuchsiaDevFinder.resolve( - name, - local: false, - ); - } - /// Run `command` on the Fuchsia device shell. Future shell(String command) async { if (fuchsiaArtifacts.sshConfig == null) { @@ -544,7 +540,7 @@ class FuchsiaDevice extends Device { 'ssh', '-F', fuchsiaArtifacts.sshConfig.absolute.path, - await _resolvedIp, + id, // Device's IP address. command, ]); } @@ -670,7 +666,7 @@ class FuchsiaIsolateDiscoveryProtocol { } final Uri address = flutterView.owner.vmService.httpAddress; if (flutterView.uiIsolate.name.contains(_isolateName)) { - _foundUri.complete(await _device.ipv6 + _foundUri.complete(_device.ipv6 ? Uri.parse('http://[$_ipv6Loopback]:${address.port}/') : Uri.parse('http://$_ipv4Loopback:${address.port}/')); _status.stop(); @@ -711,7 +707,7 @@ class _FuchsiaPortForwarder extends DevicePortForwarder { '-f', '-L', '$hostPort:$_ipv4Loopback:$devicePort', - await device._resolvedIp, + device.id, // Device's IP address. 'true', ]; final Process process = await globals.processManager.start(command); @@ -743,7 +739,7 @@ class _FuchsiaPortForwarder extends DevicePortForwarder { '-vvv', '-L', '${forwardedPort.hostPort}:$_ipv4Loopback:${forwardedPort.devicePort}', - await device._resolvedIp, + device.id, // Device's IP address. ]; final ProcessResult result = await globals.processManager.run(command); if (result.exitCode != 0) { @@ -753,7 +749,9 @@ class _FuchsiaPortForwarder extends DevicePortForwarder { @override Future dispose() async { - for (final ForwardedPort port in forwardedPorts) { + final List forwardedPortsCopy = + List.from(forwardedPorts); + for (final ForwardedPort port in forwardedPortsCopy) { await unforward(port); } } diff --git a/packages/flutter_tools/lib/src/fuchsia/fuchsia_sdk.dart b/packages/flutter_tools/lib/src/fuchsia/fuchsia_sdk.dart index ba91b5f15d..214b2767e9 100644 --- a/packages/flutter_tools/lib/src/fuchsia/fuchsia_sdk.dart +++ b/packages/flutter_tools/lib/src/fuchsia/fuchsia_sdk.dart @@ -54,7 +54,8 @@ class FuchsiaSdk { return devices.isNotEmpty ? devices[0] : null; } - /// Returns the fuchsia system logs for an attached device. + /// Returns the fuchsia system logs for an attached device where + /// [id] is the IP address of the device. Stream syslogs(String id) { Process process; try { @@ -72,7 +73,7 @@ class FuchsiaSdk { 'ssh', '-F', fuchsiaArtifacts.sshConfig.absolute.path, - id, + id, // The device's IP. remoteCommand, ]; globals.processManager.start(cmd).then((Process newProcess) { diff --git a/packages/flutter_tools/test/general.shard/fuchsia/fuchsia_device_test.dart b/packages/flutter_tools/test/general.shard/fuchsia/fuchsia_device_test.dart index 3bc889f352..c7a7dd5e3d 100644 --- a/packages/flutter_tools/test/general.shard/fuchsia/fuchsia_device_test.dart +++ b/packages/flutter_tools/test/general.shard/fuchsia/fuchsia_device_test.dart @@ -55,20 +55,24 @@ void main() { expect(device.name, name); }); - test('parse dev_finder output', () { - const String example = '192.168.42.56 paper-pulp-bush-angel'; - final List names = parseListDevices(example); + testUsingContext('parse dev_finder output', () async { + const String example = '2001:0db8:85a3:0000:0000:8a2e:0370:7334 paper-pulp-bush-angel'; + final List names = await parseListDevices(example); expect(names.length, 1); expect(names.first.name, 'paper-pulp-bush-angel'); - expect(names.first.id, '192.168.42.56'); + expect(names.first.id, '192.168.42.10'); + }, overrides: { + FuchsiaSdk: () => MockFuchsiaSdk(), }); - test('parse junk dev_finder output', () { + testUsingContext('parse junk dev_finder output', () async { const String example = 'junk'; - final List names = parseListDevices(example); + final List names = await parseListDevices(example); expect(names.length, 0); + }, overrides: { + FuchsiaSdk: () => MockFuchsiaSdk(), }); testUsingContext('disposing device disposes the portForwarder', () async { @@ -773,7 +777,7 @@ class MockFuchsiaDevice extends Mock implements FuchsiaDevice { final bool _ipv6; @override - Future get ipv6 async => _ipv6; + bool get ipv6 => _ipv6; @override final String id;