Do some cleanup and add tests (#39)

diff --git a/.github/workflows/process_runner.yml b/.github/workflows/process_runner.yml
index 32faff2..32529d5 100644
--- a/.github/workflows/process_runner.yml
+++ b/.github/workflows/process_runner.yml
@@ -2,9 +2,9 @@
 
 on:
   push:
-    branches: [master]
+    branches: [main]
   pull_request:
-    branches: [master]
+    branches: [main]
   workflow_dispatch:
 
 jobs:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 904250d..038bb4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
 # Change Log for `process_runner`
 
+## 4.2.2
+
+- Refactoring the main `run` method in `ProcessRunner` into smaller, more
+  manageable private methods.
+- Enhancing `FakeProcessManager` to differentiate commands based on their
+  working directory and the `runInShell` parameter, and adding more
+  comprehensive tests for it.
+- Making the addition of dependencies in `ProcessPool` idempotent (do nothing if
+  the dependency already exists).
+- Updating the CI to track the main branch.
+- Adding a number of new tests to cover `stdin` handling, `runInShell` behavior,
+  and exception scenarios.
+
 ## 4.2.1
 
 - Updates analysis_options.yaml to modern lints, and updates formatting.
diff --git a/lib/src/process_pool.dart b/lib/src/process_pool.dart
index 93481c7..5e48286 100644
--- a/lib/src/process_pool.dart
+++ b/lib/src/process_pool.dart
@@ -59,7 +59,7 @@
       throw ProcessRunnerException('A job cannot depend on itself');
     }
     if (_dependsOn.contains(job)) {
-      throw ProcessRunnerException('$job is already a dependency of $this');
+      return;
     }
     if (job._dependsOn.contains(this)) {
       throw ProcessRunnerException(
@@ -225,13 +225,16 @@
   WorkerJobGroup(Iterable<DependentJob> jobs,
       {Iterable<DependentJob>? dependsOn, this.name = 'Group'})
       : assert(jobs.isNotEmpty),
-        jobs = <DependentJob>[...jobs],
+        jobs = jobs.toList(),
         super(dependsOn: <DependentJob>{
           ...jobs.toSet(),
           if (dependsOn != null) ...dependsOn
         }) {
     // Make sure they run in series, and they depend on anything that the group
     // depends on.
+    if (dependsOn != null) {
+      this.jobs.first.addDependencies(dependsOn);
+    }
     for (var i = 1; i < this.jobs.length; i++) {
       this.jobs[i].addDependency(this.jobs[i - 1]);
     }
diff --git a/lib/src/process_runner.dart b/lib/src/process_runner.dart
index aef2637..2ec7868 100644
--- a/lib/src/process_runner.dart
+++ b/lib/src/process_runner.dart
@@ -220,81 +220,26 @@
       stderr.write(
           'Running "${commandLine.join(' ')}" in ${workingDirectory.path}.\n');
     }
+
+    final process = await _startProcess(
+        commandLine, workingDirectory, runInShell, startMode);
+
     final stdoutOutput = <int>[];
     final stderrOutput = <int>[];
     final combinedOutput = <int>[];
-    final stdoutComplete = Completer<void>();
-    final stderrComplete = Completer<void>();
-    final stdinComplete = Completer<void>();
+    final completers = _streamProcessOutput(
+      process,
+      stdin,
+      stdoutOutput,
+      stderrOutput,
+      combinedOutput,
+      printOutput,
+      startMode,
+    );
 
-    late Process process;
-    Future<int> allComplete() async {
-      if (stdin != null) {
-        await stdinComplete.future;
-        await process.stdin.close();
-      }
-      await stderrComplete.future;
-      await stdoutComplete.future;
-      return startMode == ProcessStartMode.normal
-          ? process.exitCode
-          : Future<int>.value(0);
-    }
+    final exitCode =
+        await _waitForProcess(process, startMode, completers, stdin);
 
-    try {
-      process = await processManager.start(
-        commandLine,
-        workingDirectory: workingDirectory.absolute.path,
-        environment: environment,
-        runInShell: runInShell,
-        mode: startMode,
-      );
-      if (startMode == ProcessStartMode.normal ||
-          startMode == ProcessStartMode.detachedWithStdio) {
-        if (stdin != null) {
-          stdin.listen((List<int> data) {
-            process.stdin.add(data);
-          }, onDone: () async => stdinComplete.complete());
-        }
-        process.stdout.listen(
-          (List<int> event) {
-            stdoutOutput.addAll(event);
-            combinedOutput.addAll(event);
-            if (printOutput!) {
-              stdout.add(event);
-            }
-          },
-          onDone: () async => stdoutComplete.complete(),
-        );
-        process.stderr.listen(
-          (List<int> event) {
-            stderrOutput.addAll(event);
-            combinedOutput.addAll(event);
-            if (printOutput!) {
-              stderr.add(event);
-            }
-          },
-          onDone: () async => stderrComplete.complete(),
-        );
-      } else {
-        // For "detached", we can't wait for anything to complete.
-        stdinComplete.complete();
-        stdoutComplete.complete();
-        stderrComplete.complete();
-      }
-    } on ProcessException catch (e) {
-      final message =
-          'Running "${commandLine.join(' ')}" in ${workingDirectory.path} '
-          'failed with:\n$e';
-      throw ProcessRunnerException(message);
-      // ignore: avoid_catching_errors
-    } on ArgumentError catch (e) {
-      final message =
-          'Running "${commandLine.join(' ')}" in ${workingDirectory.path} '
-          'failed with:\n$e';
-      throw ProcessRunnerException(message);
-    }
-
-    final exitCode = await allComplete();
     if (exitCode != 0 && !failOk) {
       final message =
           'Running "${commandLine.join(' ')}" in ${workingDirectory.path} '
@@ -320,4 +265,103 @@
       decoder: decoder,
     );
   }
+
+  Future<Process> _startProcess(
+    List<String> commandLine,
+    Directory workingDirectory,
+    bool runInShell,
+    ProcessStartMode startMode,
+  ) async {
+    try {
+      return await processManager.start(
+        commandLine,
+        workingDirectory: workingDirectory.absolute.path,
+        environment: environment,
+        runInShell: runInShell,
+        mode: startMode,
+      );
+    } on ProcessException catch (e) {
+      final message =
+          'Running "${commandLine.join(' ')}" in ${workingDirectory.path} '
+          'failed with:\n$e';
+      throw ProcessRunnerException(message);
+      // ignore: avoid_catching_errors
+    } on ArgumentError catch (e) {
+      final message =
+          'Running "${commandLine.join(' ')}" in ${workingDirectory.path} '
+          'failed with:\n$e';
+      throw ProcessRunnerException(message);
+    }
+  }
+
+  List<Completer<void>> _streamProcessOutput(
+    Process process,
+    Stream<List<int>>? stdin,
+    List<int> stdoutOutput,
+    List<int> stderrOutput,
+    List<int> combinedOutput,
+    bool printOutput,
+    ProcessStartMode startMode,
+  ) {
+    final stdoutComplete = Completer<void>();
+    final stderrComplete = Completer<void>();
+    final stdinComplete = Completer<void>();
+
+    if (startMode == ProcessStartMode.normal ||
+        startMode == ProcessStartMode.detachedWithStdio) {
+      if (stdin != null) {
+        stdin.listen((List<int> data) {
+          process.stdin.add(data);
+        }, onDone: () async => stdinComplete.complete());
+      } else {
+        stdinComplete.complete();
+      }
+      process.stdout.listen(
+        (List<int> event) {
+          stdoutOutput.addAll(event);
+          combinedOutput.addAll(event);
+          if (printOutput) {
+            stdout.add(event);
+          }
+        },
+        onDone: () async => stdoutComplete.complete(),
+      );
+      process.stderr.listen(
+        (List<int> event) {
+          stderrOutput.addAll(event);
+          combinedOutput.addAll(event);
+          if (printOutput) {
+            stderr.add(event);
+          }
+        },
+        onDone: () async => stderrComplete.complete(),
+      );
+    } else {
+      stdinComplete.complete();
+      stdoutComplete.complete();
+      stderrComplete.complete();
+    }
+    return [stdinComplete, stdoutComplete, stderrComplete];
+  }
+
+  Future<int> _waitForProcess(
+    Process process,
+    ProcessStartMode startMode,
+    List<Completer<void>> completers,
+    Stream<List<int>>? stdin,
+  ) async {
+    final stdinComplete = completers[0];
+    final stdoutComplete = completers[1];
+    final stderrComplete = completers[2];
+
+    if (stdin != null) {
+      await stdinComplete.future;
+      await process.stdin.close();
+    }
+    await stderrComplete.future;
+    await stdoutComplete.future;
+    return startMode == ProcessStartMode.normal
+        ? process.exitCode
+        : Future<int>.value(0);
+  }
 }
