// 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/artifacts.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'; import '../../src/package_config.dart'; void _writeCustomDevicesConfigFile(Directory dir, List configs) { dir.createSync(); final File file = dir.childFile('.flutter_custom_devices.json'); file.writeAsStringSync( jsonEncode({ 'custom-devices': configs.map((CustomDeviceConfig c) => c.toJson()).toList(), }), ); } FlutterProject _setUpFlutterProject(Directory directory) { final 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( ['scp', r'${localPath}', r'/tmp/${appName}', 'pi@raspberrypi'], {'localPath': 'build/flutter_assets', 'appName': 'hello_world'}, ), ['scp', 'build/flutter_assets', '/tmp/hello_world', 'pi@raspberrypi'], ); expect( interpolateCommand( [r'${test1}', r' ${test2}', r'${test3}'], {'test1': '_test1', 'test2': '_test2'}, ), ['_test1', ' _test2', r''], ); expect( interpolateCommand( [r'${test1}', r' ${test2}', r'${test3}'], {'test1': '_test1', 'test2': '_test2'}, additionalReplacementValues: {'test2': '_nottest2', 'test3': '_test3'}, ), ['_test1', ' _test2', r'_test3'], ); }, ); final testConfig = CustomDeviceConfig( id: 'testid', label: 'testlabel', sdkNameAndVersion: 'testsdknameandversion', enabled: true, pingCommand: const ['testping'], pingSuccessRegex: RegExp('testpingsuccess'), postBuildCommand: const ['testpostbuild'], installCommand: const ['testinstall'], uninstallCommand: const ['testuninstall'], runDebugCommand: const ['testrundebug'], forwardPortCommand: const ['testforwardport'], forwardPortSuccessRegex: RegExp('testforwardportsuccess'), screenshotCommand: const ['testscreenshot'], ); const testConfigPingSuccessOutput = 'testpingsuccess\n'; const testConfigForwardPortSuccessOutput = 'testforwardportsuccess\n'; final CustomDeviceConfig disabledTestConfig = testConfig.copyWith(enabled: false); final CustomDeviceConfig testConfigNonForwarding = testConfig.copyWith( explicitForwardPortCommand: true, explicitForwardPortSuccessRegex: true, ); testUsingContext( 'CustomDevice defaults', () async { final device = CustomDevice( config: testConfig, processManager: FakeProcessManager.any(), logger: BufferLogger.test(), ); final 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: { FileSystem: () => MemoryFileSystem.test(), ProcessManager: () => FakeProcessManager.any(), }, ); testWithoutContext( 'CustomDevice: no devices listed if only disabled devices configured', () async { final fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, [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(), [], ); }, ); testWithoutContext( 'CustomDevice: no devices listed if custom devices feature flag disabled', () async { final fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, [testConfig]); expect( await CustomDevices( featureFlags: TestFeatureFlags(), logger: BufferLogger.test(), processManager: FakeProcessManager.any(), config: CustomDevicesConfig.test( fileSystem: fs, directory: dir, logger: BufferLogger.test(), ), ).devices(), [], ); }, ); testWithoutContext('CustomDevices.devices', () async { final fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, [testConfig]); expect( await CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.list([ 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 fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, [testConfig]); var pingCommandWasExecuted = false; final discovery = CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.list([ FakeCommand( command: testConfig.pingCommand, onRun: (_) => pingCommandWasExecuted = true, stdout: testConfigPingSuccessOutput, ), ]), config: CustomDevicesConfig.test( fileSystem: fs, directory: dir, logger: BufferLogger.test(), ), ); final List discoveredDevices = await discovery.discoverDevices(); expect(discoveredDevices, hasLength(1)); expect(pingCommandWasExecuted, true); }, ); testWithoutContext( "CustomDevices.discoverDevices doesn't report device when ping command fails", () async { final fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, [testConfig]); final discovery = CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.list([ 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 fs = MemoryFileSystem.test(); final Directory dir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(dir, [testConfig]); final discovery = CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), logger: BufferLogger.test(), processManager: FakeProcessManager.list([ 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 fileSystem = MemoryFileSystem.test(); fileSystem.file('pubspec.yaml').createSync(); writePackageConfigFiles(directory: fileSystem.currentDirectory, mainLibName: 'my_app'); 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 { var bothCommandsWereExecuted = false; final device = CustomDevice( config: testConfig, logger: BufferLogger.test(), processManager: FakeProcessManager.list([ FakeCommand(command: testConfig.uninstallCommand), FakeCommand( command: testConfig.installCommand, onRun: (_) => bothCommandsWereExecuted = true, ), ]), ); expect(await device.installApp(PrebuiltLinuxApp(executable: 'exe')), true); expect(bothCommandsWereExecuted, true); }, overrides: { FileSystem: () => MemoryFileSystem.test(), ProcessManager: () => FakeProcessManager.any(), }, ); testWithoutContext( 'CustomDevicePortForwarder will run and terminate forwardPort command', () async { final forwardPortCommandCompleter = Completer(); final forwarder = CustomDevicePortForwarder( deviceName: 'testdevicename', forwardPortCommand: testConfig.forwardPortCommand!, forwardPortSuccessRegex: testConfig.forwardPortSuccessRegex!, logger: BufferLogger.test(), processManager: FakeProcessManager.list([ 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 runDebugCompleter = Completer(); final forwardPortCompleter = Completer(); final processManager = FakeProcessManager.list([ 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 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 runDebugCompleter = Completer(); final processManager = FakeProcessManager.list([ FakeCommand( command: testConfigNonForwarding.runDebugCommand, completer: runDebugCompleter, stdout: 'The Dart VM service is listening on http://192.168.178.123:12345/abcd/\n', ), ]); final 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 runDebugCompleter = Completer(); final forwardPortCompleter = Completer(); final processManager = FakeProcessManager.list([ 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 app = PrebuiltLinuxApp(executable: 'testexecutable'); final Directory configFileDir = fs.directory('custom_devices_config_dir'); _writeCustomDevicesConfigFile(configFileDir, [testConfig]); // finally start actually testing things final customDevices = CustomDevices( featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), processManager: processManager, logger: BufferLogger.test(), config: CustomDevicesConfig.test( fileSystem: fs, directory: configFileDir, logger: BufferLogger.test(), ), ); final List devices = await customDevices.discoverDevices(); expect(devices.length, 1); expect(devices.single, isA()); final 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: { FileSystem: () => MemoryFileSystem.test(), ProcessManager: () => FakeProcessManager.any(), }, ); testUsingContext( 'custom device command string interpolation end-to-end test', () async { final runDebugCompleter = Completer(); final CustomDeviceConfig config = testConfig.copyWith( platform: TargetPlatform.linux_arm64, postBuildCommand: const [ 'testpostbuild', r'--buildMode=${buildMode}', r'--icuDataPath=${icuDataPath}', r'--engineRevision=${engineRevision}', ], runDebugCommand: const [ 'testrundebug', r'--buildMode=${buildMode}', r'--icuDataPath=${icuDataPath}', r'--engineRevision=${engineRevision}', ], ); final commandArgumentsPattern = [ RegExp(r'--buildMode=.*'), RegExp(r'--icuDataPath=.*'), RegExp(r'--engineRevision=.*'), ]; final String expectedIcuDataPath = globals.artifacts!.getArtifactPath( Artifact.icuData, platform: config.platform, ); final String expectedEngineRevision = globals.flutterVersion.engineRevision; final expectedCommandArguments = [ '--buildMode=debug', '--icuDataPath=$expectedIcuDataPath', '--engineRevision=$expectedEngineRevision', ]; final expectedRunDebugCommand = ['testrundebug', ...expectedCommandArguments]; final expectedPostBuildCommand = ['testpostbuild', ...expectedCommandArguments]; final processManager = FakeProcessManager.list([ FakeCommand( command: ['testpostbuild', ...commandArgumentsPattern], onRun: (List command) => expect(command, expectedPostBuildCommand), ), FakeCommand(command: config.uninstallCommand), FakeCommand(command: config.installCommand), FakeCommand( command: ['testrundebug', ...commandArgumentsPattern], completer: runDebugCompleter, onRun: (List command) => expect(command, expectedRunDebugCommand), stdout: 'The Dart VM service is listening on http://127.0.0.1:12345/abcd/\n', ), FakeCommand( command: config.forwardPortCommand!, stdout: testConfigForwardPortSuccessOutput, ), ]); // CustomDevice.startApp doesn't care whether we pass a prebuilt app or // buildable app as long as we pass prebuiltApplication as false final app = PrebuiltLinuxApp(executable: 'testexecutable'); // finally start actually testing things final device = CustomDevice( config: config, logger: BufferLogger.test(), processManager: processManager, ); await device.startApp( app, debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), bundleBuilder: FakeBundleBuilder(), ); expect(runDebugCompleter.isCompleted, false); expect(await device.stopApp(app), true); expect(runDebugCompleter.isCompleted, true); }, overrides: { FileSystem: () => MemoryFileSystem.test(), ProcessManager: () => FakeProcessManager.any(), }, ); testWithoutContext('CustomDevice screenshotting', () async { var screenshotCommandWasExecuted = false; final processManager = FakeProcessManager.list([ FakeCommand( command: testConfig.screenshotCommand!, onRun: (_) => screenshotCommandWasExecuted = true, ), ]); final fs = MemoryFileSystem.test(); final File screenshotFile = fs.file('screenshot.png'); final 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 { var screenshotCommandWasExecuted = false; final processManager = FakeProcessManager.list([ FakeCommand( command: testConfig.screenshotCommand!, onRun: (_) => screenshotCommandWasExecuted = true, ), ]); final fs = MemoryFileSystem.test(); final File screenshotFile = fs.file('screenshot.png'); final device = CustomDevice( config: testConfig.copyWith(explicitScreenshotCommand: true), logger: BufferLogger.test(), processManager: processManager, ); expect(device.supportsScreenshot, false); expect( () => device.takeScreenshot(screenshotFile), throwsA(const TypeMatcher()), ); expect(screenshotCommandWasExecuted, false); expect(screenshotFile.existsSync(), false); }); testWithoutContext('CustomDevice returns correct target platform', () async { final 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 logReader = CustomDeviceLogReader('testname'); final lines = Iterable>.generate(5, (int _) => utf8.encode('test')); logReader.listenToProcessOutput( FakeProcess( exitCode: Future.value(0), stdout: Stream>.fromIterable(lines), stderr: Stream>.fromIterable(lines), ), ); final subscriptions = >[]; var logLinesStreamDone = false; logReader.logLines.listen( (_) {}, onDone: () { expect(subscriptions, everyElement((MyFakeStreamSubscription s) => s.canceled)); logLinesStreamDone = true; }, ); logReader.subscriptions.replaceRange( 0, logReader.subscriptions.length, logReader.subscriptions.map( (StreamSubscription e) => MyFakeStreamSubscription(e), ), ); subscriptions.addAll(logReader.subscriptions.cast()); await logReader.dispose(); expect(logLinesStreamDone, true); }, ); } class MyFakeStreamSubscription extends Fake implements StreamSubscription { MyFakeStreamSubscription(this.parent); StreamSubscription parent; bool canceled = false; @override Future cancel() { canceled = true; return parent.cancel(); } } class FakeBundleBuilder extends Fake implements BundleBuilder { @override Future build({ TargetPlatform? platform, BuildInfo? buildInfo, FlutterProject? project, String? mainPath, String manifestPath = defaultManifestPath, String? applicationKernelFilePath, String? depfilePath, String? assetDirPath, Uri? nativeAssets, bool buildNativeAssets = true, @visibleForTesting BuildSystem? buildSystem, }) async {} }