// 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 '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/federation_safety_check_command.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; import 'common/package_command_test.mocks.dart'; import 'mocks.dart'; import 'util.dart'; void main() { FileSystem fileSystem; late MockPlatform mockPlatform; late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; setUp(() { fileSystem = MemoryFileSystem(); mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); final MockGitDir gitDir = MockGitDir(); when(gitDir.path).thenReturn(packagesDir.parent.path); when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) .thenAnswer((Invocation invocation) { final List arguments = invocation.positionalArguments[0]! as List; // Route git calls through the process runner, to make mock output // consistent with other processes. 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(); final FederationSafetyCheckCommand command = FederationSafetyCheckCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, gitDir: gitDir); runner = CommandRunner('federation_safety_check_command', 'Test for $FederationSafetyCheckCommand'); runner.addCommand(command); }); test('skips non-plugin packages', () async { final RepositoryPackage package = createFakePackage('foo', packagesDir); final String changedFileOutput = [ package.libDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo...'), contains('Not a plugin'), contains('Skipped 1 package(s)'), ]), ); }); test('skips unfederated plugins', () async { final RepositoryPackage package = createFakePlugin('foo', packagesDir); final String changedFileOutput = [ package.libDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo...'), contains('Not a federated plugin'), contains('Skipped 1 package(s)'), ]), ); }); test('skips interface packages', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage platformInterface = createFakePlugin('foo_platform_interface', pluginGroupDir); final String changedFileOutput = [ platformInterface.libDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo_platform_interface...'), contains('Platform interface changes are not validated.'), contains('Skipped 1 package(s)'), ]), ); }); test('allows changes to just an interface package', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage platformInterface = createFakePlugin('foo_platform_interface', pluginGroupDir); createFakePlugin('foo', pluginGroupDir); createFakePlugin('foo_ios', pluginGroupDir); createFakePlugin('foo_android', pluginGroupDir); final String changedFileOutput = [ platformInterface.libDirectory.childFile('foo.dart'), platformInterface.pubspecFile, ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo/foo...'), contains('No Dart changes.'), contains('Running for foo_android...'), contains('No Dart changes.'), contains('Running for foo_ios...'), contains('No Dart changes.'), contains('Running for foo_platform_interface...'), contains('Ran for 3 package(s)'), contains('Skipped 1 package(s)'), ]), ); expect( output, isNot(contains([ contains('No published changes for foo_platform_interface'), ])), ); }); test('allows changes to multiple non-interface packages', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage appFacing = createFakePlugin('foo', pluginGroupDir); final RepositoryPackage implementation = createFakePlugin('foo_bar', pluginGroupDir); createFakePlugin('foo_platform_interface', pluginGroupDir); final String changedFileOutput = [ appFacing.libDirectory.childFile('foo.dart'), implementation.libDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo/foo...'), contains('No published changes for foo_platform_interface.'), contains('Running for foo_bar...'), contains('No published changes for foo_platform_interface.'), ]), ); }); test( 'fails on changes to interface and non-interface packages in the same plugin', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage appFacing = createFakePlugin('foo', pluginGroupDir); final RepositoryPackage implementation = createFakePlugin('foo_bar', pluginGroupDir); final RepositoryPackage platformInterface = createFakePlugin('foo_platform_interface', pluginGroupDir); const String appFacingChanges = ''' diff --git a/packages/foo/foo/lib/foo.dart b/packages/foo/foo/lib/foo.dart index abc123..def456 100644 --- a/packages/foo/foo/lib/foo.dart +++ b/packages/foo/foo/lib/foo.dart @@ -51,6 +51,9 @@ Future launchUrl( return true; } +// This is a new method +bool foo() => true; + // This in an existing method void aMethod() { // Do things. '''; final String changedFileOutput = [ appFacing.libDirectory.childFile('foo.dart'), implementation.libDirectory.childFile('foo.dart'), platformInterface.pubspecFile, platformInterface.libDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), // Ensure that a change with both a comment and non-comment addition is // counted, to validate change analysis. FakeProcessInfo(MockProcess(stdout: appFacingChanges), ['', 'HEAD', '--', '/packages/foo/foo/lib/foo.dart']), // The others diffs don't need to be specified, since empty diff is also // treated as a non-comment change. ]; Error? commandError; final List output = await runCapturingPrint( runner, ['federation-safety-check'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('Running for foo/foo...'), contains('Dart changes are not allowed to other packages in foo in the ' 'same PR as changes to public Dart code in foo_platform_interface, ' 'as this can cause accidental breaking changes to be missed by ' 'automated checks. Please split the changes to these two packages ' 'into separate PRs.'), contains('Running for foo_bar...'), contains('Dart changes are not allowed to other packages in foo'), contains('The following packages had errors:'), contains('foo/foo:\n' ' foo_platform_interface changed.'), contains('foo_bar:\n' ' foo_platform_interface changed.'), ]), ); }); test('ignores test-only changes to interface packages', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage appFacing = createFakePlugin('foo', pluginGroupDir); final RepositoryPackage implementation = createFakePlugin('foo_bar', pluginGroupDir); final RepositoryPackage platformInterface = createFakePlugin('foo_platform_interface', pluginGroupDir); final String changedFileOutput = [ appFacing.libDirectory.childFile('foo.dart'), implementation.libDirectory.childFile('foo.dart'), platformInterface.pubspecFile, platformInterface.testDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo/foo...'), contains('No public code changes for foo_platform_interface.'), contains('Running for foo_bar...'), contains('No public code changes for foo_platform_interface.'), ]), ); }); test('ignores unpublished changes to interface packages', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage appFacing = createFakePlugin('foo', pluginGroupDir); final RepositoryPackage implementation = createFakePlugin('foo_bar', pluginGroupDir); final RepositoryPackage platformInterface = createFakePlugin('foo_platform_interface', pluginGroupDir); final String changedFileOutput = [ appFacing.libDirectory.childFile('foo.dart'), implementation.libDirectory.childFile('foo.dart'), platformInterface.pubspecFile, platformInterface.libDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; // Simulate no change to the version in the interface's pubspec.yaml. processRunner.mockProcessesForExecutable['git-show'] = [ FakeProcessInfo(MockProcess( stdout: platformInterface.pubspecFile.readAsStringSync())), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo/foo...'), contains('No published changes for foo_platform_interface.'), contains('Running for foo_bar...'), contains('No published changes for foo_platform_interface.'), ]), ); }); test('ignores comment-only changes in implementation packages', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage implementation = createFakePlugin('foo_bar', pluginGroupDir); final RepositoryPackage platformInterface = createFakePlugin('foo_platform_interface', pluginGroupDir); final String changedFileOutput = [ implementation.libDirectory.childFile('foo.dart'), platformInterface.pubspecFile, platformInterface.libDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); const String platformInterfaceChanges = ''' diff --git a/packages/foo/foo_platform_interface/lib/foo.dart b/packages/foo/foo_platform_interface/lib/foo.dart index abc123..def456 100644 --- a/packages/foo/foo_platform_interface/lib/foo.dart +++ b/packages/foo/foo_platform_interface/lib/foo.dart @@ -51,6 +51,7 @@ Future launchUrl( enum Foo { a, b, + c, d, e, } '''; const String implementationChanges = ''' diff --git a/packages/foo/foo_bar/lib/foo.dart b/packages/foo/foo_bar/lib/foo.dart index abc123..def456 100644 --- a/packages/foo/foo_bar/lib/foo.dart +++ b/packages/foo/foo_bar/lib/foo.dart @@ -51,6 +51,7 @@ Future launchUrl( } void foo() { + // ignore: exhaustive_cases switch(a_foo) { case a: // Do things '''; processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo( MockProcess(stdout: changedFileOutput), ['--name-only']), FakeProcessInfo(MockProcess(stdout: implementationChanges), ['', 'HEAD', '--', '/packages/foo/foo_bar/lib/foo.dart']), FakeProcessInfo(MockProcess(stdout: platformInterfaceChanges), [ '', 'HEAD', '--', '/packages/foo/foo_platform_interface/lib/foo.dart' ]), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo_bar...'), contains('No Dart changes.'), ]), ); }); test('ignores comment-only changes in platform interface packages', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage implementation = createFakePlugin('foo_bar', pluginGroupDir); final RepositoryPackage platformInterface = createFakePlugin('foo_platform_interface', pluginGroupDir); final String changedFileOutput = [ implementation.libDirectory.childFile('foo.dart'), platformInterface.pubspecFile, platformInterface.libDirectory.childFile('foo.dart'), ].map((File file) => file.path).join('\n'); const String platformInterfaceChanges = ''' diff --git a/packages/foo/foo_platform_interface/lib/foo.dart b/packages/foo/foo_platform_interface/lib/foo.dart index abc123..def456 100644 --- a/packages/foo/foo_platform_interface/lib/foo.dart +++ b/packages/foo/foo_platform_interface/lib/foo.dart @@ -51,6 +51,8 @@ Future launchUrl( // existing comment // existing comment // existing comment + // + // additional comment void foo() { some code; } '''; const String implementationChanges = ''' diff --git a/packages/foo/foo_bar/lib/foo.dart b/packages/foo/foo_bar/lib/foo.dart index abc123..def456 100644 --- a/packages/foo/foo_bar/lib/foo.dart +++ b/packages/foo/foo_bar/lib/foo.dart @@ -51,6 +51,7 @@ Future launchUrl( } void foo() { + new code; existing code; ... ... '''; processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo( MockProcess(stdout: changedFileOutput), ['--name-only']), FakeProcessInfo(MockProcess(stdout: implementationChanges), ['', 'HEAD', '--', '/packages/foo/foo_bar/lib/foo.dart']), FakeProcessInfo(MockProcess(stdout: platformInterfaceChanges), [ '', 'HEAD', '--', '/packages/foo/foo_platform_interface/lib/foo.dart' ]), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo_bar...'), contains('No public code changes for foo_platform_interface.'), ]), ); }); test('allows things that look like mass changes, with warning', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final RepositoryPackage appFacing = createFakePlugin('foo', pluginGroupDir); final RepositoryPackage implementation = createFakePlugin('foo_bar', pluginGroupDir); final RepositoryPackage platformInterface = createFakePlugin('foo_platform_interface', pluginGroupDir); final RepositoryPackage otherPlugin1 = createFakePlugin('bar', packagesDir); final RepositoryPackage otherPlugin2 = createFakePlugin('baz', packagesDir); final String changedFileOutput = [ appFacing.libDirectory.childFile('foo.dart'), implementation.libDirectory.childFile('foo.dart'), platformInterface.pubspecFile, platformInterface.libDirectory.childFile('foo.dart'), otherPlugin1.libDirectory.childFile('bar.dart'), otherPlugin2.libDirectory.childFile('baz.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo/foo...'), contains( 'Ignoring potentially dangerous change, as this appears to be a mass change.'), contains('Running for foo_bar...'), contains( 'Ignoring potentially dangerous change, as this appears to be a mass change.'), contains('Ran for 2 package(s) (2 with warnings)'), ]), ); }); test('handles top-level files that match federated package heuristics', () async { final RepositoryPackage plugin = createFakePlugin('foo', packagesDir); final String changedFileOutput = [ // This should be picked up as a change to 'foo', and not crash. plugin.directory.childFile('foo_bar.baz'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Running for foo...'), ]), ); }); test('handles deletion of an entire plugin', () async { // Simulate deletion, in the form of diffs for packages that don't exist in // the filesystem. final String changedFileOutput = [ packagesDir.childDirectory('foo').childFile('pubspec.yaml'), packagesDir .childDirectory('foo') .childDirectory('lib') .childFile('foo.dart'), packagesDir .childDirectory('foo_platform_interface') .childFile('pubspec.yaml'), packagesDir .childDirectory('foo_platform_interface') .childDirectory('lib') .childFile('foo.dart'), packagesDir.childDirectory('foo_web').childFile('pubspec.yaml'), packagesDir .childDirectory('foo_web') .childDirectory('lib') .childFile('foo.dart'), ].map((File file) => file.path).join('\n'); processRunner.mockProcessesForExecutable['git-diff'] = [ FakeProcessInfo(MockProcess(stdout: changedFileOutput)), ]; final List output = await runCapturingPrint(runner, ['federation-safety-check']); expect( output, containsAllInOrder([ contains('Ran for 0 package(s)'), ]), ); }); }