diff --git a/lib/test/fake_process_manager.dart b/lib/test/fake_process_manager.dart
index e78fc12..25d9b60 100644
--- a/lib/test/fake_process_manager.dart
+++ b/lib/test/fake_process_manager.dart
@@ -13,9 +13,52 @@
 test_package.TypeMatcher<T> isInstanceOf<T>() => isA<T>();
 
 class FakeInvocationRecord {
-  FakeInvocationRecord(this.invocation, [this.workingDirectory]);
+  FakeInvocationRecord(
+    this.invocation, {
+    this.workingDirectory,
+    this.runInShell = false,
+  });
   final List<String> invocation;
   final String? workingDirectory;
+  final bool runInShell;
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) {
+      return true;
+    }
+    if (other.runtimeType != runtimeType) {
+      return false;
+    }
+    if (other is! FakeInvocationRecord) {
+      return false;
+    }
+    if (other.workingDirectory != workingDirectory) {
+      return false;
+    }
+    if (other.runInShell != runInShell) {
+      return false;
+    }
+    if (other.invocation.length != invocation.length) {
+      return false;
+    }
+    for (var i = 0; i < invocation.length; ++i) {
+      if (other.invocation[i] != invocation[i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @override
+  int get hashCode =>
+      Object.hashAll([workingDirectory, runInShell, ...invocation]);
+
+  @override
+  String toString() {
+    return 'FakeInvocationRecord(invocation: $invocation, '
+        'workingDirectory: $workingDirectory, runInShell: $runInShell)';
+  }
 }
 
 /// A mock that can be used to fake a process manager that runs commands and
@@ -31,7 +74,7 @@
 
   /// Set to true if all commands run with this process manager should throw an
   /// exception.
-  final bool commandsThrow;
+  bool commandsThrow;
 
   /// The list of results that will be sent back, organized by the command line
   /// that will produce them. Each command line has a list of returned stdout
@@ -66,52 +109,54 @@
 
   ProcessResult _popResult(FakeInvocationRecord command) {
     expect(fakeResults, isNotEmpty);
-    late List<ProcessResult> foundResult;
-    late FakeInvocationRecord foundCommand;
-    for (final fakeCommand in fakeResults.keys) {
-      if (fakeCommand.invocation.length != command.invocation.length) {
-        continue;
-      }
-      var listsIdentical = true;
-      for (var i = 0; i < fakeCommand.invocation.length; ++i) {
-        if (fakeCommand.invocation[i] != command.invocation[i]) {
-          listsIdentical = false;
-          break;
-        }
-      }
-      if (listsIdentical) {
-        foundResult = fakeResults[fakeCommand]!;
-        foundCommand = fakeCommand;
-        break;
-      }
-    }
+    final foundResult = fakeResults[command];
     expect(foundResult, isNotNull,
         reason: '$command not found in expected results.');
     expect(foundResult, isNotEmpty);
-    return fakeResults[foundCommand]?.removeAt(0) ??
-        ProcessResult(0, 0, '', '');
+    return fakeResults[command]!.removeAt(0);
   }
 
   FakeProcess _popProcess(FakeInvocationRecord command) =>
       FakeProcess(_popResult(command), stdinResults);
 
   Future<Process> _nextProcess(
-      List<String> invocation, String? workingDirectory) async {
-    final record = FakeInvocationRecord(invocation, workingDirectory);
+    List<String> invocation, {
+    String? workingDirectory,
+    bool runInShell = false,
+  }) async {
+    final record = FakeInvocationRecord(
+      invocation,
+      workingDirectory: workingDirectory,
+      runInShell: runInShell,
+    );
     invocations.add(record);
     return Future<Process>.value(_popProcess(record));
   }
 
   ProcessResult _nextResultSync(
-      List<String> invocation, String? workingDirectory) {
-    final record = FakeInvocationRecord(invocation, workingDirectory);
+    List<String> invocation, {
+    String? workingDirectory,
+    bool runInShell = false,
+  }) {
+    final record = FakeInvocationRecord(
+      invocation,
+      workingDirectory: workingDirectory,
+      runInShell: runInShell,
+    );
     invocations.add(record);
     return _popResult(record);
   }
 
   Future<ProcessResult> _nextResult(
-      List<String> invocation, String? workingDirectory) async {
-    final record = FakeInvocationRecord(invocation, workingDirectory);
+    List<String> invocation, {
+    String? workingDirectory,
+    bool runInShell = false,
+  }) async {
+    final record = FakeInvocationRecord(
+      invocation,
+      workingDirectory: workingDirectory,
+      runInShell: runInShell,
+    );
     invocations.add(record);
     return Future<ProcessResult>.value(_popResult(record));
   }
@@ -139,7 +184,11 @@
     if (commandsThrow) {
       throw const ProcessException('failed_executable', <String>[]);
     }
-    return _nextResult(command as List<String>, workingDirectory);
+    return _nextResult(
+      command as List<String>,
+      workingDirectory: workingDirectory,
+      runInShell: runInShell,
+    );
   }
 
   @override
@@ -155,7 +204,11 @@
     if (commandsThrow) {
       throw const ProcessException('failed_executable', <String>[]);
     }
-    return _nextResultSync(command as List<String>, workingDirectory);
+    return _nextResultSync(
+      command as List<String>,
+      workingDirectory: workingDirectory,
+      runInShell: runInShell,
+    );
   }
 
   @override
