| // Copyright 2024 The Flutter Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:devtools_tool/license_utils.dart'; | |
| import 'package:path/path.dart' as p; | |
| import 'package:test/test.dart'; | |
| import '../../packages/devtools_shared/test/helpers/helpers.dart'; | |
| const licenseText1 = '''// This is some 2015 multiline license | |
| // text that should be removed from the file. | |
| '''; | |
| const licenseText2 = '''/* This is other 1999 multiline license | |
| text that should be removed from the file. */ | |
| '''; | |
| const licenseText3 = '''# This is more 2001 multiline license | |
| # text that should be removed from the file. | |
| '''; | |
| const licenseText4 = '''// This is some multiline license text to | |
| // remove that does not contain a stored value. | |
| '''; | |
| const extraText = ''' | |
| This is just some extra text to fill in the | |
| contents following the license text in the test files. | |
| It really doesn't matter what the text says.'''; | |
| late Directory testDirectory; | |
| late File configFile; | |
| late Directory repoRoot; | |
| late File hiddenFile; | |
| late File testFile1; | |
| late File testFile2; | |
| late File testFile3; | |
| late File testFile4; | |
| late File testFile5; | |
| late File testFile6; | |
| late File testFile7; | |
| late File testFile8; | |
| late File testFile9; | |
| late File testFile10; | |
| late File excludeFile1; | |
| late File excludeFile2; | |
| void main() { | |
| group('config file tests', () { | |
| setUp(() async { | |
| await _setupTestDirectoryStructure(); | |
| await _setupTestConfigFile(); | |
| }); | |
| tearDownAll(() async { | |
| await deleteDirectoryWithRetry(testDirectory); | |
| }); | |
| test('config can be read from disk without any errors', () { | |
| expect(() => LicenseConfig.fromYamlFile(configFile), returnsNormally); | |
| }); | |
| test('remove licenses text is parsed correctly', () { | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| expect(config.removeLicenses.length, equals(4)); | |
| var expectedVal = '''// This is some <value1> multiline license | |
| // text that should be removed from the file. | |
| '''; | |
| expect(config.removeLicenses[0], equals(expectedVal)); | |
| expectedVal = '''/* This is other <value2> multiline license | |
| text that should be removed from the file. */ | |
| '''; | |
| expect(config.removeLicenses[1], equals(expectedVal)); | |
| expectedVal = '''# This is more <value3> multiline license | |
| # text that should be removed from the file. | |
| '''; | |
| expect(config.removeLicenses[2], equals(expectedVal)); | |
| expectedVal = '''// This is some multiline license text to | |
| // remove that does not contain a stored value. | |
| '''; | |
| expect(config.removeLicenses[3], equals(expectedVal)); | |
| }); | |
| test('add licenses text is parsed correctly', () { | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| expect(config.addLicenses.length, equals(3)); | |
| var expectedVal = '''// This is some <value1> multiline license | |
| // text that should be added to the file. | |
| '''; | |
| expect(config.addLicenses[0], equals(expectedVal)); | |
| expectedVal = '''# This is other <value3> multiline license | |
| # text that should be added to the file. | |
| '''; | |
| expect(config.addLicenses[1], equals(expectedVal)); | |
| expectedVal = '''// This is some multiline license text to | |
| // add that does not contain a stored value. | |
| '''; | |
| expect(config.addLicenses[2], equals(expectedVal)); | |
| }); | |
| test('file types parsed correctly', () { | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| var removeIndices = config.getRemoveIndicesForExtension('ext1'); | |
| expect(removeIndices.length, equals(2)); | |
| expect(removeIndices[0], equals(0)); | |
| expect(removeIndices[1], equals(1)); | |
| var addIndex = config.getAddIndexForExtension('ext1'); | |
| expect(addIndex, equals(0)); | |
| removeIndices = config.getRemoveIndicesForExtension('ext2'); | |
| expect(removeIndices.length, equals(1)); | |
| expect(removeIndices[0], equals(2)); | |
| addIndex = config.getAddIndexForExtension('ext2'); | |
| expect(addIndex, equals(1)); | |
| }); | |
| test("included files shouldn't be excluded", () { | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| expect(config.shouldExclude(testFile1), false); | |
| expect(config.shouldExclude(testFile2), false); | |
| expect(config.shouldExclude(testFile3), false); | |
| expect(config.shouldExclude(testFile7), false); | |
| expect(config.shouldExclude(testFile8), false); | |
| expect(config.shouldExclude(testFile9), false); | |
| expect(config.shouldExclude(testFile10), false); | |
| }); | |
| test('excluded files should be excluded', () { | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| expect(config.shouldExclude(excludeFile1), true); | |
| expect(config.shouldExclude(excludeFile2), true); | |
| }); | |
| test('files in an excluded directory should be excluded', () { | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| expect(config.shouldExclude(testFile4), true); | |
| expect(config.shouldExclude(testFile5), true); | |
| expect(config.shouldExclude(testFile6), true); | |
| }); | |
| test('files not in an included directory should be excluded', () { | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| final fileNotInTestDirectory = File('test.txt'); | |
| expect(config.shouldExclude(fileNotInTestDirectory), true); | |
| }); | |
| }); | |
| group('license update tests', () { | |
| setUp(() async { | |
| await _setupTestDirectoryStructure(); | |
| }); | |
| tearDownAll(() async { | |
| await deleteDirectoryWithRetry(testDirectory); | |
| }); | |
| test('default to the current year in replacement header', () async { | |
| const existingLicenseText = '''// This is some multiline license text to | |
| // remove that does not contain a stored value.'''; | |
| const replacementLicenseText = | |
| '''// This is some <value4> multiline license | |
| // text that should be added to the file.'''; | |
| final replacementInfo = await _getTestReplacementInfo( | |
| testFile: testFile10, | |
| existingLicenseText: existingLicenseText, | |
| replacementLicenseText: replacementLicenseText, | |
| ); | |
| const expectedExistingHeader = | |
| '''// This is some multiline license text to | |
| // remove that does not contain a stored value.'''; | |
| // Note: There might be a potential failure case if the test is | |
| // run right when the year ends and a new year starts. | |
| final currentYear = DateTime.now().year.toString(); | |
| final expectedReplacementHeader = | |
| '''// This is some $currentYear multiline license | |
| // text that should be added to the file.'''; | |
| expect(replacementInfo.existingHeader, equals(expectedExistingHeader)); | |
| expect( | |
| replacementInfo.replacementHeader, | |
| equals(expectedReplacementHeader), | |
| ); | |
| }); | |
| test('stored value preserved in replacement header', () async { | |
| final testFiles = [testFile1, testFile2, testFile3]; | |
| final existingLicenseTexts = [ | |
| '''// This is some <value1> multiline license | |
| // text that should be removed from the file.''', | |
| '''# This is more <value2> multiline license | |
| # text that should be removed from the file.''', | |
| '''/* This is other <value3> multiline license | |
| text that should be removed from the file. */''', | |
| ]; | |
| final replacementLicenseTexts = [ | |
| '''// This is some <value1> multiline license | |
| // text that should be added to the file.''', | |
| '''# This is more <value2> multiline license | |
| // text that should be added to the file.''', | |
| '''/* This is other <value3> multiline license | |
| text that should be added to the file. */''', | |
| ]; | |
| final expectedExistingHeaders = [ | |
| '''// This is some 2015 multiline license | |
| // text that should be removed from the file.''', | |
| '''# This is more 2001 multiline license | |
| # text that should be removed from the file.''', | |
| '''/* This is other 1999 multiline license | |
| text that should be removed from the file. */''', | |
| ]; | |
| final expectedReplacementHeaders = [ | |
| '''// This is some 2015 multiline license | |
| // text that should be added to the file.''', | |
| '''# This is more 2001 multiline license | |
| // text that should be added to the file.''', | |
| '''/* This is other 1999 multiline license | |
| text that should be added to the file. */''', | |
| ]; | |
| for (var i = 0; i < testFiles.length; i++) { | |
| final replacementInfo = await _getTestReplacementInfo( | |
| testFile: testFiles[i], | |
| existingLicenseText: existingLicenseTexts[i], | |
| replacementLicenseText: replacementLicenseTexts[i], | |
| ); | |
| expect( | |
| replacementInfo.existingHeader, | |
| equals(expectedExistingHeaders[i]), | |
| reason: 'Failed on iteration $i', | |
| ); | |
| expect( | |
| replacementInfo.replacementHeader, | |
| equals(expectedReplacementHeaders[i]), | |
| reason: 'Failed on iteration $i', | |
| ); | |
| } | |
| }); | |
| test('update skipped if license text not found', () async { | |
| var errorMessage = ''; | |
| final header = LicenseHeader(); | |
| try { | |
| await header.getReplacementInfo( | |
| file: testFile9, | |
| existingLicenseText: 'test', | |
| replacementLicenseText: 'test', | |
| byteCount: 50, | |
| ); | |
| } on StateError catch (e) { | |
| errorMessage = e.toString(); | |
| } | |
| expect( | |
| errorMessage, | |
| equals( | |
| 'Bad state: License header expected in ${testFile9.path}, but not found!', | |
| ), | |
| ); | |
| }); | |
| test("update skipped if file can't be read", () async { | |
| var errorMessage = ''; | |
| final header = LicenseHeader(); | |
| try { | |
| await header.getReplacementInfo( | |
| file: File('bad.txt'), | |
| existingLicenseText: 'test', | |
| replacementLicenseText: 'test', | |
| byteCount: 50, | |
| ); | |
| } on StateError catch (e) { | |
| errorMessage = e.toString(); | |
| } | |
| expect( | |
| errorMessage, | |
| contains( | |
| 'Bad state: License header expected, but error reading file - PathNotFoundException', | |
| ), | |
| ); | |
| }); | |
| test('license header can be rewritten on disk', () async { | |
| final header = LicenseHeader(); | |
| const existingHeader = '''// This is some 2015 multiline license | |
| // text that should be removed from the file.'''; | |
| const replacementHeader = '''// This is some 2015 multiline license | |
| // text that should be added to the file.'''; | |
| final rewrittenFile = header.rewriteLicenseHeader( | |
| file: testFile1, | |
| existingHeader: existingHeader, | |
| replacementHeader: replacementHeader, | |
| ); | |
| expect(rewrittenFile.lengthSync(), greaterThan(0)); | |
| final existingContents = testFile1.readAsStringSync(); | |
| expect( | |
| existingContents.substring(0, existingHeader.length), | |
| equals(existingHeader), | |
| ); | |
| final rewrittenContents = rewrittenFile.readAsStringSync(); | |
| expect( | |
| rewrittenContents.substring(0, replacementHeader.length), | |
| equals(replacementHeader), | |
| ); | |
| expect( | |
| existingContents.substring(existingHeader.length + 1), | |
| equals(rewrittenContents.substring(replacementHeader.length + 1)), | |
| ); | |
| }); | |
| test('license headers can be updated in bulk', () async { | |
| await _setupTestConfigFile(); | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| final header = LicenseHeader(); | |
| final contentsBeforeUpdate = testFile1.readAsStringSync(); | |
| final results = await header.bulkUpdate( | |
| directory: testDirectory, | |
| config: config, | |
| ); | |
| final contentsAfterUpdate = testFile1.readAsStringSync(); | |
| final includedPaths = results.includedPaths; | |
| expect(includedPaths, isNotNull); | |
| expect(includedPaths.length, equals(7)); | |
| // Order is not guaranteed | |
| expect(includedPaths.contains(testFile1.path), true); | |
| expect(contentsBeforeUpdate, isNot(equals(contentsAfterUpdate))); | |
| expect(includedPaths.contains(testFile2.path), true); | |
| expect(includedPaths.contains(testFile3.path), true); | |
| expect(includedPaths.contains(testFile7.path), true); | |
| expect(includedPaths.contains(testFile8.path), true); | |
| expect(includedPaths.contains(testFile9.path), true); | |
| expect(includedPaths.contains(testFile10.path), true); | |
| final updatedPaths = results.updatedPaths; | |
| expect(updatedPaths, isNotNull); | |
| // testFile9 and testFile10 are intentionally misconfigured and so they | |
| // won't be updated even though they are on the include list. | |
| expect(updatedPaths.length, equals(5)); | |
| // Order is not guaranteed | |
| expect(updatedPaths.contains(testFile1.path), true); | |
| expect(updatedPaths.contains(testFile2.path), true); | |
| expect(updatedPaths.contains(testFile3.path), true); | |
| expect(updatedPaths.contains(testFile7.path), true); | |
| expect(updatedPaths.contains(testFile8.path), true); | |
| }); | |
| test('license headers bulk update can be dry run', () async { | |
| await _setupTestConfigFile(); | |
| final config = LicenseConfig.fromYamlFile(configFile); | |
| final header = LicenseHeader(); | |
| final contentsBeforeUpdate = testFile1.readAsStringSync(); | |
| final results = await header.bulkUpdate( | |
| directory: testDirectory, | |
| config: config, | |
| dryRun: true, | |
| ); | |
| final contentsAfterUpdate = testFile1.readAsStringSync(); | |
| final updatedPaths = results.updatedPaths; | |
| expect(updatedPaths, isNotNull); | |
| expect(updatedPaths.length, equals(5)); | |
| expect(updatedPaths.contains(testFile1.path), true); | |
| expect(contentsBeforeUpdate, equals(contentsAfterUpdate)); | |
| }); | |
| }); | |
| test('repo wide check', () async { | |
| // This test is currently skipped because not all existing files | |
| // have had their license headers updated to the correct license text. | |
| // So this test will always fail. Set skip to false to run locally, but | |
| // don't commit the change to the repository. | |
| // TODO(mossmana): This test should stop being skipped only when it is safe to check just new files going forward. | |
| final rootPathMatcher = RegExp(r'(.*[/|\\]devtools[/|\\]).*'); | |
| // TODO(mossmana): make this work on Google3 | |
| expect(rootPathMatcher.hasMatch(Directory.current.path), true); | |
| final match = rootPathMatcher.firstMatch(Directory.current.path); | |
| final rootPath = match?.group(1); | |
| expect(rootPath, isNotNull); | |
| final failedPaths = <String>[]; | |
| final subDirectories = ['packages', 'tool']; | |
| for (final subDirectory in subDirectories) { | |
| final checkedDirectory = Directory('$rootPath$subDirectory'); | |
| expect( | |
| checkedDirectory.existsSync(), | |
| true, | |
| reason: '$checkedDirectory does not exist.', | |
| ); | |
| final files = checkedDirectory | |
| .listSync(recursive: true) | |
| .whereType<File>() | |
| .toList(); | |
| final header = LicenseHeader(); | |
| const goodReplacementLicenseText = | |
| '''// Copyright <copyright_date> The Flutter Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.'''; | |
| for (final file in files) { | |
| final extension = p.extension(file.path); | |
| // Only check dart source files and exclude any files that are | |
| // downloaded as part of the flutter-sdk package dependencies. | |
| if (extension != '.dart' || file.path.contains('flutter-sdk')) { | |
| continue; | |
| } | |
| try { | |
| final replacementInfo = await header.getReplacementInfo( | |
| file: file, | |
| existingLicenseText: goodReplacementLicenseText, | |
| replacementLicenseText: '', | |
| byteCount: goodReplacementLicenseText.length, | |
| ); | |
| if (replacementInfo.existingHeader.isEmpty || | |
| replacementInfo.replacementHeader.isEmpty) { | |
| failedPaths.add(file.path); | |
| } | |
| } on StateError { | |
| failedPaths.add(file.path); | |
| } | |
| } | |
| } | |
| expect( | |
| failedPaths.isEmpty, | |
| true, | |
| reason: | |
| 'License headers are incorrect for ${failedPaths.length} files: $failedPaths', | |
| ); | |
| }, skip: true); | |
| } | |
| Future<({String existingHeader, String replacementHeader})> | |
| _getTestReplacementInfo({ | |
| required File testFile, | |
| required String existingLicenseText, | |
| required String replacementLicenseText, | |
| }) async { | |
| final header = LicenseHeader(); | |
| final bytes = utf8.encode(existingLicenseText); | |
| return await header.getReplacementInfo( | |
| file: testFile, | |
| existingLicenseText: existingLicenseText, | |
| replacementLicenseText: replacementLicenseText, | |
| byteCount: bytes.length + 1, | |
| ); | |
| } | |
| /// Sets up the config file | |
| Future<void> _setupTestConfigFile() async { | |
| configFile = File(p.join(testDirectory.path, 'test_config.yaml')) | |
| ..createSync(recursive: true); | |
| final contents = | |
| '''--- | |
| # sequence of license text strings that should be matched against at the top of a file and removed. <value>, which normally represents a date, will be stored. | |
| remove_licenses: | |
| - | | |
| // This is some <value1> multiline license | |
| // text that should be removed from the file. | |
| - | | |
| /* This is other <value2> multiline license | |
| text that should be removed from the file. */ | |
| - | | |
| # This is more <value3> multiline license | |
| # text that should be removed from the file. | |
| - | | |
| // This is some multiline license text to | |
| // remove that does not contain a stored value. | |
| # sequence of license text strings that should be added to the top of a file. {value} will be replaced. | |
| add_licenses: | |
| - | | |
| // This is some <value1> multiline license | |
| // text that should be added to the file. | |
| - | | |
| # This is other <value3> multiline license | |
| # text that should be added to the file. | |
| - | | |
| // This is some multiline license text to | |
| // add that does not contain a stored value. | |
| # defines which files should have license text added or updated. | |
| update_paths: | |
| # path(s) to recursively check for files to remove/add license | |
| include: | |
| - ${testDirectory.path}/repo_root | |
| # path(s) to recursively check for files to ignore | |
| exclude: | |
| # exclude everything in the /repo_root/sub_dir1 directory | |
| - ${testDirectory.path}/repo_root/sub_dir1/ | |
| # exclude the given files | |
| - ${testDirectory.path}/repo_root/sub_dir2/exclude1.ext1 | |
| - ${testDirectory.path}/repo_root/sub_dir2/sub_dir3/exclude2.ext2 | |
| file_types: | |
| # extension | |
| ext1: | |
| # one or more indices of remove_licenses to remove | |
| remove: | |
| - 0 | |
| - 1 | |
| # index of add_licenses to add | |
| add: 0 | |
| ext2: | |
| remove: | |
| - 2 | |
| add: 1'''; | |
| configFile.writeAsStringSync(contents, flush: true); | |
| } | |
| /// Sets up the directory structure for the tests | |
| /// repo_root/ | |
| /// test1.ext1 | |
| /// test2.ext2 | |
| /// .hidden/ | |
| /// test3.ext1 | |
| /// sub_dir1/ | |
| /// test4.ext1 | |
| /// sub_dir1a/ | |
| /// test5.ext2 | |
| /// sub_dir1b/ | |
| /// test6.ext1 | |
| /// sub_dir2/ | |
| /// exclude1.ext1 | |
| /// test7.ext2 | |
| /// sub_dir3/ | |
| /// test8.ext1 | |
| /// exclude2.ext2 | |
| /// sub_dir4/ | |
| /// test9.ext1 | |
| /// sub_dir5/ | |
| /// test10.ext2 | |
| /// | |
| Future<void> _setupTestDirectoryStructure() async { | |
| testDirectory = Directory.systemTemp.createTempSync(); | |
| // Setup /repo_root directory structure | |
| repoRoot = Directory(p.joinAll([testDirectory.path, 'repo_root'])) | |
| ..createSync(recursive: true); | |
| testFile1 = File(p.join(repoRoot.path, 'test1.ext1')) | |
| ..createSync(recursive: true); | |
| testFile1.writeAsStringSync(licenseText1 + extraText, flush: true); | |
| testFile2 = File(p.join(repoRoot.path, 'test2.ext2')) | |
| ..createSync(recursive: true); | |
| testFile2.writeAsStringSync(licenseText3 + extraText, flush: true); | |
| // Setup /repo_root/.hidden directory structure | |
| Directory(p.join(repoRoot.path, '.hidden')).createSync(recursive: true); | |
| testFile3 = File(p.join(repoRoot.path, '.hidden', 'test3.ext1')) | |
| ..createSync(recursive: true); | |
| testFile3.writeAsStringSync(licenseText2 + extraText, flush: true); | |
| // Setup /repo_root/sub_dir1/sub_dir1a/sub_dir1b directory structure | |
| Directory( | |
| p.join(repoRoot.path, 'sub_dir1', 'sub_dir1a', 'sub_dir1b'), | |
| ).createSync(recursive: true); | |
| testFile4 = File(p.join(repoRoot.path, 'sub_dir1', 'test4.ext1')) | |
| ..createSync(recursive: true); | |
| testFile4.writeAsStringSync(licenseText1 + extraText, flush: true); | |
| testFile5 = File(p.join(repoRoot.path, 'sub_dir1', 'sub_dir1a', 'test5.ext2')) | |
| ..createSync(recursive: true); | |
| testFile5.writeAsStringSync(licenseText3 + extraText, flush: true); | |
| testFile6 = File( | |
| p.join(repoRoot.path, 'sub_dir1', 'sub_dir1a', 'sub_dir1b', 'test6.ext2'), | |
| )..createSync(recursive: true); | |
| testFile6.writeAsStringSync(licenseText3 + extraText, flush: true); | |
| // Setup /repo_root/sub_dir2 directory structure | |
| Directory(p.join(repoRoot.path, 'sub_dir2')).createSync(recursive: true); | |
| excludeFile1 = File(p.join(repoRoot.path, 'sub_dir2', 'exclude1.ext1')) | |
| ..createSync(recursive: true); | |
| excludeFile1.writeAsStringSync(licenseText2 + extraText, flush: true); | |
| testFile7 = File(p.join(repoRoot.path, 'sub_dir2', 'test7.ext2')) | |
| ..createSync(recursive: true); | |
| testFile7.writeAsStringSync(licenseText3 + extraText, flush: true); | |
| // Setup /repo_root/sub_dir2/sub_dir3 directory structure | |
| Directory( | |
| p.join(repoRoot.path, 'sub_dir2', 'sub_dir3'), | |
| ).createSync(recursive: true); | |
| testFile8 = File(p.join(repoRoot.path, 'sub_dir2', 'sub_dir3', 'test8.ext1')) | |
| ..createSync(recursive: true); | |
| testFile8.writeAsStringSync(licenseText2 + extraText, flush: true); | |
| excludeFile2 = File( | |
| p.join(repoRoot.path, 'sub_dir2', 'sub_dir3', 'exclude2.ext2'), | |
| )..createSync(recursive: true); | |
| excludeFile2.writeAsStringSync(licenseText3 + extraText, flush: true); | |
| // Setup /repo_root/sub_dir2/sub_dir4 directory structure | |
| Directory( | |
| p.join(repoRoot.path, 'sub_dir2', 'sub_dir4'), | |
| ).createSync(recursive: true); | |
| testFile9 = File(p.join(repoRoot.path, 'sub_dir2', 'sub_dir4', 'test9.ext1')) | |
| ..createSync(recursive: true); | |
| testFile9.writeAsStringSync(extraText, flush: true); | |
| // Setup /repo_root/sub_dir2/sub_dir4/sub_dir5 directory structure | |
| Directory( | |
| p.join(repoRoot.path, 'sub_dir2', 'sub_dir4', 'sub_dir5'), | |
| ).createSync(recursive: true); | |
| testFile10 = File( | |
| p.join(repoRoot.path, 'sub_dir2', 'sub_dir4', 'sub_dir5', 'test10.ext2'), | |
| )..createSync(recursive: true); | |
| testFile10.writeAsStringSync(licenseText4 + extraText, flush: true); | |
| } |