blob: 1fd6abd80b3ccd3b6713cb4375ec67cf993461af [file] [log] [blame]
// Copyright 2013 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' as io;
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/common/file_utils.dart';
import 'package:flutter_plugin_tools/src/common/plugin_utils.dart';
import 'package:flutter_plugin_tools/src/common/process_runner.dart';
import 'package:flutter_plugin_tools/src/common/repository_package.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:quiver/collection.dart';
import 'mocks.dart';
export 'package:flutter_plugin_tools/src/common/repository_package.dart';
const String _defaultDartConstraint = '>=2.14.0 <4.0.0';
const String _defaultFlutterConstraint = '>=2.5.0';
/// Returns the exe name that command will use when running Flutter on
/// [platform].
String getFlutterCommand(Platform platform) =>
platform.isWindows ? 'flutter.bat' : 'flutter';
/// Creates a packages directory in the given location.
///
/// If [parentDir] is set the packages directory will be created there,
/// otherwise [fileSystem] must be provided and it will be created an arbitrary
/// location in that filesystem.
Directory createPackagesDirectory(
{Directory? parentDir, FileSystem? fileSystem}) {
assert(parentDir != null || fileSystem != null,
'One of parentDir or fileSystem must be provided');
assert(fileSystem == null || fileSystem is MemoryFileSystem,
'If using a real filesystem, parentDir must be provided');
final Directory packagesDir =
(parentDir ?? fileSystem!.currentDirectory).childDirectory('packages');
packagesDir.createSync();
return packagesDir;
}
/// Details for platform support in a plugin.
@immutable
class PlatformDetails {
const PlatformDetails(
this.type, {
this.hasNativeCode = true,
this.hasDartCode = false,
});
/// The type of support for the platform.
final PlatformSupport type;
/// Whether or not the plugin includes native code.
///
/// Ignored for web, which does not have native code.
final bool hasNativeCode;
/// Whether or not the plugin includes Dart code.
///
/// Ignored for web, which always has native code.
final bool hasDartCode;
}
/// Returns the 'example' directory for [package].
///
/// This is deliberately not a method on [RepositoryPackage] since actual tool
/// code should essentially never need this, and instead be using
/// [RepositoryPackage.getExamples] to avoid assuming there's a single example
/// directory. However, needing to construct paths with the example directory
/// is very common in test code.
///
/// This returns a Directory rather than a RepositoryPackage because there is no
/// guarantee that the returned directory is a package.
Directory getExampleDir(RepositoryPackage package) {
return package.directory.childDirectory('example');
}
/// Creates a plugin package with the given [name] in [packagesDirectory].
///
/// [platformSupport] is a map of platform string to the support details for
/// that platform.
///
/// [extraFiles] is an optional list of plugin-relative paths, using Posix
/// separators, of extra files to create in the plugin.
RepositoryPackage createFakePlugin(
String name,
Directory parentDirectory, {
List<String> examples = const <String>['example'],
List<String> extraFiles = const <String>[],
Map<String, PlatformDetails> platformSupport =
const <String, PlatformDetails>{},
String? version = '0.0.1',
String flutterConstraint = _defaultFlutterConstraint,
String dartConstraint = _defaultDartConstraint,
}) {
final RepositoryPackage package = createFakePackage(
name,
parentDirectory,
isFlutter: true,
examples: examples,
extraFiles: extraFiles,
version: version,
flutterConstraint: flutterConstraint,
dartConstraint: dartConstraint,
);
createFakePubspec(
package,
name: name,
isPlugin: true,
platformSupport: platformSupport,
version: version,
flutterConstraint: flutterConstraint,
dartConstraint: dartConstraint,
);
return package;
}
/// Creates a plugin package with the given [name] in [packagesDirectory].
///
/// [extraFiles] is an optional list of package-relative paths, using unix-style
/// separators, of extra files to create in the package.
///
/// If [includeCommonFiles] is true, common but non-critical files like
/// CHANGELOG.md, README.md, and AUTHORS will be included.
///
/// If non-null, [directoryName] will be used for the directory instead of
/// [name].
RepositoryPackage createFakePackage(
String name,
Directory parentDirectory, {
List<String> examples = const <String>['example'],
List<String> extraFiles = const <String>[],
bool isFlutter = false,
String? version = '0.0.1',
String flutterConstraint = _defaultFlutterConstraint,
String dartConstraint = _defaultDartConstraint,
bool includeCommonFiles = true,
String? directoryName,
String? publishTo,
}) {
final RepositoryPackage package =
RepositoryPackage(parentDirectory.childDirectory(directoryName ?? name));
package.directory.createSync(recursive: true);
package.libDirectory.createSync();
createFakePubspec(package,
name: name,
isFlutter: isFlutter,
version: version,
flutterConstraint: flutterConstraint,
dartConstraint: dartConstraint,
publishTo: publishTo);
if (includeCommonFiles) {
package.changelogFile.writeAsStringSync('''
## $version
* Some changes.
''');
package.readmeFile.writeAsStringSync('A very useful package');
package.authorsFile.writeAsStringSync('Google Inc.');
}
if (examples.length == 1) {
createFakePackage('${name}_example', package.directory,
directoryName: examples.first,
examples: <String>[],
includeCommonFiles: false,
isFlutter: isFlutter,
publishTo: 'none',
flutterConstraint: flutterConstraint,
dartConstraint: dartConstraint);
} else if (examples.isNotEmpty) {
final Directory examplesDirectory = getExampleDir(package)..createSync();
for (final String exampleName in examples) {
createFakePackage(exampleName, examplesDirectory,
examples: <String>[],
includeCommonFiles: false,
isFlutter: isFlutter,
publishTo: 'none',
flutterConstraint: flutterConstraint,
dartConstraint: dartConstraint);
}
}
final p.Context posixContext = p.posix;
for (final String file in extraFiles) {
childFileWithSubcomponents(package.directory, posixContext.split(file))
.createSync(recursive: true);
}
return package;
}
/// Creates a `pubspec.yaml` file for [package].
///
/// [platformSupport] is a map of platform string to the support details for
/// that platform. If empty, no `plugin` entry will be created unless `isPlugin`
/// is set to `true`.
void createFakePubspec(
RepositoryPackage package, {
String name = 'fake_package',
bool isFlutter = true,
bool isPlugin = false,
Map<String, PlatformDetails> platformSupport =
const <String, PlatformDetails>{},
String? publishTo,
String? version,
String dartConstraint = _defaultDartConstraint,
String flutterConstraint = _defaultFlutterConstraint,
}) {
isPlugin |= platformSupport.isNotEmpty;
String environmentSection = '''
environment:
sdk: "$dartConstraint"
''';
String dependenciesSection = '''
dependencies:
''';
String pluginSection = '';
// Add Flutter-specific entries if requested.
if (isFlutter) {
environmentSection += '''
flutter: "$flutterConstraint"
''';
dependenciesSection += '''
flutter:
sdk: flutter
''';
if (isPlugin) {
pluginSection += '''
flutter:
plugin:
platforms:
''';
for (final MapEntry<String, PlatformDetails> platform
in platformSupport.entries) {
pluginSection +=
_pluginPlatformSection(platform.key, platform.value, name);
}
}
}
// Default to a fake server to avoid ever accidentally publishing something
// from a test. Does not use 'none' since that changes the behavior of some
// commands.
final String publishToSection =
'publish_to: ${publishTo ?? 'http://no_pub_server.com'}';
final String yaml = '''
name: $name
${(version != null) ? 'version: $version' : ''}
$publishToSection
$environmentSection
$dependenciesSection
$pluginSection
''';
package.pubspecFile.createSync();
package.pubspecFile.writeAsStringSync(yaml);
}
String _pluginPlatformSection(
String platform, PlatformDetails support, String packageName) {
String entry = '';
// Build the main plugin entry.
if (support.type == PlatformSupport.federated) {
entry = '''
$platform:
default_package: ${packageName}_$platform
''';
} else {
final List<String> lines = <String>[
' $platform:',
];
switch (platform) {
case platformAndroid:
lines.add(' package: io.flutter.plugins.fake');
continue nativeByDefault;
nativeByDefault:
case platformIOS:
case platformLinux:
case platformMacOS:
case platformWindows:
if (support.hasNativeCode) {
final String className =
platform == platformIOS ? 'FLTFakePlugin' : 'FakePlugin';
lines.add(' pluginClass: $className');
}
if (support.hasDartCode) {
lines.add(' dartPluginClass: FakeDartPlugin');
}
break;
case platformWeb:
lines.addAll(<String>[
' pluginClass: FakePlugin',
' fileName: ${packageName}_web.dart',
]);
break;
default:
assert(false, 'Unrecognized platform: $platform');
break;
}
entry = '${lines.join('\n')}\n';
}
return entry;
}
/// Run the command [runner] with the given [args] and return
/// what was printed.
/// A custom [errorHandler] can be used to handle the runner error as desired without throwing.
Future<List<String>> runCapturingPrint(
CommandRunner<void> runner,
List<String> args, {
Function(Error error)? errorHandler,
Function(Exception error)? exceptionHandler,
}) async {
final List<String> prints = <String>[];
final ZoneSpecification spec = ZoneSpecification(
print: (_, __, ___, String message) {
prints.add(message);
},
);
try {
await Zone.current
.fork(specification: spec)
.run<Future<void>>(() => runner.run(args));
} on Error catch (e) {
if (errorHandler == null) {
rethrow;
}
errorHandler(e);
} on Exception catch (e) {
if (exceptionHandler == null) {
rethrow;
}
exceptionHandler(e);
}
return prints;
}
/// Information about a process to return from [RecordingProcessRunner].
class FakeProcessInfo {
const FakeProcessInfo(this.process,
[this.expectedInitialArgs = const <String>[]]);
/// The process to return.
final io.Process process;
/// The expected start of the argument array for the call.
///
/// This does not have to be a full list of arguments, only enough of the
/// start to ensure that the call is as expected.
final List<String> expectedInitialArgs;
}
/// A mock [ProcessRunner] which records process calls.
class RecordingProcessRunner extends ProcessRunner {
final List<ProcessCall> recordedCalls = <ProcessCall>[];
/// Maps an executable to a list of processes that should be used for each
/// successive call to it via [run], [runAndStream], or [start].
///
/// If `expectedInitialArgs` are provided for a fake process, trying to
/// return that process when the arguments don't match will throw a
/// [StateError]. This allows tests to enforce that the fake results are
/// for the expected calls when the process name itself isn't enough to tell
/// (e.g., multiple different `dart` or `flutter` calls), without going all
/// the way to a complex argument matching scheme that can make tests
/// difficult to write and debug.
final Map<String, List<FakeProcessInfo>> mockProcessesForExecutable =
<String, List<FakeProcessInfo>>{};
@override
Future<int> runAndStream(
String executable,
List<String> args, {
Directory? workingDir,
Map<String, String>? environment,
bool exitOnError = false,
}) async {
recordedCalls.add(ProcessCall(executable, args, workingDir?.path));
final io.Process? processToReturn = _getProcessToReturn(executable, args);
final int exitCode =
processToReturn == null ? 0 : await processToReturn.exitCode;
if (exitOnError && (exitCode != 0)) {
throw io.ProcessException(executable, args);
}
return Future<int>.value(exitCode);
}
/// Returns [io.ProcessResult] created from [mockProcessesForExecutable].
@override
Future<io.ProcessResult> run(
String executable,
List<String> args, {
Directory? workingDir,
Map<String, String>? environment,
bool exitOnError = false,
bool logOnError = false,
Encoding stdoutEncoding = io.systemEncoding,
Encoding stderrEncoding = io.systemEncoding,
}) async {
recordedCalls.add(ProcessCall(executable, args, workingDir?.path));
final io.Process? process = _getProcessToReturn(executable, args);
final List<String>? processStdout =
await process?.stdout.transform(stdoutEncoding.decoder).toList();
final String stdout = processStdout?.join() ?? '';
final List<String>? processStderr =
await process?.stderr.transform(stderrEncoding.decoder).toList();
final String stderr = processStderr?.join() ?? '';
final io.ProcessResult result = process == null
? io.ProcessResult(1, 0, '', '')
: io.ProcessResult(process.pid, await process.exitCode, stdout, stderr);
if (exitOnError && (result.exitCode != 0)) {
throw io.ProcessException(executable, args);
}
return Future<io.ProcessResult>.value(result);
}
@override
Future<io.Process> start(String executable, List<String> args,
{Directory? workingDirectory}) async {
recordedCalls.add(ProcessCall(executable, args, workingDirectory?.path));
return Future<io.Process>.value(
_getProcessToReturn(executable, args) ?? MockProcess());
}
io.Process? _getProcessToReturn(String executable, List<String> args) {
final List<FakeProcessInfo> fakes =
mockProcessesForExecutable[executable] ?? <FakeProcessInfo>[];
if (fakes.isNotEmpty) {
final FakeProcessInfo fake = fakes.removeAt(0);
if (args.length < fake.expectedInitialArgs.length ||
!listsEqual(args.sublist(0, fake.expectedInitialArgs.length),
fake.expectedInitialArgs)) {
throw StateError('Next fake process for $executable expects arguments '
'[${fake.expectedInitialArgs.join(', ')}] but was called with '
'arguments [${args.join(', ')}]');
}
return fake.process;
}
return null;
}
}
/// A recorded process call.
@immutable
class ProcessCall {
const ProcessCall(this.executable, this.args, this.workingDir);
/// The executable that was called.
final String executable;
/// The arguments passed to [executable] in the call.
final List<String> args;
/// The working directory this process was called from.
final String? workingDir;
@override
bool operator ==(Object other) {
return other is ProcessCall &&
executable == other.executable &&
listsEqual(args, other.args) &&
workingDir == other.workingDir;
}
@override
int get hashCode => Object.hash(executable, args, workingDir);
@override
String toString() {
final List<String> command = <String>[executable, ...args];
return '"${command.join(' ')}" in $workingDir';
}
}