@@ -170,7 +223,11 @@
     if (commandsThrow) {
       throw const ProcessException('failed_executable', <String>[]);
     }
-    return _nextProcess(command as List<String>, workingDirectory);
+    return _nextProcess(
+      command as List<String>,
+      workingDirectory: workingDirectory,
+      runInShell: runInShell,
+    );
   }
 }
 
diff --git a/pubspec.yaml b/pubspec.yaml
index 74e91aa..02f0f49 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -4,7 +4,7 @@
 
 name: process_runner
 description: A process invocation abstraction for Dart that manages a multi-process queue.
-version: 4.2.1
+version: 4.2.2
 repository: https://github.com/google/process_runner
 issue_tracker: https://github.com/google/process_runner/issues
 documentation: https://github.com/google/process_runner/blob/master/process_runner/README.md
@@ -24,7 +24,7 @@
   dart_flutter_team_lints: ^3.5.2
 
 environment:
-  sdk: ">=2.12.0 <4.0.0"
+  sdk: ">=2.14.0 <4.0.0"
 
 binaries:
   process_runner:
diff --git a/test/src/fake_process_manager_test.dart b/test/src/fake_process_manager_test.dart
index 5060361..0488584 100644
--- a/test/src/fake_process_manager_test.dart
+++ b/test/src/fake_process_manager_test.dart
@@ -5,24 +5,145 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:file/memory.dart';
+import 'package:process_runner/process_runner.dart';
 import 'package:process_runner/test/fake_process_manager.dart';
 import 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
 
 void main() {
-  group('ArchivePublisher', () {
+  group('FakeProcessManager', () {
     final stdinCaptured = <String>[];
     void captureStdin(String item) {
       stdinCaptured.add(item);
     }
 
-    var processManager = FakeProcessManager(captureStdin);
+    late MemoryFileSystem fs;
+    late FakeProcessManager processManager;
+    late ProcessRunner processRunner;
 
     setUp(() async {
+      fs = MemoryFileSystem(
+          style: Platform.isWindows
+              ? FileSystemStyle.windows
+              : FileSystemStyle.posix);
       processManager = FakeProcessManager(captureStdin);
+      processRunner = ProcessRunner(
+          processManager: processManager,
+          defaultWorkingDirectory: fs.currentDirectory);
     });
 
-    tearDown(() async {});
+    tearDown(() async {
+      stdinCaptured.clear();
+    });
 
+    test('fakes processes', () async {
+      processManager.fakeResults = <FakeInvocationRecord, List<ProcessResult>>{
+        FakeInvocationRecord(
+          <String>['command', 'arg1', 'arg2'],
+          workingDirectory: fs.currentDirectory.path,
+        ): <ProcessResult>[
+          ProcessResult(0, 0, 'output1', ''),
+        ],
+        FakeInvocationRecord(
+          <String>['command2', 'arg1', 'arg2'],
+          workingDirectory: fs.currentDirectory.path,
+        ): <ProcessResult>[
+          ProcessResult(0, 0, 'output2', ''),
+        ],
+      };
+      var result =
+          await processRunner.runProcess(<String>['command', 'arg1', 'arg2']);
+      expect(result.stdout, equals('output1'));
+      result =
+          await processRunner.runProcess(<String>['command2', 'arg1', 'arg2']);
+      expect(result.stdout, equals('output2'));
+      processManager.verifyCalls(<FakeInvocationRecord>[
+        FakeInvocationRecord(
+          <String>['command', 'arg1', 'arg2'],
+          workingDirectory: fs.currentDirectory.path,
+        ),
+        FakeInvocationRecord(
+          <String>['command2', 'arg1', 'arg2'],
+          workingDirectory: fs.currentDirectory.path,
+        ),
+      ]);
+    });
+    test('distinguishes commands by working directory', () async {
+      processManager.fakeResults = <FakeInvocationRecord, List<ProcessResult>>{
+        FakeInvocationRecord(
+          <String>['command', 'arg'],
+          workingDirectory: Directory('wd1').absolute.path,
+        ): <ProcessResult>[
+          ProcessResult(0, 0, 'output1', ''),
+        ],
+        FakeInvocationRecord(
+          <String>['command', 'arg'],
+          workingDirectory: Directory('wd2').absolute.path,
+        ): <ProcessResult>[
+          ProcessResult(0, 0, 'output2', ''),
+        ],
+      };
+      var result = await processRunner.runProcess(
+        <String>['command', 'arg'],
+        workingDirectory: Directory('wd1'),
+      );
+      expect(result.stdout, equals('output1'));
+      result = await processRunner.runProcess(
+        <String>['command', 'arg'],
+        workingDirectory: Directory('wd2'),
+      );
+      expect(result.stdout, equals('output2'));
+      processManager.verifyCalls(<FakeInvocationRecord>[
+        FakeInvocationRecord(
+          <String>['command', 'arg'],
+          workingDirectory: Directory('wd1').absolute.path,
+        ),
+        FakeInvocationRecord(
+          <String>['command', 'arg'],
+          workingDirectory: Directory('wd2').absolute.path,
+        ),
+      ]);
+    });
+    test('distinguishes commands by runInShell', () async {
+      processManager.fakeResults = <FakeInvocationRecord, List<ProcessResult>>{
+        FakeInvocationRecord(
+          <String>['command', 'arg'],
+          workingDirectory: fs.currentDirectory.path,
+          runInShell: true,
+        ): <ProcessResult>[
+          ProcessResult(0, 0, 'output1', ''),
+        ],
+        FakeInvocationRecord(
+          <String>['command', 'arg'],
+          workingDirectory: fs.currentDirectory.path,
+          runInShell: false,
+        ): <ProcessResult>[
+          ProcessResult(0, 0, 'output2', ''),
+        ],
+      };
+      var result = await processRunner.runProcess(
+        <String>['command', 'arg'],
+        runInShell: true,
+      );
+      expect(result.stdout, equals('output1'));
+      result = await processRunner.runProcess(
+        <String>['command', 'arg'],
+        runInShell: false,
+      );
+      expect(result.stdout, equals('output2'));
+      processManager.verifyCalls(<FakeInvocationRecord>[
+        FakeInvocationRecord(
+          <String>['command', 'arg'],
+          workingDirectory: fs.currentDirectory.path,
+          runInShell: true,
+        ),
+        FakeInvocationRecord(
+          <String>['command', 'arg'],
+          workingDirectory: fs.currentDirectory.path,
+          runInShell: false,
+        ),
+      ]);
+    });
     test('start works', () async {
       final calls = <FakeInvocationRecord, List<ProcessResult>>{
         FakeInvocationRecord(<String>['command', 'arg1', 'arg2']):
@@ -114,5 +235,29 @@
       }
       processManager.verifyCalls(calls.keys.toList());
     });
+
+    test('run throws when commandsThrow is true', () async {
+      processManager = FakeProcessManager(captureStdin, commandsThrow: true);
+      expect(
+        () async => await processManager.run(<String>['command']),
+        throwsA(isA<ProcessException>()),
+      );
+    });
+
+    test('runSync throws when commandsThrow is true', () async {
+      processManager = FakeProcessManager(captureStdin, commandsThrow: true);
+      expect(
+        () => processManager.runSync(<String>['command']),
+        throwsA(isA<ProcessException>()),
+      );
+    });
+
+    test('start throws when commandsThrow is true', () async {
+      processManager = FakeProcessManager(captureStdin, commandsThrow: true);
+      expect(
+        () async => await processManager.start(<String>['command']),
+        throwsA(isA<ProcessException>()),
+      );
+    });
   });
 }
diff --git a/test/src/process_pool_test.dart b/test/src/process_pool_test.dart
index 3bb11da..9a964f9 100644
--- a/test/src/process_pool_test.dart
+++ b/test/src/process_pool_test.dart
@@ -4,11 +4,14 @@
 
 import 'dart:io';
 
+import 'package:file/file.dart';
+import 'package:file/memory.dart';
 import 'package:process_runner/process_runner.dart';
 import 'package:process_runner/test/fake_process_manager.dart';
 import 'package:test/test.dart';
 
 void main() {
+  late FileSystem fs;
   late FakeProcessManager fakeProcessManager;
   late ProcessRunner processRunner;
   late ProcessPool processPool;
@@ -16,17 +19,21 @@
 
   setUp(() {
     fakeProcessManager = FakeProcessManager((String value) {});
+    fs = MemoryFileSystem(
+        style: Platform.isWindows
+            ? FileSystemStyle.windows
+            : FileSystemStyle.posix);
     processRunner = ProcessRunner(
       processManager: fakeProcessManager,
-      defaultWorkingDirectory: Directory(testPath),
+      defaultWorkingDirectory: fs.directory(testPath),
     );
     processPool = ProcessPool(processRunner: processRunner, printReport: null);
   });
 
   test('startWorkers works', () async {
     final calls = <FakeInvocationRecord, List<ProcessResult>>{
-      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'output1', ''),
       ],
     };
@@ -39,8 +46,8 @@
   });
   test('runToCompletion works', () async {
     final calls = <FakeInvocationRecord, List<ProcessResult>>{
-      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'output1', ''),
       ],
     };
@@ -53,8 +60,8 @@
   });
   test('failed tests report results', () async {
     final calls = <FakeInvocationRecord, List<ProcessResult>>{
-      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, -1, 'output1', 'stderr1'),
       ],
     };
