// 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: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/plugin_command.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:git/git.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:platform/platform.dart'; import 'package:test/test.dart'; import '../mocks.dart'; import '../util.dart'; import 'plugin_command_test.mocks.dart'; @GenerateMocks([GitDir]) void main() { late RecordingProcessRunner processRunner; late SamplePluginCommand command; late CommandRunner runner; late FileSystem fileSystem; late MockPlatform mockPlatform; late Directory packagesDir; late Directory thirdPartyPackagesDir; setUp(() { fileSystem = MemoryFileSystem(); mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); thirdPartyPackagesDir = packagesDir.parent .childDirectory('third_party') .childDirectory('packages'); final MockGitDir gitDir = MockGitDir(); when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) .thenAnswer((Invocation invocation) { final List arguments = invocation.positionalArguments[0]! as List; // Attach the first argument to the command to make targeting the mock // results easier. final String gitCommand = arguments.removeAt(0); return processRunner.run('git-$gitCommand', arguments); }); processRunner = RecordingProcessRunner(); command = SamplePluginCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, gitDir: gitDir, ); runner = CommandRunner('common_command', 'Test for common functionality'); runner.addCommand(command); }); group('plugin iteration', () { test('all plugins from file system', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('includes both plugins and packages', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); final Directory package3 = createFakePackage('package3', packagesDir); final Directory package4 = createFakePackage('package4', packagesDir); await runCapturingPrint(runner, ['sample']); expect( command.plugins, unorderedEquals([ plugin1.path, plugin2.path, package3.path, package4.path, ])); }); test('all plugins includes third_party/packages', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); final Directory plugin3 = createFakePlugin('plugin3', thirdPartyPackagesDir); await runCapturingPrint(runner, ['sample']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path, plugin3.path])); }); test('--packages limits packages', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); createFakePackage('package3', packagesDir); final Directory package4 = createFakePackage('package4', packagesDir); await runCapturingPrint( runner, ['sample', '--packages=plugin1,package4']); expect( command.plugins, unorderedEquals([ plugin1.path, package4.path, ])); }); test('--plugins acts as an alias to --packages', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); createFakePackage('package3', packagesDir); final Directory package4 = createFakePackage('package4', packagesDir); await runCapturingPrint( runner, ['sample', '--plugins=plugin1,package4']); expect( command.plugins, unorderedEquals([ plugin1.path, package4.path, ])); }); test('exclude packages when packages flag is specified', () async { createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ 'sample', '--packages=plugin1,plugin2', '--exclude=plugin1' ]); expect(command.plugins, unorderedEquals([plugin2.path])); }); test('exclude packages when packages flag isn\'t specified', () async { createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); await runCapturingPrint( runner, ['sample', '--exclude=plugin1,plugin2']); expect(command.plugins, unorderedEquals([])); }); test('exclude federated plugins when packages flag is specified', () async { createFakePlugin('plugin1', packagesDir.childDirectory('federated')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ 'sample', '--packages=federated/plugin1,plugin2', '--exclude=federated/plugin1' ]); expect(command.plugins, unorderedEquals([plugin2.path])); }); test('exclude entire federated plugins when packages flag is specified', () async { createFakePlugin('plugin1', packagesDir.childDirectory('federated')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ 'sample', '--packages=federated/plugin1,plugin2', '--exclude=federated' ]); expect(command.plugins, unorderedEquals([plugin2.path])); }); test('exclude accepts config files', () async { createFakePlugin('plugin1', packagesDir); final File configFile = packagesDir.childFile('exclude.yaml'); configFile.writeAsStringSync('- plugin1'); await runCapturingPrint(runner, [ 'sample', '--packages=plugin1', '--exclude=${configFile.path}' ]); expect(command.plugins, unorderedEquals([])); }); group('conflicting package selection', () { test('does not allow --packages with --run-on-changed-packages', () async { Error? commandError; final List output = await runCapturingPrint(runner, [ 'sample', '--run-on-changed-packages', '--packages=plugin1', ], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('Only one of --packages, --run-on-changed-packages, or ' '--packages-for-branch can be provided.') ])); }); test('does not allow --packages with --packages-for-branch', () async { Error? commandError; final List output = await runCapturingPrint(runner, [ 'sample', '--packages-for-branch', '--packages=plugin1', ], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('Only one of --packages, --run-on-changed-packages, or ' '--packages-for-branch can be provided.') ])); }); test( 'does not allow --run-on-changed-packages with --packages-for-branch', () async { Error? commandError; final List output = await runCapturingPrint(runner, [ 'sample', '--packages-for-branch', '--packages=plugin1', ], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('Only one of --packages, --run-on-changed-packages, or ' '--packages-for-branch can be provided.') ])); }); }); group('test run-on-changed-packages', () { test('all plugins should be tested if there are no changes.', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test( 'all plugins should be tested if there are no plugin related changes.', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: 'AUTHORS'), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if .cirrus.yml changes.', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' .cirrus.yml packages/plugin1/CHANGELOG '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if .ci.yaml changes', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' .ci.yaml packages/plugin1/CHANGELOG '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if anything in .ci/ changes', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' .ci/Dockerfile packages/plugin1/CHANGELOG '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if anything in script changes.', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' script/tool_runner.sh packages/plugin1/CHANGELOG '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if the root analysis options change.', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' analysis_options.yaml packages/plugin1/CHANGELOG '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if formatting options change.', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' .clang-format packages/plugin1/CHANGELOG '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('Only changed plugin should be tested.', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: 'packages/plugin1/plugin1.dart'), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); final List output = await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect( output, containsAllInOrder([ contains( 'Running for all packages that have changed relative to "main"'), ])); expect(command.plugins, unorderedEquals([plugin1.path])); }); test('multiple files in one plugin should also test the plugin', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' packages/plugin1/plugin1.dart packages/plugin1/ios/plugin1.m '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path])); }); test('multiple plugins changed should test all the changed plugins', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test( 'multiple plugins inside the same plugin group changed should output the plugin group name', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' packages/plugin1/plugin1/plugin1.dart packages/plugin1/plugin1_platform_interface/plugin1_platform_interface.dart packages/plugin1/plugin1_web/plugin1_web.dart '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect(command.plugins, unorderedEquals([plugin1.path])); }); test( 'changing one plugin in a federated group should include all plugins in the group', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' packages/plugin1/plugin1/plugin1.dart '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); final Directory plugin2 = createFakePlugin('plugin1_platform_interface', packagesDir.childDirectory('plugin1')); final Directory plugin3 = createFakePlugin( 'plugin1_web', packagesDir.childDirectory('plugin1')); await runCapturingPrint(runner, ['sample', '--base-sha=main', '--run-on-changed-packages']); expect( command.plugins, unorderedEquals( [plugin1.path, plugin2.path, plugin3.path])); }); test('--exclude flag works with --run-on-changed-packages', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: ''' packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m packages/plugin3/plugin3.dart '''), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); await runCapturingPrint(runner, [ 'sample', '--exclude=plugin2,plugin3', '--base-sha=main', '--run-on-changed-packages' ]); expect(command.plugins, unorderedEquals([plugin1.path])); }); }); }); group('--packages-for-branch', () { test('only tests changed packages on a branch', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: 'packages/plugin1/plugin1.dart'), ]; processRunner.mockProcessesForExecutable['git-rev-parse'] = [ MockProcess(stdout: 'a-branch'), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); final List output = await runCapturingPrint( runner, ['sample', '--packages-for-branch']); expect(command.plugins, unorderedEquals([plugin1.path])); expect( output, containsAllInOrder([ contains('--packages-for-branch: running on changed packages'), ])); }); test('tests all packages on main', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: 'packages/plugin1/plugin1.dart'), ]; processRunner.mockProcessesForExecutable['git-rev-parse'] = [ MockProcess(stdout: 'main'), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); final List output = await runCapturingPrint( runner, ['sample', '--packages-for-branch']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); expect( output, containsAllInOrder([ contains('--packages-for-branch: running on all packages'), ])); }); test('tests all packages on master', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: 'packages/plugin1/plugin1.dart'), ]; processRunner.mockProcessesForExecutable['git-rev-parse'] = [ MockProcess(stdout: 'master'), ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); final List output = await runCapturingPrint( runner, ['sample', '--packages-for-branch']); expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path])); expect( output, containsAllInOrder([ contains('--packages-for-branch: running on all packages'), ])); }); test('throws if getting the branch fails', () async { processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: 'packages/plugin1/plugin1.dart'), ]; processRunner.mockProcessesForExecutable['git-rev-parse'] = [ MockProcess(exitCode: 1), ]; Error? commandError; final List output = await runCapturingPrint( runner, ['sample', '--packages-for-branch'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('Unabled to determine branch'), ])); }); }); group('sharding', () { test('distributes evenly when evenly divisible', () async { final List> expectedShards = >[ [ createFakePackage('package1', packagesDir), createFakePackage('package2', packagesDir), createFakePackage('package3', packagesDir), ], [ createFakePackage('package4', packagesDir), createFakePackage('package5', packagesDir), createFakePackage('package6', packagesDir), ], [ createFakePackage('package7', packagesDir), createFakePackage('package8', packagesDir), createFakePackage('package9', packagesDir), ], ]; for (int i = 0; i < expectedShards.length; ++i) { final SamplePluginCommand localCommand = SamplePluginCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, gitDir: MockGitDir(), ); final CommandRunner localRunner = CommandRunner('common_command', 'Shard testing'); localRunner.addCommand(localCommand); await runCapturingPrint(localRunner, [ 'sample', '--shardIndex=$i', '--shardCount=3', ]); expect( localCommand.plugins, unorderedEquals(expectedShards[i] .map((Directory packageDir) => packageDir.path) .toList())); } }); test('distributes as evenly as possible when not evenly divisible', () async { final List> expectedShards = >[ [ createFakePackage('package1', packagesDir), createFakePackage('package2', packagesDir), createFakePackage('package3', packagesDir), ], [ createFakePackage('package4', packagesDir), createFakePackage('package5', packagesDir), createFakePackage('package6', packagesDir), ], [ createFakePackage('package7', packagesDir), createFakePackage('package8', packagesDir), ], ]; for (int i = 0; i < expectedShards.length; ++i) { final SamplePluginCommand localCommand = SamplePluginCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, gitDir: MockGitDir(), ); final CommandRunner localRunner = CommandRunner('common_command', 'Shard testing'); localRunner.addCommand(localCommand); await runCapturingPrint(localRunner, [ 'sample', '--shardIndex=$i', '--shardCount=3', ]); expect( localCommand.plugins, unorderedEquals(expectedShards[i] .map((Directory packageDir) => packageDir.path) .toList())); } }); // In CI (which is the use case for sharding) we often want to run muliple // commands on the same set of packages, but the exclusion lists for those // commands may be different. In those cases we still want all the commands // to operate on a consistent set of plugins. // // E.g., some commands require running build-examples in a previous step; // excluding some plugins from the later step shouldn't change what's tested // in each shard, as it may no longer align with what was built. test('counts excluded plugins when sharding', () async { final List> expectedShards = >[ [ createFakePackage('package1', packagesDir), createFakePackage('package2', packagesDir), createFakePackage('package3', packagesDir), ], [ createFakePackage('package4', packagesDir), createFakePackage('package5', packagesDir), createFakePackage('package6', packagesDir), ], [ createFakePackage('package7', packagesDir), ], ]; // These would be in the last shard, but are excluded. createFakePackage('package8', packagesDir); createFakePackage('package9', packagesDir); for (int i = 0; i < expectedShards.length; ++i) { final SamplePluginCommand localCommand = SamplePluginCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, gitDir: MockGitDir(), ); final CommandRunner localRunner = CommandRunner('common_command', 'Shard testing'); localRunner.addCommand(localCommand); await runCapturingPrint(localRunner, [ 'sample', '--shardIndex=$i', '--shardCount=3', '--exclude=package8,package9', ]); expect( localCommand.plugins, unorderedEquals(expectedShards[i] .map((Directory packageDir) => packageDir.path) .toList())); } }); }); } class SamplePluginCommand extends PluginCommand { SamplePluginCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Platform platform = const LocalPlatform(), GitDir? gitDir, }) : super(packagesDir, processRunner: processRunner, platform: platform, gitDir: gitDir); final List plugins = []; @override final String name = 'sample'; @override final String description = 'sample command'; @override Future run() async { await for (final PackageEnumerationEntry entry in getTargetPackages()) { plugins.add(entry.package.path); } } }