// 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:file/file.dart'; import 'package:file/memory.dart'; import 'package:file_testing/file_testing.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/build_system/build_system.dart'; import 'package:flutter_tools/src/bundle.dart'; import 'package:flutter_tools/src/bundle_builder.dart'; import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/custom_devices/custom_device.dart'; import 'package:flutter_tools/src/custom_devices/custom_device_config.dart'; import 'package:flutter_tools/src/custom_devices/custom_devices_config.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/linux/application_package.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:meta/meta.dart'; import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/context.dart'; import '../../src/fakes.dart'; void _writeCustomDevicesConfigFile(Directory dir, List<CustomDeviceConfig> configs) { dir.createSync(); final File file = dir.childFile('.flutter_custom_devices.json'); file.writeAsStringSync(jsonEncode( <String, dynamic>{ 'custom-devices': configs.map<dynamic>((CustomDeviceConfig c) => c.toJson()).toList(), } )); } FlutterProject _setUpFlutterProject(Directory directory) { final FlutterProjectFactory flutterProjectFactory = FlutterProjectFactory( fileSystem: directory.fileSystem, logger: BufferLogger.test(), ); return flutterProjectFactory.fromDirectory(directory); } void main() { testWithoutContext('replacing string interpolation occurrences in custom device commands', () async { expect( interpolateCommand( <String>['scp', r'${localPath}', r'/tmp/${appName}', 'pi@raspberrypi'], <String, String>{ 'localPath': 'build/flutter_assets', 'appName': 'hello_world', }, ), <String>[ 'scp', 'build/flutter_assets', '/tmp/hello_world', 'pi@raspberrypi', ] ); expect( interpolateCommand( <String>[r'${test1}', r' ${test2}', r'${test3}'], <String, String>{ 'test1': '_test1', 'test2': '_test2', }, ), <String>[ '_test1', ' _test2', r'', ] ); expect( interpolateCommand( <String>[r'${test1}', r' ${test2}', r'${test3}'], <String, String>{ 'test1': '_test1', 'test2': '_test2', }, additionalReplacementValues: <String, String>{ 'test2': '_nottest2', 'test3': '_test3', } ), <String>[ '_test1', ' _test2', r'_test3', ] ); }); final CustomDeviceConfig testConfig = CustomDeviceConfig( id: 'testid', label: 'testlabel', sdkNameAndVersion: 'testsdknameandversion', enabled: true, pingCommand: const <String>['testping'], pingSuccessRegex: RegExp('testpingsuccess'), postBuildCommand: const <String>['testpostbuild'], installCommand: const <String>['testinstall'], uninstallCommand: const <String>['testuninstall'], runDebugCommand: const <String>['testrundebug'], forwardPortCommand: const <String>['testforwardport'], forwardPortSuccessRegex: RegExp('testforwardportsuccess'), screenshotCommand: const <String>['testscreenshot'], ); const String testConfigPingSuccessOutput = 'testpingsuccess\n'; const String testConfigForwardPortSuccessOutput = 'testforwardportsuccess\n'; final CustomDeviceConfig disabledTestConfig = testConfig.copyWith(enabled: false); final CustomDeviceConfig testConfigNonForwarding = testConfig.copyWith( explicitForwardPortCommand: true, explicitForwardPortSuccessRegex: true, ); testUsingContext('CustomDevice defaults', () async { final CustomDevice device = CustomDevice( config: testConfig, processManager: FakeProcessManager.any(), logger: BufferLogger.test() ); final PrebuiltLinuxApp linuxApp = PrebuiltLinuxApp(executable: 'foo'); expect(device.id, 'testid'); expect(device.name, 'testlabel'); expect(device.platformType, PlatformType.custom); expect(await device.sdkNameAndVersion, 'testsdknameandversion'); expect(await device.targetPlatform, TargetPlatform.linux_arm64); expect(await device.installApp(linuxApp), true); expect(await device.uninstallApp(linuxApp), true); expect(await device.isLatestBuildInstalled(linuxApp), false); expect(await device.isAppInstalled(linuxApp), false); expect(await device.stopApp(linuxApp), false); expect(await device.stopApp(null), false); expect(device.category, Category.mobile); expect(device.supportsRuntimeMode(BuildMode.debug), true); expect(device.supportsRuntimeMode(BuildMode.profile), false); expect(device.supportsRuntimeMode(BuildMode.release), false); expect(device.supportsRuntimeMode(BuildMode.jitRelease), false); }, overrides: <Type, dynamic Function()>{ FileSystem: () => MemoryFileSystem.test(), ProcessManager: () => FakeProcessManager.any(), } ); testWithoutContext('CustomDevice: no devices listed if only disabled devices configured', () async { final MemoryFileSystem fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[disabledTestConfig]); expect(await CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.any(), config: CustomDevicesConfig.test( fileSystem: fs, directory: dir, logger: BufferLogger.test() ) ).devices, <Device>[]); }); testWithoutContext('CustomDevice: no devices listed if custom devices feature flag disabled', () async { final MemoryFileSystem fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]); expect(await CustomDevices( featureFlags: TestFeatureFlags(), logger: BufferLogger.test(), processManager: FakeProcessManager.any(), config: CustomDevicesConfig.test( fileSystem: fs, directory: dir, logger: BufferLogger.test() ) ).devices, <Device>[]); }); testWithoutContext('CustomDevices.devices', () async { final MemoryFileSystem fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]); expect( await CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.list(<FakeCommand>[ FakeCommand( command: testConfig.pingCommand, stdout: testConfigPingSuccessOutput ), ]), config: CustomDevicesConfig.test( fileSystem: fs, directory: dir, logger: BufferLogger.test() ) ).devices, hasLength(1) ); }); testWithoutContext('CustomDevices.discoverDevices successfully discovers devices and executes ping command', () async { final MemoryFileSystem fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]); bool pingCommandWasExecuted = false; final CustomDevices discovery = CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.list(<FakeCommand>[ FakeCommand( command: testConfig.pingCommand, onRun: () => pingCommandWasExecuted = true, stdout: testConfigPingSuccessOutput ), ]), config: CustomDevicesConfig.test( fileSystem: fs, directory: dir, logger: BufferLogger.test(), ), ); final List<Device> discoveredDevices = await discovery.discoverDevices(); expect(discoveredDevices, hasLength(1)); expect(pingCommandWasExecuted, true); }); testWithoutContext("CustomDevices.discoverDevices doesn't report device when ping command fails", () async { final MemoryFileSystem fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]); final CustomDevices discovery = CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.list(<FakeCommand>[ FakeCommand( command: testConfig.pingCommand, stdout: testConfigPingSuccessOutput, exitCode: 1 ), ]), config: CustomDevicesConfig.test( fileSystem: fs, directory: dir, logger: BufferLogger.test(), ), ); expect(await discovery.discoverDevices(), hasLength(0)); }); testWithoutContext("CustomDevices.discoverDevices doesn't report device when ping command output doesn't match ping success regex", () async { final MemoryFileSystem fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, <CustomDeviceConfig>[testConfig]); final CustomDevices discovery = CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.list(<FakeCommand>[ FakeCommand( command: testConfig.pingCommand, ), ]), config: CustomDevicesConfig.test( fileSystem: fs, directory: dir, logger: BufferLogger.test(), ), ); expect(await discovery.discoverDevices(), hasLength(0)); }); testWithoutContext('CustomDevice.isSupportedForProject is true with editable host app', () async { final MemoryFileSystem fileSystem = MemoryFileSystem.test(); fileSystem.file('pubspec.yaml').createSync(); fileSystem.file('.packages').createSync(); final FlutterProject flutterProject = _setUpFlutterProject(fileSystem.currentDirectory); expect(CustomDevice( config: testConfig, logger: BufferLogger.test(), processManager: FakeProcessManager.any(), ).isSupportedForProject(flutterProject), true); }); testUsingContext( 'CustomDevice.install invokes uninstall and install command', () async { bool bothCommandsWereExecuted = false; final CustomDevice device = CustomDevice( config: testConfig, logger: BufferLogger.test(), processManager: FakeProcessManager.list(<FakeCommand>[ FakeCommand(command: testConfig.uninstallCommand), FakeCommand(command: testConfig.installCommand, onRun: () => bothCommandsWereExecuted = true), ]) ); expect(await device.installApp(PrebuiltLinuxApp(executable: 'exe')), true); expect(bothCommandsWereExecuted, true); }, overrides: <Type, dynamic Function()>{ FileSystem: () => MemoryFileSystem.test(), ProcessManager: () => FakeProcessManager.any(), } ); testWithoutContext('CustomDevicePortForwarder will run and terminate forwardPort command', () async { final Completer<void> forwardPortCommandCompleter = Completer<void>(); final CustomDevicePortForwarder forwarder = CustomDevicePortForwarder( deviceName: 'testdevicename', forwardPortCommand: testConfig.forwardPortCommand!, forwardPortSuccessRegex: testConfig.forwardPortSuccessRegex!, logger: BufferLogger.test(), processManager: FakeProcessManager.list(<FakeCommand>[ FakeCommand( command: testConfig.forwardPortCommand!, stdout: testConfigForwardPortSuccessOutput, completer: forwardPortCommandCompleter, ), ]) ); // this should start the command expect(await forwarder.forward(12345), 12345); expect(forwardPortCommandCompleter.isCompleted, false); // this should terminate it await forwarder.dispose(); // the termination should have completed our completer expect(forwardPortCommandCompleter.isCompleted, true); }); testWithoutContext('CustomDevice forwards VM Service port correctly when port forwarding is configured', () async { final Completer<void> runDebugCompleter = Completer<void>(); final Completer<void> forwardPortCompleter = Completer<void>(); final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[ FakeCommand( command: testConfig.runDebugCommand, completer: runDebugCompleter, stdout: 'The Dart VM service is listening on http://127.0.0.1:12345/abcd/\n', ), FakeCommand( command: testConfig.forwardPortCommand!, completer: forwardPortCompleter, stdout: testConfigForwardPortSuccessOutput, ), ]); final CustomDeviceAppSession appSession = CustomDeviceAppSession( name: 'testname', device: CustomDevice( config: testConfig, logger: BufferLogger.test(), processManager: processManager ), appPackage: PrebuiltLinuxApp(executable: 'testexecutable'), logger: BufferLogger.test(), processManager: processManager, ); final LaunchResult launchResult = await appSession.start(debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug)); expect(launchResult.started, true); expect(launchResult.vmServiceUri, Uri.parse('http://127.0.0.1:12345/abcd/')); expect(runDebugCompleter.isCompleted, false); expect(forwardPortCompleter.isCompleted, false); expect(await appSession.stop(), true); expect(runDebugCompleter.isCompleted, true); expect(forwardPortCompleter.isCompleted, true); }); testWithoutContext('CustomDeviceAppSession forwards VM Service port correctly when port forwarding is not configured', () async { final Completer<void> runDebugCompleter = Completer<void>(); final FakeProcessManager processManager = FakeProcessManager.list( <FakeCommand>[ FakeCommand( command: testConfigNonForwarding.runDebugCommand, completer: runDebugCompleter, stdout: 'The Dart VM service is listening on http://192.168.178.123:12345/abcd/\n' ), ] ); final CustomDeviceAppSession appSession = CustomDeviceAppSession( name: 'testname', device: CustomDevice( config: testConfigNonForwarding, logger: BufferLogger.test(), processManager: processManager ), appPackage: PrebuiltLinuxApp(executable: 'testexecutable'), logger: BufferLogger.test(), processManager: processManager ); final LaunchResult launchResult = await appSession.start(debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug)); expect(launchResult.started, true); expect(launchResult.vmServiceUri, Uri.parse('http://192.168.178.123:12345/abcd/')); expect(runDebugCompleter.isCompleted, false); expect(await appSession.stop(), true); expect(runDebugCompleter.isCompleted, true); }); testUsingContext( 'custom device end-to-end test', () async { final Completer<void> runDebugCompleter = Completer<void>(); final Completer<void> forwardPortCompleter = Completer<void>(); final FakeProcessManager processManager = FakeProcessManager.list( <FakeCommand>[ FakeCommand( command: testConfig.pingCommand, stdout: testConfigPingSuccessOutput ), FakeCommand(command: testConfig.postBuildCommand!), FakeCommand(command: testConfig.uninstallCommand), FakeCommand(command: testConfig.installCommand), FakeCommand( command: testConfig.runDebugCommand, completer: runDebugCompleter, stdout: 'The Dart VM service is listening on http://127.0.0.1:12345/abcd/\n', ), FakeCommand( command: testConfig.forwardPortCommand!, completer: forwardPortCompleter, stdout: testConfigForwardPortSuccessOutput, ), ] ); // Reuse our filesystem from context instead of mixing two filesystem instances // together final FileSystem fs = globals.fs; // CustomDevice.startApp doesn't care whether we pass a prebuilt app or // buildable app as long as we pass prebuiltApplication as false final PrebuiltLinuxApp app = PrebuiltLinuxApp(executable: 'testexecutable'); final Directory configFileDir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(configFileDir, <CustomDeviceConfig>[testConfig]); // finally start actually testing things final CustomDevices customDevices = CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), processManager: processManager, logger: BufferLogger.test(), config: CustomDevicesConfig.test( fileSystem: fs, directory: configFileDir, logger: BufferLogger.test() ) ); final List<Device> devices = await customDevices.discoverDevices(); expect(devices.length, 1); expect(devices.single, isA<CustomDevice>()); final CustomDevice device = devices.single as CustomDevice; expect(device.id, testConfig.id); expect(device.name, testConfig.label); expect(await device.sdkNameAndVersion, testConfig.sdkNameAndVersion); final LaunchResult result = await device.startApp( app, debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), bundleBuilder: FakeBundleBuilder() ); expect(result.started, true); expect(result.hasVmService, true); expect(result.vmServiceUri, Uri.tryParse('http://127.0.0.1:12345/abcd/')); expect(runDebugCompleter.isCompleted, false); expect(forwardPortCompleter.isCompleted, false); expect(await device.stopApp(app), true); expect(runDebugCompleter.isCompleted, true); expect(forwardPortCompleter.isCompleted, true); }, overrides: <Type, Generator>{ FileSystem: () => MemoryFileSystem.test(), ProcessManager: () => FakeProcessManager.any(), } ); testWithoutContext('CustomDevice screenshotting', () async { bool screenshotCommandWasExecuted = false; final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[ FakeCommand( command: testConfig.screenshotCommand!, onRun: () => screenshotCommandWasExecuted = true, ), ]); final MemoryFileSystem fs = MemoryFileSystem.test(); final File screenshotFile = fs.file('screenshot.png'); final CustomDevice device = CustomDevice( config: testConfig, logger: BufferLogger.test(), processManager: processManager ); expect(device.supportsScreenshot, true); await device.takeScreenshot(screenshotFile); expect(screenshotCommandWasExecuted, true); expect(screenshotFile, exists); }); testWithoutContext('CustomDevice without screenshotting support', () async { bool screenshotCommandWasExecuted = false; final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[ FakeCommand( command: testConfig.screenshotCommand!, onRun: () => screenshotCommandWasExecuted = true, ), ]); final MemoryFileSystem fs = MemoryFileSystem.test(); final File screenshotFile = fs.file('screenshot.png'); final CustomDevice device = CustomDevice( config: testConfig.copyWith( explicitScreenshotCommand: true ), logger: BufferLogger.test(), processManager: processManager ); expect(device.supportsScreenshot, false); expect( () => device.takeScreenshot(screenshotFile), throwsA(const TypeMatcher<UnsupportedError>()), ); expect(screenshotCommandWasExecuted, false); expect(screenshotFile.existsSync(), false); }); testWithoutContext('CustomDevice returns correct target platform', () async { final CustomDevice device = CustomDevice( config: testConfig.copyWith( platform: TargetPlatform.linux_x64 ), logger: BufferLogger.test(), processManager: FakeProcessManager.empty() ); expect(await device.targetPlatform, TargetPlatform.linux_x64); }); testWithoutContext('CustomDeviceLogReader cancels subscriptions before closing logLines stream', () async { final CustomDeviceLogReader logReader = CustomDeviceLogReader('testname'); final Iterable<List<int>> lines = Iterable<List<int>>.generate(5, (int _) => utf8.encode('test')); logReader.listenToProcessOutput( FakeProcess( exitCode: Future<int>.value(0), stdout: Stream<List<int>>.fromIterable(lines), stderr: Stream<List<int>>.fromIterable(lines), ), ); final List<MyFakeStreamSubscription<String>> subscriptions = <MyFakeStreamSubscription<String>>[]; bool logLinesStreamDone = false; logReader.logLines.listen((_) {}, onDone: () { expect(subscriptions, everyElement((MyFakeStreamSubscription<String> s) => s.canceled)); logLinesStreamDone = true; }); logReader.subscriptions.replaceRange( 0, logReader.subscriptions.length, logReader.subscriptions.map( (StreamSubscription<String> e) => MyFakeStreamSubscription<String>(e) ), ); subscriptions.addAll(logReader.subscriptions.cast()); await logReader.dispose(); expect(logLinesStreamDone, true); }); } class MyFakeStreamSubscription<T> extends Fake implements StreamSubscription<T> { MyFakeStreamSubscription(this.parent); StreamSubscription<T> parent; bool canceled = false; @override Future<void> cancel() { canceled = true; return parent.cancel(); } } class FakeBundleBuilder extends Fake implements BundleBuilder { @override Future<void> build({ TargetPlatform? platform, BuildInfo? buildInfo, FlutterProject? project, String? mainPath, String manifestPath = defaultManifestPath, String? applicationKernelFilePath, String? depfilePath, String? assetDirPath, @visibleForTesting BuildSystem? buildSystem }) async {} }