1// Copyright 2017 The Chromium 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 5import 'dart:convert'; 6import 'dart:io'; 7 8import 'package:args/args.dart'; 9import 'package:glob/glob.dart'; 10import 'package:meta/meta.dart'; 11import 'package:path/path.dart' as path; 12 13Future<void> main(List<String> arguments) async { 14 exit(await run(arguments) ? 0 : 1); 15} 16 17Future<bool> run(List<String> arguments) async { 18 final ArgParser argParser = ArgParser( 19 allowTrailingOptions: false, 20 usageLineLength: 72, 21 ) 22 ..addOption( 23 'repeat', 24 defaultsTo: '1', 25 help: 'How many times to run each test. Set to a high value to look for flakes.', 26 valueHelp: 'count', 27 ) 28 ..addFlag( 29 'skip-on-fetch-failure', 30 defaultsTo: false, 31 help: 'Whether to skip tests that we fail to download.', 32 ) 33 ..addFlag( 34 'skip-template', 35 defaultsTo: false, 36 help: 'Whether to skip tests named "template.test".', 37 ) 38 ..addFlag( 39 'verbose', 40 defaultsTo: false, 41 help: 'Describe what is happening in detail.', 42 ) 43 ..addFlag( 44 'help', 45 defaultsTo: false, 46 negatable: false, 47 help: 'Print this help message.', 48 ); 49 50 void printHelp() { 51 print('run_tests.dart [options...] path/to/file1.test path/to/file2.test...'); 52 print('For details on the test registry format, see:'); 53 print(' https://github.com/flutter/tests/blob/master/registry/template.test'); 54 print(''); 55 print(argParser.usage); 56 print(''); 57 } 58 59 ArgResults parsedArguments; 60 try { 61 parsedArguments = argParser.parse(arguments); 62 } on ArgParserException catch (error) { 63 printHelp(); 64 print('Error: ${error.message} Use --help for usage information.'); 65 exit(1); 66 } 67 68 final int repeat = int.tryParse(parsedArguments['repeat']); 69 final bool skipOnFetchFailure = parsedArguments['skip-on-fetch-failure']; 70 final bool skipTemplate = parsedArguments['skip-template']; 71 final bool verbose = parsedArguments['verbose']; 72 final bool help = parsedArguments['help']; 73 final List<File> files = parsedArguments 74 .rest 75 .expand((String path) => Glob(path).listSync()) 76 .whereType<File>() 77 .where((File file) => !skipTemplate || path.basename(file.path) != 'template.test') 78 .toList(); 79 80 if (help || repeat == null || files.isEmpty) { 81 printHelp(); 82 if (verbose) { 83 if (repeat == null) 84 print('Error: Could not parse repeat count ("${parsedArguments['repeat']}")'); 85 if (parsedArguments.rest.isEmpty) { 86 print('Error: No file arguments specified.'); 87 } else if (files.isEmpty) { 88 print('Error: File arguments ("${parsedArguments.rest.join("\", \"")}") did not identify any real files.'); 89 } 90 } 91 return help; 92 } 93 94 if (verbose) 95 print('Starting run_tests.dart...'); 96 97 int failures = 0; 98 99 if (verbose) { 100 final String s = files.length == 1 ? '' : 's'; 101 print('${files.length} file$s specified.'); 102 print(''); 103 } 104 105 for (File file in files) { 106 if (verbose) 107 print('Processing ${file.path}...'); 108 TestFile instructions; 109 try { 110 instructions = TestFile(file); 111 } on FormatException catch (error) { 112 print('ERROR: ${error.message}'); 113 print(''); 114 failures += 1; 115 continue; 116 } on FileSystemException catch (error) { 117 print('ERROR: ${error.message}'); 118 print(' ${file.path}'); 119 print(''); 120 failures += 1; 121 continue; 122 } 123 124 final Directory checkout = Directory.systemTemp.createTempSync('flutter_customer_testing.${path.basenameWithoutExtension(file.path)}.'); 125 if (verbose) 126 print('Created temporary directory: ${checkout.path}'); 127 try { 128 bool success; 129 bool showContacts = false; 130 for (String fetchCommand in instructions.fetch) { 131 success = await shell(fetchCommand, checkout, verbose: verbose, silentFailure: skipOnFetchFailure); 132 if (!success) { 133 if (skipOnFetchFailure) { 134 if (verbose) { 135 print('Skipping (fetch failed).'); 136 } else { 137 print('Skipping ${file.path} (fetch failed).'); 138 } 139 } else { 140 print('ERROR: Failed to fetch repository.'); 141 failures += 1; 142 showContacts = true; 143 } 144 break; 145 } 146 } 147 assert(success != null); 148 if (success) { 149 if (verbose) 150 print('Running tests...'); 151 final Directory tests = Directory(path.join(checkout.path, 'tests')); 152 // TODO(ianh): Once we have a way to update source code, run that command in each directory of instructions.update 153 for (int iteration = 0; iteration < repeat; iteration += 1) { 154 if (verbose && repeat > 1) 155 print('Round ${iteration + 1} of $repeat.'); 156 for (String testCommand in instructions.tests) { 157 success = await shell(testCommand, tests, verbose: verbose); 158 if (!success) { 159 print('ERROR: One or more tests from ${path.basenameWithoutExtension(file.path)} failed.'); 160 failures += 1; 161 showContacts = true; 162 break; 163 } 164 } 165 } 166 if (verbose && success) 167 print('Tests finished.'); 168 } 169 if (showContacts) { 170 final String s = instructions.contacts.length == 1 ? '' : 's'; 171 print('Contact$s: ${instructions.contacts.join(", ")}'); 172 } 173 } finally { 174 if (verbose) 175 print('Deleting temporary directory...'); 176 checkout.deleteSync(recursive: true); 177 } 178 if (verbose) 179 print(''); 180 } 181 if (failures > 0) { 182 final String s = failures == 1 ? '' : 's'; 183 print('$failures failure$s.'); 184 return false; 185 } 186 if (verbose) { 187 print('All tests passed!'); 188 } 189 return true; 190} 191 192@immutable 193class TestFile { 194 factory TestFile(File file) { 195 final String errorPrefix = 'Could not parse: ${file.path}\n'; 196 final List<String> contacts = <String>[]; 197 final List<String> fetch = <String>[]; 198 final List<Directory> update = <Directory>[]; 199 final List<String> test = <String>[]; 200 for (String line in file.readAsLinesSync().map((String line) => line.trim())) { 201 if (line.isEmpty) { 202 // blank line 203 } else if (line.startsWith('#')) { 204 // comment 205 } else if (line.startsWith('contact=')) { 206 contacts.add(line.substring(8)); 207 } else if (line.startsWith('fetch=')) { 208 fetch.add(line.substring(6)); 209 } else if (line.startsWith('update=')) { 210 update.add(Directory(line.substring(7))); 211 } else if (line.startsWith('test=')) { 212 test.add(line.substring(5)); 213 } else { 214 throw FormatException('${errorPrefix}Unexpected directive:\n$line'); 215 } 216 } 217 if (contacts.isEmpty) 218 throw FormatException('${errorPrefix}No contacts specified. At least one contact e-mail address must be specified.'); 219 for (String email in contacts) { 220 if (!email.contains(_email) || email.endsWith('@example.com')) 221 throw FormatException('${errorPrefix}The following e-mail address appears to be an invalid e-mail address: $email'); 222 } 223 if (fetch.isEmpty) 224 throw FormatException('${errorPrefix}No "fetch" directives specified. Two lines are expected: "git clone https://github.com/USERNAME/REPOSITORY.git tests" and "git -C tests checkout HASH".'); 225 if (fetch.length < 2) 226 throw FormatException('${errorPrefix}Only one "fetch" directive specified. Two lines are expected: "git clone https://github.com/USERNAME/REPOSITORY.git tests" and "git -C tests checkout HASH".'); 227 if (!fetch[0].contains(_fetch1)) 228 throw FormatException('${errorPrefix}First "fetch" directive does not match expected pattern (expected "git clone https://github.com/USERNAME/REPOSITORY.git tests").'); 229 if (!fetch[1].contains(_fetch2)) 230 throw FormatException('${errorPrefix}Second "fetch" directive does not match expected pattern (expected "git -C tests checkout HASH").'); 231 if (update.isEmpty) 232 throw FormatException('${errorPrefix}No "update" directives specified. At least one directory must be specified. (It can be "." to just upgrade the root of the repository.)'); 233 if (test.isEmpty) 234 throw FormatException('${errorPrefix}No "test" directives specified. At least one command must be specified to run tests.'); 235 return TestFile._( 236 List<String>.unmodifiable(contacts), 237 List<String>.unmodifiable(fetch), 238 List<Directory>.unmodifiable(update), 239 List<String>.unmodifiable(test), 240 ); 241 } 242 243 const TestFile._(this.contacts, this.fetch, this.update, this.tests); 244 245 // (e-mail regexp from HTML standard) 246 static final RegExp _email = RegExp(r'''^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'''); 247 static final RegExp _fetch1 = RegExp(r'^git clone https://github.com/[-a-zA-Z0-9]+/[-_a-zA-Z0-9]+.git tests$'); 248 static final RegExp _fetch2 = RegExp(r'^git -C tests checkout [0-9a-f]+$'); 249 250 final List<String> contacts; 251 final List<String> fetch; 252 final List<Directory> update; 253 final List<String> tests; 254} 255 256final RegExp _spaces = RegExp(r' +'); 257 258Future<bool> shell(String command, Directory directory, { bool verbose = false, bool silentFailure = false }) async { 259 if (verbose) 260 print('>> $command'); 261 Process process; 262 if (Platform.isWindows) { 263 process = await Process.start('CMD.EXE', <String>['/S', '/C', '$command'], workingDirectory: directory.path); 264 } else { 265 final List<String> segments = command.trim().split(_spaces); 266 process = await Process.start(segments.first, segments.skip(1).toList(), workingDirectory: directory.path); 267 } 268 final List<String> output = <String>[]; 269 utf8.decoder.bind(process.stdout).transform(const LineSplitter()).listen(verbose ? printLog : output.add); 270 utf8.decoder.bind(process.stderr).transform(const LineSplitter()).listen(verbose ? printLog : output.add); 271 final bool success = await process.exitCode == 0; 272 if (success || silentFailure) 273 return success; 274 if (!verbose) { 275 print('>> $command'); 276 output.forEach(printLog); 277 } 278 return success; 279} 280 281void printLog(String line) { 282 print('| $line'.trimRight()); 283} 284