Add the package implementation. (#1)
diff --git a/lib/test_process.dart b/lib/test_process.dart
new file mode 100644
index 0000000..b4f01c3
--- /dev/null
+++ b/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/pubspec.yaml b/pubspec.yaml
index 6ca6e3a..48f5b84 100644
--- a/pubspec.yaml
+++ b/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/test/test_process_test.dart b/test/test_process_test.dart
new file mode 100644
index 0000000..8acf2a6
--- /dev/null
+++ b/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]);
+}