//#!/usr/bin/env dart // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. /** * testrunner is a program to run Dart unit tests. Unlike $DART/tools/test.dart, * this program is intended for 3rd parties to be able to run unit tests in * a batched fashion. As such, it adds some features and removes others. Some * of the removed features are: * * - No support for test.status files. The assumption is that tests are * expected to pass. Status file support will be added in the future. * - A restricted set of runtimes. The assumption here is that the Dart * libraries deal with platform dependencies, and so the primary * SKUs that a user of this app would be concerned with would be * Dart-native versus compiled, and client (browser) vs server. To * support these, three runtimes are allowed: 'drt-dart' and 'drt-js' (for * client native and client-compiled, respectively), and 'vm' * (for server-side native). * - No sharding of test processes. * * On the other hand, a number of features have been added: * * - The ability to filter tests by group or name. * - The ability to run tests in isolates. * - The ability to customize the format of the test result messages. * - The ability to list the tests available. * * By default, testrunner will run all tests in the current directory. * With a -R option, it will recurse into subdirectories. * Directories can also be specified on the command line; if * any are specified they will override the use of the current directory. * All files that match the `--test-file-pattern` will be included; by default * this is files with names that end in _test.dart. * * Options can be specified on the command line, via a configuration * file (`--config`) or via a test.config file in the test directory, * in decreasing order of priority. * * The three runtimes are: * * vm - run native Dart in the VM; i.e. using $DARTSDK/dart-sdk/bin/dart. * TODO(antonm): fix the option name. * drt-dart - run native Dart in content shell, the headless version of * Dartium, which is located in $DARTSDK/chromium/content_shell, if * you installed the SDK that is bundled with the editor, or available * from http://gsdview.appspot.com/dartium-archive/continuous/ * otherwise. * * TODO(antonm): fix the option name. * drt-js - run Dart compiled to Javascript in content shell. * * testrunner supports simple DOM render tests. These can use expected values * for the render output from content shell, either are textual DOM * descriptions (`--layout-tests`) or pixel renderings (`--pixel-tests`). * When running layout tests, testrunner will see if there is a file with * a .png or a .txt extension in a directory with the same name as the * test file (without extension) and with the test name as the file name. * For example, if there is a test file foo_test.dart with tests 'test1' * and 'test2', it will look for foo_test/test1.txt and foo_test/test2.txt * for text render layout files. If these exist it will do additional checks * of the rendered layout; if not, the test will fail. * * Layout file (re)generation can be done using `--regenerate`. This will * create or update the layout files (and implicitly pass the tests). * * The wrapping and execution of test files is handled by test_pipeline.dart, * which is run in an isolate. The `--pipeline` argument can be used to * specify a different script for running a test file pipeline, allowing * customization of the pipeline. * * Wrapper files are created for tests in the tmp directory, which can be * overridden with --tempdir. These files are not removed after the tests * are complete, primarily to reduce the amount of times pub must be * executed. You can use --clean-files to force file cleanup. The temp * directories will have pubspec.yaml files auto-generated unless the * original test file directories have such files; in that case the existing * files will be copied. Whenever a new pubspec file is copied or * created pub will be run (but not otherwise - so if you want to do * the equivelent of pub update you should use --clean-files and the rerun * the tests). * * TODO(gram): if the user has a pubspec.yaml file, we should inspect the * pubspec.lock file and give useful errors: * - if the lock file doesn't exit, then run pub install * - if it exists and it doesn't have the required packages (unittest or * browser), ask the user to add them and run pub install again. */ library testrunner; import 'dart:async'; import 'dart:io'; import 'dart:isolate'; import 'dart:math'; import 'package:args/args.dart'; part 'options.dart'; part 'utils.dart'; /** The set of [PipelineRunner]s to execute. */ List _tasks; /** The maximum number of pipelines that can run concurrently. */ int _maxTasks; /** The number of pipelines currently running. */ int _numTasks; /** The index of the next pipeline runner to execute. */ int _nextTask; /** The sink to use for high-value messages, like test results. */ IOSink _outSink; /** The sink to use for low-value messages, like verbose output. */ IOSink _logSink; /** * The last temp test directory we accessed; we use this to know if we * need to check the pub configuration. */ String _testDir; /** * The user can specify output streams on the command line, using 'none', * 'stdout', 'stderr', or a file path; [getSink] will take such a name * and return an appropriate [IOSink]. */ IOSink getSink(String name) { if (name == null || name == 'none') { return null; } if (name == 'stdout') { return stdout; } if (name == 'stderr') { return stderr; } var f = new File(name); return f.openWrite(); } /** * Given a [List] of [testFiles], either print the list or create * and execute pipelines for the files. */ void processTests(Map config, List testFiles) { _outSink = getSink(config['out']); _logSink = getSink(config['log']); if (config['list-files']) { if (_outSink != null) { for (var i = 0; i < testFiles.length; i++) { _outSink.write(testFiles[i]); _outSink.write('\n'); } } } else { _maxTasks = min(config['tasks'], testFiles.length); _numTasks = 0; _nextTask = 0; spawnTasks(config, testFiles); } } /** * Create or update a pubspec for the target test directory. We use the * source directory pubspec if available; otherwise we create a minimal one. * We return a Future if we are running pub install, or null otherwise. */ Future doPubConfig(Path sourcePath, String sourceDir, Path targetPath, String targetDir, String pub, String runtime) { // Make sure the target directory exists. var d = new Directory(targetDir); if (!d.existsSync()) { d.createSync(recursive: true); } // If the source has no pubspec, but the dest does, leave // things as they are. If neither do, create one in dest. var sourcePubSpecName = new Path(sourceDir).append("pubspec.yaml"). toNativePath(); var targetPubSpecName = new Path(targetDir).append("pubspec.yaml"). toNativePath(); var sourcePubSpec = new File(sourcePubSpecName); var targetPubSpec = new File(targetPubSpecName); if (!sourcePubSpec.existsSync()) { if (targetPubSpec.existsSync()) { return null; } else { // Create one. if (runtime == 'vm') { writeFile(targetPubSpecName, "name: testrunner\ndependencies:\n unittest: any\n"); } else { writeFile(targetPubSpecName, "name: testrunner\ndependencies:\n unittest: any\n browser: any\n"); } } } else { if (targetPubSpec.existsSync()) { // If there is a source one, and it is older than the target, // leave the target as is. if (sourcePubSpec.lastModifiedSync().millisecondsSinceEpoch < targetPubSpec.lastModifiedSync().millisecondsSinceEpoch) { return null; } } // Source exists and is newer than target or there is no target; // copy the source to the target. If there is a pubspec.lock file, // copy that too. var s = sourcePubSpec.readAsStringSync(); targetPubSpec.writeAsStringSync(s); var sourcePubLock = new File(sourcePubSpecName.replaceAll(".yaml", ".lock")); if (sourcePubLock.existsSync()) { var targetPubLock = new File(targetPubSpecName.replaceAll(".yaml", ".lock")); s = sourcePubLock.readAsStringSync(); targetPubLock.writeAsStringSync(s); } } // A new target pubspec was created so run pub install. return _processHelper(pub, [ 'install' ], workingDir: targetDir); } /** Execute as many tasks as possible up to the maxTasks limit. */ void spawnTasks(Map config, List testFiles) { var verbose = config['verbose']; // If we were running in the VM and the immediate flag was set, we have // already printed the important messages (i.e. prefixed with ###), // so we should skip them now. var skipNonVerbose = config['immediate'] && config['runtime'] == 'vm'; while (_numTasks < _maxTasks && _nextTask < testFiles.length) { ++_numTasks; var testfile = testFiles[_nextTask++]; config['testfile'] = testfile; ReceivePort port = new ReceivePort(); port.receive((msg, _) { List stdout = msg[0]; List stderr = msg[1]; List log = msg[2]; int exitCode = msg[3]; writelog(stdout, _outSink, _logSink, verbose, skipNonVerbose); writelog(stderr, _outSink, _logSink, true, skipNonVerbose); writelog(log, _outSink, _logSink, verbose, skipNonVerbose); port.close(); --_numTasks; if (exitCode == 0 || !config['stop-on-failure']) { spawnTasks(config, testFiles); } if (_numTasks == 0) { // No outstanding tasks; we're all done. // We could later print a summary report here. } }); SendPort s = spawnUri(config['pipeline']); // Get the names of the source and target test files and containing // directories. var testPath = new Path(testfile); var sourcePath = testPath.directoryPath; var sourceDir = sourcePath.toNativePath(); var targetPath = new Path(config["tempdir"]); var normalizedTarget = testPath.directoryPath.toNativePath() .replaceAll(Platform.pathSeparator, '_') .replaceAll(':', '_'); targetPath = targetPath.append("${normalizedTarget}_${config['runtime']}"); var targetDir = targetPath.toNativePath(); config['targetDir'] = targetDir; // If this is a new target dir, we need to redo the pub check. var f = null; if (targetDir != _testDir) { f = doPubConfig(sourcePath, sourceDir, targetPath, targetDir, config['pub'], config['runtime']); _testDir = targetDir; } if (f == null) { s.send(config, port.toSendPort()); } else { f.then((_) { s.send(config, port.toSendPort()); }); break; // Don't do any more until pub is done. } } } /** * Our tests are configured so that critical messages have a '###' prefix. * [writelog] takes the output from a pipeline execution and writes it to * our output sinks. It will strip the '###' if necessary on critical * messages; other messages will only be written if verbose output was * specified. */ void writelog(List messages, IOSink out, IOSink log, bool includeVerbose, bool skipNonVerbose) { for (var i = 0; i < messages.length; i++) { var msg = messages[i]; if (msg.startsWith('###')) { if (!skipNonVerbose && out != null) { out.write(msg.substring(3)); out.write('\n'); } } else if (msg.startsWith('CONSOLE MESSAGE:')) { if (!skipNonVerbose && out != null) { int idx = msg.indexOf('###'); if (idx > 0) { out.write(msg.substring(idx + 3)); out.write('\n'); } } } else if (includeVerbose && log != null) { log.write(msg); log.write('\n'); } } } normalizeFilter(List filter) { // We want the filter to be a quoted string or list of quoted // strings. for (var i = 0; i < filter.length; i++) { var f = filter[i]; if (f[0] != "'" && f[0] != '"') { filter[i] = "'$f'"; // TODO(gram): Quote embedded quotes. } } return filter; } void sanitizeConfig(Map config, ArgParser parser) { config['layout'] = config['layout-text'] || config['layout-pixel']; config['verbose'] = (config['log'] != 'none' && !config['list-groups']); config['timeout'] = int.parse(config['timeout']); config['tasks'] = int.parse(config['tasks']); var dartsdk = config['dartsdk']; var pathSep = Platform.pathSeparator; if (dartsdk == null) { var opt = new Options(); var runner = opt.executable; var idx = runner.indexOf('dart-sdk'); if (idx < 0) { print("Please use --dartsdk option or run using the dart executable " "from the Dart SDK"); exit(0); } dartsdk = runner.substring(0, idx); } if (Platform.operatingSystem == 'macos') { config['dart2js'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}dart2js'; config['dart'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}dart'; config['pub'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}pub'; config['drt'] = '$dartsdk/chromium/Content Shell.app/Contents/MacOS/Content Shell'; } else if (Platform.operatingSystem == 'linux') { config['dart2js'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}dart2js'; config['dart'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}dart'; config['pub'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}pub'; config['drt'] = '$dartsdk${pathSep}chromium${pathSep}content_shell'; } else { config['dart2js'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}dart2js.bat'; config['dart'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}dart.exe'; config['pub'] = '$dartsdk${pathSep}dart-sdk${pathSep}bin${pathSep}pub.bat'; config['drt'] = '$dartsdk${pathSep}chromium${pathSep}content_shell.exe'; } for (var prog in [ 'drt', 'dart', 'pub', 'dart2js' ]) { config[prog] = makePathAbsolute(config[prog]); } config['runnerDir'] = runnerDirectory; config['include'] = normalizeFilter(config['include']); config['exclude'] = normalizeFilter(config['exclude']); } main() { var optionsParser = getOptionParser(); var options = loadConfiguration(optionsParser); if (isSane(options)) { if (options['list-options']) { printOptions(optionsParser, options, false, stdout); } else if (options['list-all-options']) { printOptions(optionsParser, options, true, stdout); } else { var config = new Map(); for (var option in options.options) { config[option] = options[option]; } var rest = []; // Process the remmaining command line args. If they look like // options then split them up and add them to the map; they may be for // custom pipelines. for (var other in options.rest) { var idx; if (other.startsWith('--') && (idx = other.indexOf('=')) > 0) { var optName = other.substring(2, idx); var optValue = other.substring(idx+1); config[optName] = optValue; } else { rest.add(other); } } sanitizeConfig(config, optionsParser); // Build the list of tests and then execute them. List dirs = rest; if (dirs.length == 0) { dirs.add('.'); // Use current working directory as default. } var f = buildFileList(dirs, new RegExp(config['test-file-pattern']), config['recurse']); if (config['sort']) f.sort(); processTests(config, f); } } }