@@ -70,8 +77,8 @@
   });
   test('failed tests throw when failOk is false', () async {
     final calls = <FakeInvocationRecord, List<ProcessResult>>{
-      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, -1, 'output1', 'stderr1'),
       ],
     };
@@ -90,8 +97,8 @@
     processRunner = ProcessRunner(processManager: fakeProcessManager);
     processPool = ProcessPool(processRunner: processRunner, printReport: null);
     final calls = <FakeInvocationRecord, List<ProcessResult>>{
-      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, -1, 'output1', 'stderr1'),
       ],
     };
@@ -106,32 +113,29 @@
 
   test('Commands in task groups run in order, but parallel with other groups',
       () async {
-    fakeProcessManager = FakeProcessManager((String value) {});
-    processRunner = ProcessRunner(processManager: fakeProcessManager);
-    processPool = ProcessPool(processRunner: processRunner, printReport: null);
     final calls = <FakeInvocationRecord, List<ProcessResult>>{
-      FakeInvocationRecord(<String>['commandA1', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandA1', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputA1', 'stderrA1'),
       ],
-      FakeInvocationRecord(<String>['commandB1', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandB1', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputB1', 'stderrB1'),
       ],
-      FakeInvocationRecord(<String>['commandA2', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandA2', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputA2', 'stderrA2'),
       ],
-      FakeInvocationRecord(<String>['commandB2', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandB2', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, -1, 'outputB2', 'stderrB2'),
       ],
-      FakeInvocationRecord(<String>['commandA3', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandA3', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputA3', 'stderrA3'),
       ],
-      FakeInvocationRecord(<String>['commandB3', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandB3', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputB3', 'stderrB3'),
       ],
     };
@@ -191,32 +195,29 @@
     );
   });
   test('Commands in task groups can depend on other groups', () async {
-    fakeProcessManager = FakeProcessManager((String value) {});
-    processRunner = ProcessRunner(processManager: fakeProcessManager);
-    processPool = ProcessPool(processRunner: processRunner, printReport: null);
     final calls = <FakeInvocationRecord, List<ProcessResult>>{
-      FakeInvocationRecord(<String>['commandA1', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandA1', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputA1', 'stderrA1'),
       ],
-      FakeInvocationRecord(<String>['commandB1', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandB1', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputB1', 'stderrB1'),
       ],
-      FakeInvocationRecord(<String>['commandA2', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandA2', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputA2', 'stderrA2'),
       ],
-      FakeInvocationRecord(<String>['commandB2', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandB2', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, -1, 'outputB2', 'stderrB2'),
       ],
-      FakeInvocationRecord(<String>['commandA3', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandA3', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputA3', 'stderrA3'),
       ],
-      FakeInvocationRecord(<String>['commandB3', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandB3', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputB3', 'stderrB3'),
       ],
     };
@@ -309,16 +310,16 @@
     processRunner = ProcessRunner(processManager: fakeProcessManager);
     processPool = ProcessPool(processRunner: processRunner, printReport: null);
     final calls = <FakeInvocationRecord, List<ProcessResult>>{
-      FakeInvocationRecord(<String>['commandA1', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandA1', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputA1', 'stderrA1'),
       ],
-      FakeInvocationRecord(<String>['commandB1', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandB1', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputB1', 'stderrB1'),
       ],
-      FakeInvocationRecord(<String>['commandC1', 'arg1', 'arg2'], testPath):
-          <ProcessResult>[
+      FakeInvocationRecord(<String>['commandC1', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
         ProcessResult(0, 0, 'outputC1', 'stderrC1'),
       ],
     };
@@ -351,4 +352,58 @@
       ),
     );
   });
+
+  test('throws when a job depends on a job not in the pool', () async {
+    final jobA = WorkerJob(<String>['commandA'], name: 'job A');
+    final jobB = WorkerJob(<String>['commandB'], name: 'job B');
+    jobA.addDependency(jobB);
+    final jobs = <DependentJob>[jobA];
+    expect(
+      () => processPool.runToCompletion(jobs),
+      throwsA(isA<ProcessRunnerException>().having(
+        (e) => e.message,
+        'message',
+        contains("job A has dependent jobs that aren't scheduled to be run"),
+      )),
+    );
+  });
+
+  test('WorkerJob with stdin works', () async {
+    final stdinCaptured = <String>[];
+    fakeProcessManager = FakeProcessManager(stdinCaptured.add);
+    processRunner = ProcessRunner(
+      processManager: fakeProcessManager,
+      defaultWorkingDirectory: fs.directory(testPath),
+    );
+    processPool = ProcessPool(processRunner: processRunner, printReport: null);
+
+    final calls = <FakeInvocationRecord, List<ProcessResult>>{
+      FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+          workingDirectory: testPath): <ProcessResult>[
+        ProcessResult(0, 0, 'output1', ''),
+      ],
+    };
+    fakeProcessManager.fakeResults = calls;
+    final jobs = <WorkerJob>[
+      WorkerJob(
+        <String>['command', 'arg1', 'arg2'],
+        name: 'job 1',
+        stdin: Stream<String>.fromIterable(<String>['input']),
+      ),
+    ];
+    await processPool.runToCompletion(jobs);
+    fakeProcessManager.verifyCalls(calls.keys);
+    expect(stdinCaptured, equals(<String>['input']));
+  });
+
+  test('defaultReportToString formats correctly', () {
+    expect(
+      ProcessPool.defaultReportToString(100, 20, 10, 60, 10),
+      'Jobs:  30% done,  20/100 completed, 10 in progress,  60 pending,  10 failed.    \r',
+    );
+    expect(
+      ProcessPool.defaultReportToString(0, 0, 0, 0, 0),
+      'Jobs: 100% done,   0/0   completed,  0 in progress,   0 pending,   0 failed.    \r',
+    );
+  });
 }
