Skip to content
This repository was archived by the owner on Jan 3, 2025. It is now read-only.

Add the package implementation. #1

Merged
merged 1 commit into from
Mar 11, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions lib/test_process.dart
Original file line number Diff line number Diff line change
@@ -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.");
}
}
8 changes: 7 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,4 +8,10 @@ environment:
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"
131 changes: 131 additions & 0 deletions test/test_process_test.dart
Original file line number Diff line number Diff line change
@@ -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]);
}