// 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. @TestOn('vm') library; import 'dart:io' as io; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:platform/platform.dart'; import 'package:test/test.dart'; ////////////////////////////////////////////////////////////////////// // // // ✨ THINKING OF MOVING/REFACTORING THIS FILE? READ ME FIRST! ✨ // // // // There is a link to this file in //docs/tool/Engine-artfiacts.md // // and it would be very kind of you to update the link, if needed. // // // ////////////////////////////////////////////////////////////////////// void main() { const FileSystem localFs = LocalFileSystem(); final _FlutterRootUnderTest flutterRoot = _FlutterRootUnderTest.findWithin(); late Directory tmpDir; late _FlutterRootUnderTest testRoot; late Map environment; void printIfNotEmpty(String prefix, String string) { if (string.isNotEmpty) { string.split(io.Platform.lineTerminator).forEach((String s) { print('$prefix:>$s<'); }); } } io.ProcessResult run(String executable, List args, {String? workingPath}) { print('Running "$executable ${args.join(" ")}"${workingPath != null ? ' $workingPath' : ''}'); final io.ProcessResult result = io.Process.runSync( executable, args, environment: environment, workingDirectory: workingPath ?? testRoot.root.absolute.path, includeParentEnvironment: false, ); if (result.exitCode != 0) { fail( 'Failed running "$executable $args" (exit code = ${result.exitCode}),' '\nstdout: ${result.stdout}' '\nstderr: ${result.stderr}', ); } printIfNotEmpty('stdout', (result.stdout as String).trim()); printIfNotEmpty('stderr', (result.stderr as String).trim()); return result; } setUp(() async { tmpDir = localFs.systemTempDirectory.createTempSync('content_aware_hash.'); testRoot = _FlutterRootUnderTest.fromPath(tmpDir.childDirectory('flutter').path); environment = {}; if (const LocalPlatform().isWindows) { // Copy a minimal set of environment variables needed to run the update_engine_version script in PowerShell. const List powerShellVariables = ['SystemRoot', 'Path', 'PATHEXT']; for (final String key in powerShellVariables) { final String? value = io.Platform.environment[key]; if (value != null) { environment[key] = value; } } } // Make a slim copy of the flutterRoot. flutterRoot.copyTo(testRoot); // Generate blank files for what otherwise would exist in the engine. testRoot ..engineReadMe.createSync(recursive: true) ..flutterReadMe.createSync(recursive: true) ..deps.createSync(recursive: true); }); tearDown(() { // Git adds a lot of files, we don't want to test for them. final Directory gitDir = testRoot.root.childDirectory('.git'); if (gitDir.existsSync()) { gitDir.deleteSync(recursive: true); } // Now do cleanup so even if the next step fails, we still deleted tmp. tmpDir.deleteSync(recursive: true); }); /// Runs `bin/internal/content_aware_hash.{sh|ps1}` and returns the process result. /// /// If the exit code is 0, it is considered a success, and files should exist as a side-effect. /// - On Windows, `powershell` is used (to run `update_engine_version.ps1`); /// - Otherwise, `update_engine_version.sh` is used. io.ProcessResult runContentAwareHash() { final String executable; final List args; if (const LocalPlatform().isWindows) { executable = 'powershell'; args = [testRoot.contentAwareHashPs1.path]; } else { executable = testRoot.contentAwareHashSh.path; args = []; } return run(executable, args); } /// Initializes a blank git repo in [testRoot.root]. void initGitRepoWithBlankInitialCommit({String? workingPath}) { run('git', ['init', '--initial-branch', 'master'], workingPath: workingPath); // autocrlf is very important for tests to work on windows. run('git', 'config --local core.autocrlf true'.split(' '), workingPath: workingPath); run('git', [ 'config', '--local', 'user.email', 'test@example.com', ], workingPath: workingPath); run('git', ['config', '--local', 'user.name', 'Test User'], workingPath: workingPath); run('git', ['add', '.'], workingPath: workingPath); run('git', [ 'commit', '--allow-empty', '-m', 'Initial commit', ], workingPath: workingPath); } void writeFileAndCommit(File file, String contents) { file.writeAsStringSync(contents); run('git', ['add', '--all']); run('git', ['commit', '--all', '-m', 'changed ${file.basename} to $contents']); } test('generates a hash', () async { initGitRepoWithBlankInitialCommit(); expect(runContentAwareHash(), processStdout('3bbeb6a394378478683ece4f8e8663c42f8dc814')); }); group('generates a different hash when', () { setUp(() { initGitRepoWithBlankInitialCommit(); }); test('DEPS is changed', () async { writeFileAndCommit(testRoot.deps, 'deps changed'); expect(runContentAwareHash(), processStdout('f049fdcd4300c8c0d5041b5e35b3d11c2d289bdf')); }); test('an engine file changes', () async { writeFileAndCommit(testRoot.engineReadMe, 'engine file changed'); expect(runContentAwareHash(), processStdout('49e58f425cb039e745614d7ea10c369387c43681')); }); test('a new engine file is added', () async { final List gibberish = ('_abcdefghijklmnopqrstuvqxyz0123456789' * 20).split('') ..shuffle(); final String newFileName = gibberish.take(20).join(); writeFileAndCommit( testRoot.engineReadMe.parent.childFile(newFileName), '$newFileName file added', ); expect( runContentAwareHash(), isNot(processStdout('e9d1f7dc1718dac8e8189791a8073e38abdae1cf')), ); }); test('bin/internal/release-candidate-branch.version is present', () { writeFileAndCommit( testRoot.contentAwareHashPs1.parent.childFile('release-candidate-branch.version'), 'sup', ); expect(runContentAwareHash(), processStdout('3b81cd2164f26a8db3271d46c7022c159193417d')); }); }); test('does not hash non-engine files', () async { initGitRepoWithBlankInitialCommit(); testRoot.flutterReadMe.writeAsStringSync('codefu was here'); expect(runContentAwareHash(), processStdout('3bbeb6a394378478683ece4f8e8663c42f8dc814')); }); } /// A FrUT, or "Flutter Root"-Under Test (parallel to a SUT, System Under Test). /// /// For the intent of this test case, the "Flutter Root" is a directory /// structure with a minimal set of files. final class _FlutterRootUnderTest { /// Creates a root-under test using [path] as the root directory. /// /// It is assumed the files already exist or will be created if needed. factory _FlutterRootUnderTest.fromPath( String path, { FileSystem fileSystem = const LocalFileSystem(), }) { final Directory root = fileSystem.directory(path); return _FlutterRootUnderTest._( root, contentAwareHashPs1: root.childFile( fileSystem.path.joinAll('bin/internal/content_aware_hash.ps1'.split('/')), ), contentAwareHashSh: root.childFile( fileSystem.path.joinAll('bin/internal/content_aware_hash.sh'.split('/')), ), engineReadMe: root.childFile(fileSystem.path.joinAll('engine/README.md'.split('/'))), deps: root.childFile(fileSystem.path.join('DEPS')), flutterReadMe: root.childFile( fileSystem.path.joinAll('packages/flutter/README.md'.split('/')), ), ); } factory _FlutterRootUnderTest.findWithin({ String? path, FileSystem fileSystem = const LocalFileSystem(), }) { path ??= fileSystem.currentDirectory.path; Directory current = fileSystem.directory(path); while (!current.childFile('DEPS').existsSync()) { if (current.path == current.parent.path) { throw ArgumentError.value(path, 'path', 'Could not resolve flutter root'); } current = current.parent; } return _FlutterRootUnderTest.fromPath(current.path); } const _FlutterRootUnderTest._( this.root, { required this.deps, required this.contentAwareHashPs1, required this.contentAwareHashSh, required this.engineReadMe, required this.flutterReadMe, }); final Directory root; final File deps; final File contentAwareHashPs1; final File contentAwareHashSh; final File engineReadMe; final File flutterReadMe; /// Copies files under test to the [testRoot]. void copyTo(_FlutterRootUnderTest testRoot) { contentAwareHashPs1.copySyncRecursive(testRoot.contentAwareHashPs1.path); contentAwareHashSh.copySyncRecursive(testRoot.contentAwareHashSh.path); } } extension on File { void copySyncRecursive(String newPath) { fileSystem.directory(fileSystem.path.dirname(newPath)).createSync(recursive: true); copySync(newPath); } } /// Returns a matcher, that, given [stdout]: /// /// 1. Process exists with code 0 /// 2. Stdout is a String /// 3. Stdout contents, after applying [collapseWhitespace], is the same as /// [stdout], after applying [collapseWhitespace]. Matcher processStdout(String stdout) { return _ProcessSucceedsAndOutputs(stdout); } final class _ProcessSucceedsAndOutputs extends Matcher { _ProcessSucceedsAndOutputs(String stdout) : _expected = collapseWhitespace(stdout); final String _expected; @override bool matches(Object? item, _) { if (item is! io.ProcessResult || item.exitCode != 0 || item.stdout is! String) { return false; } final String actual = item.stdout as String; return collapseWhitespace(actual) == collapseWhitespace(_expected); } @override Description describe(Description description) { return description.add( 'The process exists normally and stdout (ignoring whitespace): $_expected', ); } @override Description describeMismatch(Object? item, Description mismatch, _, _) { if (item is! io.ProcessResult) { return mismatch.add('is not a process result (${item.runtimeType})'); } if (item.exitCode != 0) { return mismatch.add('exit code is not zero (${item.exitCode})'); } if (item.stdout is! String) { return mismatch.add('stdout is not String (${item.stdout.runtimeType})'); } return mismatch .add('is ') .addDescriptionOf(collapseWhitespace(item.stdout as String)) .add(' with whitespace compressed'); } }