diff --git a/test/src/process_runner_test.dart b/test/src/process_runner_test.dart
index b7b4d60..ef7c50f 100644
--- a/test/src/process_runner_test.dart
+++ b/test/src/process_runner_test.dart
@@ -9,12 +9,18 @@
 import 'package:test/test.dart';
 
 void main() {
-  var fakeProcessManager = FakeProcessManager((String value) {});
+  final stdinCaptured = <String>[];
+  void captureStdin(String item) {
+    stdinCaptured.add(item);
+  }
+
+  var fakeProcessManager = FakeProcessManager(captureStdin);
   var processRunner = ProcessRunner(processManager: fakeProcessManager);
   final testPath = Platform.isWindows ? r'C:\tmp\foo' : '/tmp/foo';
 
   setUp(() {
-    fakeProcessManager = FakeProcessManager((String value) {});
+    stdinCaptured.clear();
+    fakeProcessManager = FakeProcessManager(captureStdin);
     processRunner = ProcessRunner(
         processManager: fakeProcessManager,
         defaultWorkingDirectory: Directory(testPath));
@@ -25,8 +31,8 @@
   group('Output Capture', () {
     test('runProcess works', () async {
       final calls = <FakeInvocationRecord, List<ProcessResult>>{
-        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], testPath):
-            <ProcessResult>[
+        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+            workingDirectory: testPath): <ProcessResult>[
           ProcessResult(0, 0, 'output1', ''),
         ],
       };
@@ -36,8 +42,8 @@
     });
     test('runProcess returns correct output', () async {
       final calls = <FakeInvocationRecord, List<ProcessResult>>{
-        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], testPath):
-            <ProcessResult>[
+        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+            workingDirectory: testPath): <ProcessResult>[
           ProcessResult(0, 0, 'output1', 'stderr1'),
         ],
       };
@@ -51,8 +57,8 @@
     });
     test('runProcess fails properly', () async {
       final calls = <FakeInvocationRecord, List<ProcessResult>>{
-        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], ''):
-            <ProcessResult>[
+        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+            workingDirectory: ''): <ProcessResult>[
           ProcessResult(0, -1, 'output1', 'stderr1'),
         ],
       };
@@ -63,8 +69,8 @@
     });
     test('runProcess returns the failed results properly', () async {
       final calls = <FakeInvocationRecord, List<ProcessResult>>{
-        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'], testPath):
-            <ProcessResult>[
+        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+            workingDirectory: testPath): <ProcessResult>[
           ProcessResult(0, -1, 'output1', 'stderr1'),
         ],
       };
@@ -75,5 +81,57 @@
       expect(result.stderr, equals('stderr1'));
       expect(result.output, equals('output1stderr1'));
     });
