|
| 1 | +// Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | +// Use of this source code is governed by a BSD-style license that can be |
| 3 | +// found in the LICENSE file. |
| 4 | + |
| 5 | +@TestOn('vm') |
| 6 | +library; |
| 7 | + |
| 8 | +import 'dart:io' as io; |
| 9 | + |
| 10 | +import 'package:file/file.dart'; |
| 11 | +import 'package:file/local.dart'; |
| 12 | +import 'package:platform/platform.dart'; |
| 13 | +import 'package:test/test.dart'; |
| 14 | + |
| 15 | +////////////////////////////////////////////////////////////////////// |
| 16 | +// // |
| 17 | +// ✨ THINKING OF MOVING/REFACTORING THIS FILE? READ ME FIRST! ✨ // |
| 18 | +// // |
| 19 | +// There is a link to this file in //docs/tool/Engine-artfiacts.md // |
| 20 | +// and it would be very kind of you to update the link, if needed. // |
| 21 | +// // |
| 22 | +////////////////////////////////////////////////////////////////////// |
| 23 | +
|
| 24 | +void main() { |
| 25 | + const FileSystem localFs = LocalFileSystem(); |
| 26 | + final _FlutterRootUnderTest flutterRoot = _FlutterRootUnderTest.findWithin(); |
| 27 | + |
| 28 | + late Directory tmpDir; |
| 29 | + late _FlutterRootUnderTest testRoot; |
| 30 | + late Map<String, String> environment; |
| 31 | + |
| 32 | + void printIfNotEmpty(String prefix, String string) { |
| 33 | + if (string.isNotEmpty) { |
| 34 | + string.split(io.Platform.lineTerminator).forEach((String s) { |
| 35 | + print('$prefix:>$s<'); |
| 36 | + }); |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + io.ProcessResult run(String executable, List<String> args, {String? workingPath}) { |
| 41 | + print('Running "$executable ${args.join(" ")}"${workingPath != null ? ' $workingPath' : ''}'); |
| 42 | + final io.ProcessResult result = io.Process.runSync( |
| 43 | + executable, |
| 44 | + args, |
| 45 | + environment: environment, |
| 46 | + workingDirectory: workingPath ?? testRoot.root.absolute.path, |
| 47 | + includeParentEnvironment: false, |
| 48 | + ); |
| 49 | + if (result.exitCode != 0) { |
| 50 | + fail( |
| 51 | + 'Failed running "$executable $args" (exit code = ${result.exitCode}),' |
| 52 | + '\nstdout: ${result.stdout}' |
| 53 | + '\nstderr: ${result.stderr}', |
| 54 | + ); |
| 55 | + } |
| 56 | + printIfNotEmpty('stdout', (result.stdout as String).trim()); |
| 57 | + printIfNotEmpty('stderr', (result.stderr as String).trim()); |
| 58 | + return result; |
| 59 | + } |
| 60 | + |
| 61 | + setUp(() async { |
| 62 | + tmpDir = localFs.systemTempDirectory.createTempSync('content_aware_hash.'); |
| 63 | + testRoot = _FlutterRootUnderTest.fromPath(tmpDir.childDirectory('flutter').path); |
| 64 | + |
| 65 | + environment = <String, String>{}; |
| 66 | + |
| 67 | + if (const LocalPlatform().isWindows) { |
| 68 | + // Copy a minimal set of environment variables needed to run the update_engine_version script in PowerShell. |
| 69 | + const List<String> powerShellVariables = <String>['SystemRoot', 'Path', 'PATHEXT']; |
| 70 | + for (final String key in powerShellVariables) { |
| 71 | + final String? value = io.Platform.environment[key]; |
| 72 | + if (value != null) { |
| 73 | + environment[key] = value; |
| 74 | + } |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + // Make a slim copy of the flutterRoo |
| 79 | + flutterRoot.copyTo(testRoot); |
| 80 | + }); |
| 81 | + |
| 82 | + tearDown(() { |
| 83 | + // Git adds a lot of files, we don't want to test for them. |
| 84 | + final Directory gitDir = testRoot.root.childDirectory('.git'); |
| 85 | + if (gitDir.existsSync()) { |
| 86 | + gitDir.deleteSync(recursive: true); |
| 87 | + } |
| 88 | + |
| 89 | + // Now do cleanup so even if the next step fails, we still deleted tmp. |
| 90 | + tmpDir.deleteSync(recursive: true); |
| 91 | + }); |
| 92 | + |
| 93 | + /// Runs `bin/internal/content_aware_hash.{sh|ps1}` and returns the process result. |
| 94 | + /// |
| 95 | + /// If the exit code is 0, it is considered a success, and files should exist as a side-effect. |
| 96 | + /// - On Windows, `powershell` is used (to run `update_engine_version.ps1`); |
| 97 | + /// - Otherwise, `update_engine_version.sh` is used. |
| 98 | + io.ProcessResult runContentAwareHash() { |
| 99 | + final String executable; |
| 100 | + final List<String> args; |
| 101 | + if (const LocalPlatform().isWindows) { |
| 102 | + executable = 'powershell'; |
| 103 | + args = <String>[testRoot.contentAwareHashPs1.path]; |
| 104 | + } else { |
| 105 | + executable = testRoot.contentAwareHashSh.path; |
| 106 | + args = <String>[]; |
| 107 | + } |
| 108 | + return run(executable, args); |
| 109 | + } |
| 110 | + |
| 111 | + /// Initializes a blank git repo in [testRoot.root]. |
| 112 | + void initGitRepoWithBlankInitialCommit({String? workingPath}) { |
| 113 | + run('git', <String>['init', '--initial-branch', 'master'], workingPath: workingPath); |
| 114 | + // autocrlf is very important for tests to work on windows. |
| 115 | + run('git', 'config --local core.autocrlf true'.split(' '), workingPath: workingPath); |
| 116 | + run('git', <String>[ |
| 117 | + 'config', |
| 118 | + '--local', |
| 119 | + 'user.email', |
| 120 | + |
| 121 | + ], workingPath: workingPath); |
| 122 | + run('git', <String>['config', '--local', 'user.name', 'Test User'], workingPath: workingPath); |
| 123 | + run('git', <String>['add', '.'], workingPath: workingPath); |
| 124 | + run('git', <String>[ |
| 125 | + 'commit', |
| 126 | + '--allow-empty', |
| 127 | + '-m', |
| 128 | + 'Initial commit', |
| 129 | + ], workingPath: workingPath); |
| 130 | + } |
| 131 | + |
| 132 | + void writeFileAndCommit(File file, String contents) { |
| 133 | + file.writeAsStringSync(contents); |
| 134 | + run('git', <String>['add', '--all']); |
| 135 | + run('git', <String>['commit', '--all', '-m', 'changed ${file.basename} to $contents']); |
| 136 | + } |
| 137 | + |
| 138 | + test('generates a hash', () async { |
| 139 | + initGitRepoWithBlankInitialCommit(); |
| 140 | + expect(runContentAwareHash(), processStdout('eb4bfafe997ec78b3ac8134fbac3eb105ae19155')); |
| 141 | + }); |
| 142 | + |
| 143 | + group('generates a different hash when', () { |
| 144 | + setUp(() { |
| 145 | + initGitRepoWithBlankInitialCommit(); |
| 146 | + }); |
| 147 | + |
| 148 | + test('DEPS is changed', () async { |
| 149 | + writeFileAndCommit(testRoot.deps, 'deps changed'); |
| 150 | + expect(runContentAwareHash(), processStdout('38703cae8a58bd0e7e93342bddd20634b069e608')); |
| 151 | + }); |
| 152 | + |
| 153 | + test('an engine file changes', () async { |
| 154 | + writeFileAndCommit(testRoot.engineReadMe, 'engine file changed'); |
| 155 | + expect(runContentAwareHash(), processStdout('f92b9d9ee03d3530c750235a2fd8559a68d21eac')); |
| 156 | + }); |
| 157 | + |
| 158 | + test('a new engine file is added', () async { |
| 159 | + final List<String> gibberish = ('_abcdefghijklmnopqrstuvqxyz0123456789' * 20).split('') |
| 160 | + ..shuffle(); |
| 161 | + final String newFileName = gibberish.take(20).join(); |
| 162 | + |
| 163 | + writeFileAndCommit( |
| 164 | + testRoot.engineReadMe.parent.childFile(newFileName), |
| 165 | + '$newFileName file added', |
| 166 | + ); |
| 167 | + |
| 168 | + expect( |
| 169 | + runContentAwareHash(), |
| 170 | + isNot(processStdout('e9d1f7dc1718dac8e8189791a8073e38abdae1cf')), |
| 171 | + ); |
| 172 | + }); |
| 173 | + |
| 174 | + test('bin/internal/release-candidate-branch.version is present', () { |
| 175 | + writeFileAndCommit( |
| 176 | + testRoot.contentAwareHashPs1.parent.childFile('release-candidate-branch.version'), |
| 177 | + 'sup', |
| 178 | + ); |
| 179 | + expect(runContentAwareHash(), processStdout('f34e6ca2d4dfafc20a5eb23d616df764cbbe937d')); |
| 180 | + }); |
| 181 | + }); |
| 182 | + |
| 183 | + test('does not hash non-engine files', () async { |
| 184 | + initGitRepoWithBlankInitialCommit(); |
| 185 | + testRoot.flutterReadMe.writeAsStringSync('codefu was here'); |
| 186 | + expect(runContentAwareHash(), processStdout('eb4bfafe997ec78b3ac8134fbac3eb105ae19155')); |
| 187 | + }); |
| 188 | +} |
| 189 | + |
| 190 | +/// A FrUT, or "Flutter Root"-Under Test (parallel to a SUT, System Under Test). |
| 191 | +/// |
| 192 | +/// For the intent of this test case, the "Flutter Root" is a directory |
| 193 | +/// structure with a minimal set of files. |
| 194 | +final class _FlutterRootUnderTest { |
| 195 | + /// Creates a root-under test using [path] as the root directory. |
| 196 | + /// |
| 197 | + /// It is assumed the files already exist or will be created if needed. |
| 198 | + factory _FlutterRootUnderTest.fromPath( |
| 199 | + String path, { |
| 200 | + FileSystem fileSystem = const LocalFileSystem(), |
| 201 | + }) { |
| 202 | + final Directory root = fileSystem.directory(path); |
| 203 | + return _FlutterRootUnderTest._( |
| 204 | + root, |
| 205 | + contentAwareHashPs1: root.childFile( |
| 206 | + fileSystem.path.joinAll('bin/internal/content_aware_hash.ps1'.split('/')), |
| 207 | + ), |
| 208 | + contentAwareHashSh: root.childFile( |
| 209 | + fileSystem.path.joinAll('bin/internal/content_aware_hash.sh'.split('/')), |
| 210 | + ), |
| 211 | + engineReadMe: root.childFile(fileSystem.path.joinAll('engine/README.md'.split('/'))), |
| 212 | + deps: root.childFile(fileSystem.path.join('DEPS')), |
| 213 | + flutterReadMe: root.childFile( |
| 214 | + fileSystem.path.joinAll('packages/flutter/README.md'.split('/')), |
| 215 | + ), |
| 216 | + ); |
| 217 | + } |
| 218 | + |
| 219 | + factory _FlutterRootUnderTest.findWithin({ |
| 220 | + String? path, |
| 221 | + FileSystem fileSystem = const LocalFileSystem(), |
| 222 | + }) { |
| 223 | + path ??= fileSystem.currentDirectory.path; |
| 224 | + Directory current = fileSystem.directory(path); |
| 225 | + while (!current.childFile('DEPS').existsSync()) { |
| 226 | + if (current.path == current.parent.path) { |
| 227 | + throw ArgumentError.value(path, 'path', 'Could not resolve flutter root'); |
| 228 | + } |
| 229 | + current = current.parent; |
| 230 | + } |
| 231 | + return _FlutterRootUnderTest.fromPath(current.path); |
| 232 | + } |
| 233 | + |
| 234 | + const _FlutterRootUnderTest._( |
| 235 | + this.root, { |
| 236 | + required this.deps, |
| 237 | + required this.contentAwareHashPs1, |
| 238 | + required this.contentAwareHashSh, |
| 239 | + required this.engineReadMe, |
| 240 | + required this.flutterReadMe, |
| 241 | + }); |
| 242 | + |
| 243 | + final Directory root; |
| 244 | + |
| 245 | + final File deps; |
| 246 | + final File contentAwareHashPs1; |
| 247 | + final File contentAwareHashSh; |
| 248 | + final File engineReadMe; |
| 249 | + final File flutterReadMe; |
| 250 | + |
| 251 | + /// Copies files under test to the [testRoot]. |
| 252 | + void copyTo(_FlutterRootUnderTest testRoot) { |
| 253 | + deps.copySyncRecursive(testRoot.deps.path); |
| 254 | + contentAwareHashPs1.copySyncRecursive(testRoot.contentAwareHashPs1.path); |
| 255 | + contentAwareHashSh.copySyncRecursive(testRoot.contentAwareHashSh.path); |
| 256 | + engineReadMe.copySyncRecursive(testRoot.engineReadMe.path); |
| 257 | + flutterReadMe.copySyncRecursive(testRoot.flutterReadMe.path); |
| 258 | + } |
| 259 | +} |
| 260 | + |
| 261 | +extension on File { |
| 262 | + void copySyncRecursive(String newPath) { |
| 263 | + fileSystem.directory(fileSystem.path.dirname(newPath)).createSync(recursive: true); |
| 264 | + copySync(newPath); |
| 265 | + } |
| 266 | +} |
| 267 | + |
| 268 | +/// Returns a matcher, that, given [stdout]: |
| 269 | +/// |
| 270 | +/// 1. Process exists with code 0 |
| 271 | +/// 2. Stdout is a String |
| 272 | +/// 3. Stdout contents, after applying [collapseWhitespace], is the same as |
| 273 | +/// [stdout], after applying [collapseWhitespace]. |
| 274 | +Matcher processStdout(String stdout) { |
| 275 | + return _ProcessSucceedsAndOutputs(stdout); |
| 276 | +} |
| 277 | + |
| 278 | +final class _ProcessSucceedsAndOutputs extends Matcher { |
| 279 | + _ProcessSucceedsAndOutputs(String stdout) : _expected = collapseWhitespace(stdout); |
| 280 | + |
| 281 | + final String _expected; |
| 282 | + |
| 283 | + @override |
| 284 | + bool matches(Object? item, _) { |
| 285 | + if (item is! io.ProcessResult || item.exitCode != 0 || item.stdout is! String) { |
| 286 | + return false; |
| 287 | + } |
| 288 | + final String actual = item.stdout as String; |
| 289 | + return collapseWhitespace(actual) == collapseWhitespace(_expected); |
| 290 | + } |
| 291 | + |
| 292 | + @override |
| 293 | + Description describe(Description description) { |
| 294 | + return description.add( |
| 295 | + 'The process exists normally and stdout (ignoring whitespace): $_expected', |
| 296 | + ); |
| 297 | + } |
| 298 | + |
| 299 | + @override |
| 300 | + Description describeMismatch(Object? item, Description mismatch, _, _) { |
| 301 | + if (item is! io.ProcessResult) { |
| 302 | + return mismatch.add('is not a process result (${item.runtimeType})'); |
| 303 | + } |
| 304 | + if (item.exitCode != 0) { |
| 305 | + return mismatch.add('exit code is not zero (${item.exitCode})'); |
| 306 | + } |
| 307 | + if (item.stdout is! String) { |
| 308 | + return mismatch.add('stdout is not String (${item.stdout.runtimeType})'); |
| 309 | + } |
| 310 | + return mismatch |
| 311 | + .add('is ') |
| 312 | + .addDescriptionOf(collapseWhitespace(item.stdout as String)) |
| 313 | + .add(' with whitespace compressed'); |
| 314 | + } |
| 315 | +} |
0 commit comments