Add the package implementation. (dart-lang/test_process#1)
diff --git a/pkgs/test_process/lib/test_process.dart b/pkgs/test_process/lib/test_process.dart new file mode 100644 index 0000000..b4f01c3 --- /dev/null +++ b/pkgs/test_process/lib/test_process.dart
@@ -0,0 +1,224 @@ +// Copyright (c) 2017, 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. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// A wrapper for [Process] that provides a convenient API for testing its +/// standard IO and interacting with it from a test. +/// +/// If the test fails, this will automatically print out any stdout and stderr +/// from the process to aid debugging. +/// +/// This may be extended to provide custom implementations of [stdoutStream] and +/// [stderrStream]. These will automatically be picked up by the [stdout] and +/// [stderr] queues, but the debug log will still contain the original output. +class TestProcess { + /// The underlying process. + final Process _process; + + /// A human-friendly description of this process. + final String description; + + /// A [StreamQueue] that emits each line of stdout from the process. + /// + /// A copy of the underlying stream can be retreived using [stdoutStream]. + StreamQueue<String> get stdout => _stdout; + StreamQueue<String> _stdout; + + /// A [StreamQueue] that emits each line of stderr from the process. + /// + /// A copy of the underlying stream can be retreived using [stderrStream]. + StreamQueue<String> get stderr => _stderr; + StreamQueue<String> _stderr; + + /// A splitter that can emit new copies of [stdout]. + final StreamSplitter<String> _stdoutSplitter; + + /// A splitter that can emit new copies of [stderr]. + final StreamSplitter<String> _stderrSplitter; + + /// The standard input sink for this process. + IOSink get stdin => _process.stdin; + + /// A buffer of mixed stdout and stderr lines. + final _log = <String>[]; + + /// Whether [_log] has been passed to [printOnFailure] yet. + bool _loggedOutput = false; + + /// Completes to [_process]'s exit code if it's exited, otherwise completes to + /// `null` immediately. + Future<int> get _exitCodeOrNull async => + await _process.exitCode.timeout(Duration.ZERO, onTimeout: () => null); + + /// Starts a process. + /// + /// [executable], [arguments], [workingDirectory], and [environment] have the + /// same meaning as for [Process.start]. + /// + /// [description] is a string description of this process; it defaults to the + /// command-line invocation. [encoding] is the [Encoding] that will be used + /// for the process's input and output; it defaults to [UTF8]. + /// + /// If [forwardStdio] is `true`, the process's stdout and stderr will be + /// printed to the console as they appear. This is only intended to be set + /// temporarily to help when debugging test failures. + static Future<TestProcess> start( + String executable, + Iterable<String> arguments, + {String workingDirectory, + Map<String, String> environment, + bool includeParentEnvironment: true, + bool runInShell: false, + String description, + Encoding encoding, + bool forwardStdio: false}) async { + var process = await Process.start(executable, arguments.toList(), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell); + + if (description == null) { + var humanExecutable = p.isWithin(p.current, executable) + ? p.relative(executable) + : executable; + description = "$humanExecutable ${arguments.join(" ")}"; + } + + encoding ??= UTF8; + return new TestProcess( + process, description, encoding: encoding, forwardStdio: forwardStdio); + } + + /// Creates a [TestProcess] for [process]. + /// + /// The [description], [encoding], and [forwardStdio] are the same as those to + /// [start]. + /// + /// This is protected, which means it should only be called by subclasses. + @protected + TestProcess(Process process, this.description, {Encoding encoding, + bool forwardStdio: false}) + : _process = process, + _stdoutSplitter = new StreamSplitter(process.stdout + .transform(encoding.decoder).transform(const LineSplitter())), + _stderrSplitter = new StreamSplitter(process.stderr + .transform(encoding.decoder).transform(const LineSplitter())) { + addTearDown(_tearDown); + expect(_process.exitCode.then((_) => _logOutput()), completes, + reason: "Process `$description` never exited."); + + _stdout = new StreamQueue(stdoutStream()); + _stderr = new StreamQueue(stderrStream()); + + // Listen eagerly so that the lines are interleaved properly between the two + // streams. + stdoutStream().listen((line) { + if (forwardStdio) print(line); + _log.add(" $line"); + }); + + stderrStream().listen((line) { + if (forwardStdio) print(line); + _log.add("[e] $line"); + }); + } + + /// A callback that's run when the test completes. + Future _tearDown() async { + // If the process is already dead, do nothing. + if (await _exitCodeOrNull != null) return; + + _process.kill(ProcessSignal.SIGKILL); + + // Log output now rather than waiting for the exitCode callback so that + // it's visible even if we time out waiting for the process to die. + await _logOutput(); + } + + /// Formats the contents of [_log] and passes them to [printOnFailure]. + Future _logOutput() async { + if (_loggedOutput) return; + _loggedOutput = true; + + var exitCode = await _exitCodeOrNull; + + // Wait a timer tick to ensure that all available lines have been flushed to + // [_log]. + await new Future.delayed(Duration.ZERO); + + var buffer = new StringBuffer(); + buffer.write("Process `$description` "); + if ((await _exitCodeOrNull) == null) { + buffer.writeln("was killed with SIGKILL in a tear-down. Output:"); + } else { + buffer.writeln("exited with exitCode $exitCode. Output:"); + } + + buffer.writeln(_log.join("\n")); + printOnFailure(buffer.toString()); + } + + /// Returns a copy of [stdout] as a single-subscriber stream. + /// + /// Each time this is called, it will return a separate copy that will start + /// from the beginning of the process. + /// + /// This can be overridden by subclasses to return a derived standard output + /// stream. This stream will then be used for [stdout]. + Stream<String> stdoutStream() => _stdoutSplitter.split(); + + /// Returns a copy of [stderr] as a single-subscriber stream. + /// + /// Each time this is called, it will return a separate copy that will start + /// from the beginning of the process. + /// + /// This can be overridden by subclasses to return a derived standard output + /// stream. This stream will then be used for [stderr]. + Stream<String> stderrStream() => _stderrSplitter.split(); + + /// Sends [signal] to the process. + /// + /// This is meant for sending specific signals. If you just want to kill the + /// process, use [kill] instead. + /// + /// Throws an [UnsupportedError] on Windows. + void signal(ProcessSignal signal) { + if (Platform.isWindows) { + throw new UnsupportedError( + "TestProcess.signal() isn't supported on Windows."); + } + + _process.kill(signal); + } + + /// Kills the process (with SIGKILL on POSIX operating systems), and returns a + /// future that completes once it's dead. + /// + /// If this is called after the process is already dead, it does nothing. + Future kill() async { + _process.kill(ProcessSignal.SIGKILL); + await _process.exitCode; + } + + /// Waits for the process to exit, and verifies that the exit code matches + /// [expectedExitCode] (if given). + /// + /// If this is called after the process is already dead, it verifies its + /// existing exit code. + Future shouldExit([expectedExitCode]) async { + var exitCode = await _process.exitCode; + if (expectedExitCode == null) return; + expect(exitCode, expectedExitCode, + reason: "Process `$description` had an unexpected exit code."); + } +}
diff --git a/pkgs/test_process/pubspec.yaml b/pkgs/test_process/pubspec.yaml index 6ca6e3a..48f5b84 100644 --- a/pkgs/test_process/pubspec.yaml +++ b/pkgs/test_process/pubspec.yaml
@@ -1,5 +1,5 @@ name: test_process -version: 1.0.0-dev +version: 1.0.0-rc.1 description: A library for testing subprocesses. author: Dart Team <misc@dartlang.org> homepage: https://github.com/dart-lang/test_process @@ -8,4 +8,10 @@ sdk: '>=1.8.0 <2.0.0' dependencies: + async: "^1.12.0" + meta: ">=0.9.0 <2.0.0" + path: "^1.0.0" test: "^0.12.19" + +dev_dependencies: + test_descriptor: "^1.0.0"
diff --git a/pkgs/test_process/test/test_process_test.dart b/pkgs/test_process/test/test_process_test.dart new file mode 100644 index 0000000..8acf2a6 --- /dev/null +++ b/pkgs/test_process/test/test_process_test.dart
@@ -0,0 +1,131 @@ +// Copyright (c) 2017, 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. + +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'package:test_descriptor/test_descriptor.dart' as d; +import 'package:test_process/test_process.dart'; + +final throwsTestFailure = throwsA(new isInstanceOf<TestFailure>()); + +void main() { + group("shouldExit()", () { + test("succeeds when the process exits with the given exit code", () async { + var process = await startDartProcess('exitCode = 42;'); + await process.shouldExit(greaterThan(12)); + }); + + test("fails when the process exits with a different exit code", () async { + var process = await startDartProcess('exitCode = 1;'); + expect(process.shouldExit(greaterThan(12)), throwsTestFailure); + }); + + test("allows any exit code without an assertion", () async { + var process = await startDartProcess('exitCode = 1;'); + await process.shouldExit(); + }); + }); + + test("kill() stops the process", () async { + var process = await startDartProcess('while (true);'); + + // Should terminate. + await process.kill(); + }); + + group("stdout and stderr", () { + test("expose the process's standard io", () async { + var process = await startDartProcess(r''' + print("hello"); + stderr.writeln("hi"); + print("\nworld"); + '''); + + expect(process.stdout, + emitsInOrder(['hello', '', 'world', emitsDone])); + expect(process.stderr, emitsInOrder(['hi', emitsDone])); + await process.shouldExit(0); + }); + + test("close when the process exits", () async { + var process = await startDartProcess(''); + expect(expectLater(process.stdout, emits('hello')), + throwsTestFailure); + expect(expectLater(process.stderr, emits('world')), + throwsTestFailure); + await process.shouldExit(0); + }); + }); + + test("stdoutStream() and stderrStream() copy the process's standard io", + () async { + var process = await startDartProcess(r''' + print("hello"); + stderr.writeln("hi"); + print("\nworld"); + '''); + + expect(process.stdoutStream(), + emitsInOrder(['hello', '', 'world', emitsDone])); + expect(process.stdoutStream(), + emitsInOrder(['hello', '', 'world', emitsDone])); + + expect(process.stderrStream(), emitsInOrder(['hi', emitsDone])); + expect(process.stderrStream(), emitsInOrder(['hi', emitsDone])); + + await process.shouldExit(0); + + expect(process.stdoutStream(), + emitsInOrder(['hello', '', 'world', emitsDone])); + expect(process.stderrStream(), emitsInOrder(['hi', emitsDone])); + }); + + test("stdin writes to the process", () async { + var process = await startDartProcess(r''' + stdinLines.listen((line) => print("> $line")); + '''); + + process.stdin.writeln("hello"); + await expectLater(process.stdout, emits("> hello")); + process.stdin.writeln("world"); + await expectLater(process.stdout, emits("> world")); + await process.kill(); + }); + + test("signal sends a signal to the process", () async { + var process = await startDartProcess(r''' + ProcessSignal.SIGHUP.watch().listen((_) => print("HUP")); + print("ready"); + '''); + + await expectLater(process.stdout, emits('ready')); + process.signal(ProcessSignal.SIGHUP); + await expectLater(process.stdout, emits('HUP')); + process.kill(); + }, testOn: "!windows"); +} + +/// Starts a Dart process running [script] in a main method. +Future<TestProcess> startDartProcess(String script) { + var dartPath = p.join(d.sandbox, 'test.dart'); + new File(dartPath).writeAsStringSync(''' + import 'dart:async'; + import 'dart:convert'; + import 'dart:io'; + + var stdinLines = stdin + .transform(UTF8.decoder) + .transform(new LineSplitter()); + + void main() { + $script + } + '''); + + return TestProcess.start(Platform.executable, ['--checked', dartPath]); +}