+
+    test('runProcess with stdin works', () async {
+      final calls = <FakeInvocationRecord, List<ProcessResult>>{
+        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+            workingDirectory: testPath): <ProcessResult>[
+          ProcessResult(0, 0, 'output1', ''),
+        ],
+      };
+      fakeProcessManager.fakeResults = calls;
+      final stdin = Stream<List<int>>.fromIterable(<List<int>>[
+        'input'.codeUnits,
+      ]);
+      final result = await processRunner.runProcess(
+        calls.keys.first.invocation,
+        stdin: stdin,
+      );
+      expect(result.stdout, equals('output1'));
+      expect(stdinCaptured, equals(<String>['input']));
+    });
+
+    test('runProcess with runInShell works', () async {
+      final calls = <FakeInvocationRecord, List<ProcessResult>>{
+        FakeInvocationRecord(
+          <String>['command', 'arg1', 'arg2'],
+          workingDirectory: testPath,
+          runInShell: true,
+        ): <ProcessResult>[
+          ProcessResult(0, 0, 'output1', ''),
+        ],
+      };
+      fakeProcessManager.fakeResults = calls;
+      await processRunner.runProcess(
+        calls.keys.first.invocation,
+        runInShell: true,
+      );
+      fakeProcessManager.verifyCalls(calls.keys);
+    });
+
+    test('runProcess throws when process manager throws', () async {
+      fakeProcessManager.commandsThrow = true;
+      final calls = <FakeInvocationRecord, List<ProcessResult>>{
+        FakeInvocationRecord(<String>['command', 'arg1', 'arg2'],
+            workingDirectory: testPath): <ProcessResult>[
+          ProcessResult(0, -1, 'output1', 'stderr1'),
+        ],
+      };
+      fakeProcessManager.fakeResults = calls;
+      await expectLater(
+        () => processRunner.runProcess(calls.keys.first.invocation),
+        throwsA(isA<ProcessRunnerException>()),
+      );
+    });
   });
 }