Introduce pkg/modular_test: a package to specify modular tests

This initial commit includes: the definitions of a modular test, a module, a
pipeline, and an in-memory implementation of such pipeline, and an IO implementation.

Change-Id: I69056342da8ba126459064d7751d5dbe75ebcfca
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/100627
Reviewed-by: Johnni Winther <johnniwinther@google.com>
diff --git a/.packages b/.packages
index c5944a1..db0e7d7 100644
--- a/.packages
+++ b/.packages
@@ -59,6 +59,7 @@
 meta:pkg/meta/lib
 mime:third_party/pkg/mime/lib
 mockito:third_party/pkg/mockito/lib
+modular_test:pkg/modular_test/lib
 mustache:third_party/pkg/mustache/lib
 oauth2:third_party/pkg/oauth2/lib
 observatory:runtime/observatory/lib
diff --git a/pkg/modular_test/lib/src/io_pipeline.dart b/pkg/modular_test/lib/src/io_pipeline.dart
new file mode 100644
index 0000000..a92c6c7
--- /dev/null
+++ b/pkg/modular_test/lib/src/io_pipeline.dart
@@ -0,0 +1,91 @@
+// Copyright (c) 2019, 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.
+
+/// An implementation of [Pipeline] that runs using IO.
+///
+/// To define a step, implement [IOModularStep].
+import 'dart:io';
+
+import 'pipeline.dart';
+import 'suite.dart';
+
+/// Indicates where to read and write data produced by the pipeline.
+typedef ModuleDataToRelativeUri = Uri Function(Module, DataId);
+
+abstract class IOModularStep extends ModularStep {
+  /// Execute the step under [root].
+  ///
+  /// The [root] folder will hold all inputs and will be used to emit the output
+  /// of this step.
+  ///
+  /// Assets created on previous steps of the pipeline should be available under
+  /// `root.resolveUri(toUri(module, dataId))` and the output of this step
+  /// should be stored under `root.resolveUri(toUri(module, resultKind))`.
+  Future<void> execute(Module module, Uri root, ModuleDataToRelativeUri toUri);
+}
+
+class IOPipeline extends Pipeline<IOModularStep> {
+  /// A folder per step. The key is the data id produced by a specific step.
+  ///
+  /// This contains internal state used during the run of the pipeline, but is
+  /// expected to be null before and after the pipeline is executed.
+  Map<DataId, Uri> _tmpFolders;
+  Map<DataId, Uri> get tmpFoldersForTesting => _tmpFolders;
+  bool saveFoldersForTesting;
+
+  IOPipeline(List<ModularStep> steps, {this.saveFoldersForTesting: false})
+      : super(steps);
+
+  @override
+  Future<void> run(ModularTest test) async {
+    assert(_tmpFolders == null);
+    _tmpFolders = {};
+    await super.run(test);
+    if (!saveFoldersForTesting) {
+      for (var folder in _tmpFolders.values) {
+        await Directory.fromUri(folder).delete(recursive: true);
+      }
+      _tmpFolders = null;
+    }
+  }
+
+  @override
+  Future<void> runStep(IOModularStep step, Module module,
+      Map<Module, Set<DataId>> visibleData) async {
+    var folder = await Directory.systemTemp
+        .createTemp('modular_test_${step.resultKind}-');
+    _tmpFolders[step.resultKind] ??= (await Directory.systemTemp
+            .createTemp('modular_test_${step.resultKind}_res-'))
+        .uri;
+    for (var module in visibleData.keys) {
+      for (var dataId in visibleData[module]) {
+        var filename = "${module.name}.${dataId.name}";
+        var assetUri = _tmpFolders[dataId].resolve(filename);
+        await File.fromUri(assetUri)
+            .copy(folder.uri.resolve(filename).toFilePath());
+      }
+    }
+    if (step.needsSources) {
+      for (var uri in module.sources) {
+        var originalUri = module.rootUri.resolveUri(uri);
+        await File.fromUri(originalUri)
+            .copy(folder.uri.resolveUri(uri).toFilePath());
+      }
+    }
+
+    await step.execute(module, folder.uri,
+        (Module m, DataId id) => Uri.parse("${m.name}.${id.name}"));
+
+    var outputFile = File.fromUri(
+        folder.uri.resolve("${module.name}.${step.resultKind.name}"));
+    if (!await outputFile.exists()) {
+      throw StateError(
+          "Step '${step.runtimeType}' didn't produce an output file");
+    }
+    await outputFile.copy(_tmpFolders[step.resultKind]
+        .resolve("${module.name}.${step.resultKind.name}")
+        .toFilePath());
+    await folder.delete(recursive: true);
+  }
+}
diff --git a/pkg/modular_test/lib/src/memory_pipeline.dart b/pkg/modular_test/lib/src/memory_pipeline.dart
new file mode 100644
index 0000000..4254bbc
--- /dev/null
+++ b/pkg/modular_test/lib/src/memory_pipeline.dart
@@ -0,0 +1,62 @@
+// Copyright (c) 2019, 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.
+
+/// An implementation of [Pipeline] that runs in-memory.
+///
+/// To define a step, implement [MemoryModularStep].
+import 'pipeline.dart';
+import 'suite.dart';
+
+/// A hook to fetch data previously computed for a dependency.
+typedef ModuleDataProvider = Object Function(Module, DataId);
+typedef SourceProvider = String Function(Uri);
+
+abstract class MemoryModularStep extends ModularStep {
+  Future<Object> execute(Module module, SourceProvider sourceProvider,
+      ModuleDataProvider dataProvider);
+}
+
+class MemoryPipeline extends Pipeline<MemoryModularStep> {
+  final Map<Uri, String> _sources;
+
+  /// Internal state to hold the current results as they are computed by the
+  /// pipeline. Expected to be null before and after the pipeline runs.
+  Map<Module, Map<DataId, Object>> _results;
+
+  /// A copy of [_result] at the time the pipeline last finished running.
+  Map<Module, Map<DataId, Object>> resultsForTesting;
+
+  MemoryPipeline(this._sources, List<ModularStep> steps) : super(steps);
+
+  @override
+  Future<void> run(ModularTest test) async {
+    assert(_results == null);
+    _results = {};
+    await super.run(test);
+    resultsForTesting = _results;
+    _results = null;
+  }
+
+  @override
+  Future<void> runStep(MemoryModularStep step, Module module,
+      Map<Module, Set<DataId>> visibleData) async {
+    Map<Module, Map<DataId, Object>> inputData = {};
+    visibleData.forEach((module, dataIdSet) {
+      inputData[module] = {};
+      for (var dataId in dataIdSet) {
+        inputData[module][dataId] = _results[module][dataId];
+      }
+    });
+    Map<Uri, String> inputSources = {};
+    if (step.needsSources) {
+      module.sources.forEach((relativeUri) {
+        var uri = module.rootUri.resolveUri(relativeUri);
+        inputSources[uri] = _sources[uri];
+      });
+    }
+    Object result = await step.execute(module, (Uri uri) => inputSources[uri],
+        (Module m, DataId id) => inputData[m][id]);
+    (_results[module] ??= {})[step.resultKind] = result;
+  }
+}
diff --git a/pkg/modular_test/lib/src/pipeline.dart b/pkg/modular_test/lib/src/pipeline.dart
new file mode 100644
index 0000000..1323459
--- /dev/null
+++ b/pkg/modular_test/lib/src/pipeline.dart
@@ -0,0 +1,153 @@
+// Copyright (c) 2019, 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.
+
+/// Abstraction for a compilation pipeline.
+///
+/// A pipeline defines how modular steps are excuted and ensures that a step
+/// only has access to the data it declares.
+///
+/// The abstract implementation validates how the data is declared, and the
+/// underlying implementations enforce the access to data in different ways.
+///
+/// The IO-based implementation ensures hermeticity by copying data to different
+/// directories. The memory-based implementation ensures hemeticity by filtering
+/// out the data before invoking the next step.
+import 'suite.dart';
+
+/// Describes a step in a modular compilation pipeline.
+class ModularStep {
+  /// Whether this step needs to read the source files in the module.
+  final bool needsSources;
+
+  /// Data that this step needs to read about dependencies.
+  ///
+  /// This can be data produced on a previous stage of the pipeline
+  /// or produced by this same step when it was run on a dependency.
+  ///
+  /// If this list includes [resultKind], then the modular-step has to be run on
+  /// dependencies before it is run on a module. Otherwise, it could be run in
+  /// parallel.
+  final List<DataId> dependencyDataNeeded;
+
+  /// Data that this step needs to read about the module itself.
+  ///
+  /// This is meant to be data produced in earlier stages of the modular
+  /// pipeline. It is an error to include [resultKind] in this list.
+  final List<DataId> moduleDataNeeded;
+
+  /// Data that this step produces.
+  final DataId resultKind;
+
+  ModularStep(
+      {this.needsSources: true,
+      this.dependencyDataNeeded: const [],
+      this.moduleDataNeeded: const [],
+      this.resultKind});
+}
+
+/// An object to uniquely identify modular data produced by a modular step.
+///
+/// Two modular steps on the same pipeline cannot emit the same data.
+class DataId {
+  final String name;
+
+  const DataId(this.name);
+
+  @override
+  String toString() => name;
+}
+
+abstract class Pipeline<S extends ModularStep> {
+  final List<S> steps;
+
+  Pipeline(this.steps) {
+    _validate();
+  }
+
+  void _validate() {
+    // Ensure that steps consume only data that was produced by previous steps
+    // or by the same step on a dependency.
+    Map<DataId, S> previousKinds = {};
+    for (var step in steps) {
+      var resultKind = step.resultKind;
+      if (previousKinds.containsKey(resultKind)) {
+        _validationError("Cannot produce the same data on two modular steps."
+            " '$resultKind' was previously produced by "
+            "'${previousKinds[resultKind].runtimeType}' but "
+            "'${step.runtimeType}' also produces the same data.");
+      }
+      previousKinds[resultKind] = step;
+      for (var dataId in step.dependencyDataNeeded) {
+        if (!previousKinds.containsKey(dataId)) {
+          _validationError(
+              "Step '${step.runtimeType}' needs data '${dataId}', but the data"
+              " is not produced by this or a preceding step.");
+        }
+      }
+      for (var dataId in step.moduleDataNeeded) {
+        if (!previousKinds.containsKey(dataId)) {
+          _validationError(
+              "Step '${step.runtimeType}' needs data '${dataId}', but the data"
+              " is not produced by a preceding step.");
+        }
+        if (dataId == resultKind) {
+          _validationError(
+              "Circular dependency on '$dataId' in step '${step.runtimeType}'");
+        }
+      }
+    }
+  }
+
+  void _validationError(String s) => throw InvalidPipelineError(s);
+
+  Future<void> run(ModularTest test) async {
+    // TODO(sigmund): validate that [ModularTest] has no cycles.
+    Map<Module, Set<DataId>> computedData = {};
+    for (var step in steps) {
+      await _recursiveRun(step, test.mainModule, computedData, {});
+    }
+  }
+
+  Future<void> _recursiveRun(S step, Module module,
+      Map<Module, Set<DataId>> computedData, Set<Module> seen) async {
+    if (!seen.add(module)) return;
+    for (var dependency in module.dependencies) {
+      await _recursiveRun(step, dependency, computedData, seen);
+    }
+    // Include only requested data from transitive dependencies.
+    Map<Module, Set<DataId>> visibleData = {};
+
+    // TODO(sigmund): consider excluding parent modules here. In particular,
+    // [seen] not only contains transitive dependencies, but also this module
+    // and parent modules. Technically we haven't computed any data for those,
+    // so we shouldn't be including any entries for parent modules in
+    // [visibleData].
+    seen.forEach((dep) {
+      if (dep == module) return;
+      visibleData[dep] = {};
+      for (var dataId in step.dependencyDataNeeded) {
+        if (computedData[dep].contains(dataId)) {
+          visibleData[dep].add(dataId);
+        }
+      }
+    });
+    visibleData[module] = {};
+    for (var dataId in step.moduleDataNeeded) {
+      if (computedData[module].contains(dataId)) {
+        visibleData[module].add(dataId);
+      }
+    }
+    await runStep(step, module, visibleData);
+    (computedData[module] ??= {}).add(step.resultKind);
+  }
+
+  Future<void> runStep(
+      S step, Module module, Map<Module, Set<DataId>> visibleData);
+}
+
+class InvalidPipelineError extends Error {
+  final String message;
+  InvalidPipelineError(this.message);
+  String toString() => "Invalid pipeline: $message";
+}
diff --git a/pkg/modular_test/lib/src/suite.dart b/pkg/modular_test/lib/src/suite.dart
new file mode 100644
index 0000000..bba158e
--- /dev/null
+++ b/pkg/modular_test/lib/src/suite.dart
@@ -0,0 +1,51 @@
+// Copyright (c) 2019, 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.
+
+/// Model for a modular test.
+
+/// A modular test declares the structure of the test code: what files are
+/// grouped as a module and how modules depend on one another.
+class ModularTest {
+  /// Modules that will be compiled by for modular test
+  final List<Module> modules;
+
+  /// The module containing the main entry method.
+  final Module mainModule;
+
+  ModularTest(this.modules, this.mainModule)
+      : assert(mainModule != null && modules.length > 0);
+}
+
+/// A single module in a modular test.
+class Module {
+  /// A short name to identify this module.
+  final String name;
+
+  /// Other modules that need to be compiled first and whose result may be
+  /// necessary in order to compile this module.
+  final List<Module> dependencies;
+
+  /// Root under which all sources in the module can be found.
+  final Uri rootUri;
+
+  /// Source files that are part of this module only. Stored as a relative [Uri]
+  /// from [rootUri].
+  final List<Uri> sources;
+
+  /// The file containing the main entry method, if any. Stored as a relative
+  /// [Uri] from [rootUri].
+  final Uri mainSource;
+
+  Module(this.name, this.dependencies, this.rootUri, this.sources,
+      this.mainSource) {
+    if (!_validModuleName.hasMatch(name)) {
+      throw "invalid module name: $name";
+    }
+  }
+
+  @override
+  String toString() => '[module $name]';
+}
+
+final RegExp _validModuleName = new RegExp(r'^[a-zA-Z_][a-zA-Z0-9_]*$');
diff --git a/pkg/modular_test/pubspec.yaml b/pkg/modular_test/pubspec.yaml
new file mode 100644
index 0000000..94f26d8
--- /dev/null
+++ b/pkg/modular_test/pubspec.yaml
@@ -0,0 +1,14 @@
+name: modular_test
+publish_to: none
+description: >
+ Small framework to test modular pipelines.
+ This is used within the Dart SDK to define and validate modular tests, and to
+ execute them using the modular pipeline of different SDK tools.
+environment:
+  sdk: ">=2.2.1 <3.0.0"
+
+dependencies:
+  yaml: ^2.1.15
+
+dev_dependencies:
+  test: any
diff --git a/pkg/modular_test/test/io_pipeline_test.dart b/pkg/modular_test/test/io_pipeline_test.dart
new file mode 100644
index 0000000..a30207c
--- /dev/null
+++ b/pkg/modular_test/test/io_pipeline_test.dart
@@ -0,0 +1,180 @@
+// Copyright (c) 2019, 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.
+
+/// Unit test for in-memory pipelines.
+import 'dart:io';
+
+import 'package:modular_test/src/io_pipeline.dart';
+
+import 'pipeline_common.dart';
+
+main() async {
+  var uri = Directory.systemTemp.uri.resolve("io_modular_test_root/");
+  int i = 0;
+  while (await Directory.fromUri(uri).exists()) {
+    uri = Directory.systemTemp.uri.resolve("io_modular_test_root$i/");
+    i++;
+  }
+  runPipelineTest(new IOPipelineTestStrategy(uri));
+}
+
+/// The strategy implementation to exercise the pipeline test on a
+/// [IOPipeline].
+class IOPipelineTestStrategy implements PipelineTestStrategy<IOModularStep> {
+  @override
+  final Uri testRootUri;
+
+  IOPipelineTestStrategy(this.testRootUri);
+
+  @override
+  Future<Pipeline<IOModularStep>> createPipeline(
+      Map<Uri, String> sources, List<IOModularStep> steps) async {
+    await Directory.fromUri(testRootUri).create();
+    for (var uri in sources.keys) {
+      var file = new File.fromUri(uri);
+      await file.create(recursive: true);
+      await file.writeAsStringSync(sources[uri]);
+    }
+    return new IOPipeline(steps, saveFoldersForTesting: true);
+  }
+
+  @override
+  IOModularStep createConcatStep({bool requestSources: true}) =>
+      ConcatStep(requestSources);
+
+  @override
+  IOModularStep createLowerCaseStep({bool requestModuleData: true}) =>
+      LowerCaseStep(requestModuleData);
+
+  @override
+  IOModularStep createReplaceAndJoinStep(
+          {bool requestDependenciesData: true}) =>
+      ReplaceAndJoinStep(requestDependenciesData);
+
+  @override
+  IOModularStep createReplaceAndJoinStep2(
+          {bool requestDependenciesData: true}) =>
+      ReplaceAndJoinStep2(requestDependenciesData);
+
+  @override
+  String getResult(covariant IOPipeline pipeline, Module m, DataId dataId) {
+    var folderUri = pipeline.tmpFoldersForTesting[dataId];
+    return File.fromUri(folderUri.resolve("${m.name}.${dataId.name}"))
+        .readAsStringSync();
+  }
+
+  @override
+  Future<void> cleanup(Pipeline<IOModularStep> pipeline) async {
+    var folders = (pipeline as IOPipeline).tmpFoldersForTesting.values;
+    for (var folder in folders) {
+      await Directory.fromUri(folder).delete(recursive: true);
+    }
+    await Directory.fromUri(testRootUri).delete(recursive: true);
+  }
+}
+
+class ConcatStep implements IOModularStep {
+  final bool needsSources;
+  List<DataId> get dependencyDataNeeded => const [];
+  List<DataId> get moduleDataNeeded => const [];
+  DataId get resultKind => const DataId("concat");
+
+  ConcatStep(this.needsSources);
+
+  @override
+  Future<void> execute(
+      Module module, Uri root, ModuleDataToRelativeUri toUri) async {
+    var buffer = new StringBuffer();
+    for (var uri in module.sources) {
+      var file = File.fromUri(root.resolveUri(uri));
+      String data = await file.exists() ? await file.readAsString() : null;
+      buffer.write("$uri: ${data}\n");
+    }
+    await File.fromUri(root.resolveUri(toUri(module, resultKind)))
+        .writeAsString('$buffer');
+  }
+}
+
+Future<String> _readHelper(Module module, Uri root, DataId dataId,
+    ModuleDataToRelativeUri toUri) async {
+  var file = File.fromUri(root.resolveUri(toUri(module, dataId)));
+  if (await file.exists()) {
+    return await file.readAsString();
+  }
+  return null;
+}
+
+class LowerCaseStep implements IOModularStep {
+  bool get needsSources => false;
+  List<DataId> get dependencyDataNeeded => const [];
+  final List<DataId> moduleDataNeeded;
+  DataId get resultKind => const DataId("lowercase");
+
+  LowerCaseStep(bool requestConcat)
+      : moduleDataNeeded = requestConcat ? const [DataId("concat")] : const [];
+
+  @override
+  Future<void> execute(
+      Module module, Uri root, ModuleDataToRelativeUri toUri) async {
+    var concatData =
+        await _readHelper(module, root, const DataId("concat"), toUri);
+    if (concatData == null) concatData = "data for $module was null";
+    await File.fromUri(root.resolveUri(toUri(module, resultKind)))
+        .writeAsString(concatData.toLowerCase());
+  }
+}
+
+class ReplaceAndJoinStep implements IOModularStep {
+  bool get needsSources => false;
+  final List<DataId> dependencyDataNeeded;
+  List<DataId> get moduleDataNeeded => const [DataId("lowercase")];
+  DataId get resultKind => const DataId("join");
+
+  ReplaceAndJoinStep(bool requestDependencies)
+      : dependencyDataNeeded =
+            requestDependencies ? const [DataId("join")] : [];
+
+  @override
+  Future<void> execute(
+      Module module, Uri root, ModuleDataToRelativeUri toUri) async {
+    var buffer = new StringBuffer();
+    for (var dependency in module.dependencies) {
+      var depData =
+          await _readHelper(dependency, root, const DataId("join"), toUri);
+      buffer.write("$depData\n");
+    }
+    var moduleData =
+        await _readHelper(module, root, const DataId("lowercase"), toUri);
+    buffer.write(moduleData.replaceAll(".dart:", ""));
+    await File.fromUri(root.resolveUri(toUri(module, resultKind)))
+        .writeAsString('$buffer');
+  }
+}
+
+class ReplaceAndJoinStep2 implements IOModularStep {
+  bool get needsSources => false;
+  final List<DataId> dependencyDataNeeded;
+  List<DataId> get moduleDataNeeded => const [DataId("lowercase")];
+  DataId get resultKind => const DataId("join");
+
+  ReplaceAndJoinStep2(bool requestDependencies)
+      : dependencyDataNeeded =
+            requestDependencies ? const [DataId("lowercase")] : [];
+
+  @override
+  Future<void> execute(
+      Module module, Uri root, ModuleDataToRelativeUri toUri) async {
+    var buffer = new StringBuffer();
+    for (var dependency in module.dependencies) {
+      var depData =
+          await _readHelper(dependency, root, const DataId("lowercase"), toUri);
+      buffer.write("$depData\n");
+    }
+    var moduleData =
+        await _readHelper(module, root, const DataId("lowercase"), toUri);
+    buffer.write(moduleData.replaceAll(".dart:", ""));
+    await File.fromUri(root.resolveUri(toUri(module, resultKind)))
+        .writeAsString('$buffer');
+  }
+}
diff --git a/pkg/modular_test/test/memory_pipeline_test.dart b/pkg/modular_test/test/memory_pipeline_test.dart
new file mode 100644
index 0000000..29c407f
--- /dev/null
+++ b/pkg/modular_test/test/memory_pipeline_test.dart
@@ -0,0 +1,132 @@
+// Copyright (c) 2019, 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.
+
+/// Unit test for in-memory pipelines.
+import 'dart:async';
+
+import 'package:modular_test/src/memory_pipeline.dart';
+
+import 'pipeline_common.dart';
+
+main() {
+  runPipelineTest(new MemoryPipelineTestStrategy());
+}
+
+/// The strategy implementation to exercise the pipeline test on a
+/// [MemoryPipeline].
+class MemoryPipelineTestStrategy
+    implements PipelineTestStrategy<MemoryModularStep> {
+  @override
+  Uri get testRootUri => Uri.parse('/');
+
+  @override
+  FutureOr<Pipeline<MemoryModularStep>> createPipeline(
+      Map<Uri, String> sources, List<MemoryModularStep> steps) {
+    return new MemoryPipeline(sources, steps);
+  }
+
+  @override
+  MemoryModularStep createConcatStep({bool requestSources: true}) =>
+      ConcatStep(requestSources);
+
+  @override
+  MemoryModularStep createLowerCaseStep({bool requestModuleData: true}) =>
+      LowerCaseStep(requestModuleData);
+
+  @override
+  MemoryModularStep createReplaceAndJoinStep(
+          {bool requestDependenciesData: true}) =>
+      ReplaceAndJoinStep(requestDependenciesData);
+
+  @override
+  MemoryModularStep createReplaceAndJoinStep2(
+          {bool requestDependenciesData: true}) =>
+      ReplaceAndJoinStep2(requestDependenciesData);
+
+  @override
+  String getResult(covariant MemoryPipeline pipeline, Module m, DataId dataId) {
+    return pipeline.resultsForTesting[m][dataId];
+  }
+
+  FutureOr<void> cleanup(Pipeline<MemoryModularStep> pipeline) => null;
+}
+
+class ConcatStep implements MemoryModularStep {
+  final bool needsSources;
+  List<DataId> get dependencyDataNeeded => const [];
+  List<DataId> get moduleDataNeeded => const [];
+  DataId get resultKind => const DataId("concat");
+
+  ConcatStep(this.needsSources);
+
+  Future<Object> execute(Module module, SourceProvider sourceProvider,
+      ModuleDataProvider dataProvider) {
+    var buffer = new StringBuffer();
+    for (var uri in module.sources) {
+      buffer.write("$uri: ${sourceProvider(module.rootUri.resolveUri(uri))}\n");
+    }
+    return Future.value('$buffer');
+  }
+}
+
+class LowerCaseStep implements MemoryModularStep {
+  bool get needsSources => false;
+  List<DataId> get dependencyDataNeeded => const [];
+  final List<DataId> moduleDataNeeded;
+  DataId get resultKind => const DataId("lowercase");
+
+  LowerCaseStep(bool requestConcat)
+      : moduleDataNeeded = requestConcat ? const [DataId("concat")] : const [];
+
+  Future<Object> execute(Module module, SourceProvider sourceProvider,
+      ModuleDataProvider dataProvider) {
+    var concatData = dataProvider(module, const DataId("concat")) as String;
+    if (concatData == null) return Future.value("data for $module was null");
+    return Future.value(concatData.toLowerCase());
+  }
+}
+
+class ReplaceAndJoinStep implements MemoryModularStep {
+  bool get needsSources => false;
+  final List<DataId> dependencyDataNeeded;
+  List<DataId> get moduleDataNeeded => const [DataId("lowercase")];
+  DataId get resultKind => const DataId("join");
+
+  ReplaceAndJoinStep(bool requestDependencies)
+      : dependencyDataNeeded =
+            requestDependencies ? const [DataId("join")] : [];
+
+  Future<Object> execute(Module module, SourceProvider sourceProvider,
+      ModuleDataProvider dataProvider) {
+    var buffer = new StringBuffer();
+    for (var dependency in module.dependencies) {
+      buffer.write("${dataProvider(dependency, const DataId("join"))}\n");
+    }
+    var moduleData = dataProvider(module, const DataId("lowercase")) as String;
+    buffer.write(moduleData.replaceAll(".dart:", ""));
+    return Future.value('$buffer');
+  }
+}
+
+class ReplaceAndJoinStep2 implements MemoryModularStep {
+  bool get needsSources => false;
+  final List<DataId> dependencyDataNeeded;
+  List<DataId> get moduleDataNeeded => const [DataId("lowercase")];
+  DataId get resultKind => const DataId("join");
+
+  ReplaceAndJoinStep2(bool requestDependencies)
+      : dependencyDataNeeded =
+            requestDependencies ? const [DataId("lowercase")] : [];
+
+  Future<Object> execute(Module module, SourceProvider sourceProvider,
+      ModuleDataProvider dataProvider) {
+    var buffer = new StringBuffer();
+    for (var dependency in module.dependencies) {
+      buffer.write("${dataProvider(dependency, const DataId("lowercase"))}\n");
+    }
+    var moduleData = dataProvider(module, const DataId("lowercase")) as String;
+    buffer.write(moduleData.replaceAll(".dart:", ""));
+    return Future.value('$buffer');
+  }
+}
diff --git a/pkg/modular_test/test/pipeline_common.dart b/pkg/modular_test/test/pipeline_common.dart
new file mode 100644
index 0000000..159e464
--- /dev/null
+++ b/pkg/modular_test/test/pipeline_common.dart
@@ -0,0 +1,192 @@
+// Copyright (c) 2019, 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.
+
+/// Defines the abstract skeleton of the memory and io pipeline tests.
+///
+/// The idea is to ensure that pipelines are evaluated in the expected order
+/// and that steps are hermetic in that they are only provided the data they
+/// request.
+///
+/// We place most of the logic here to guarantee that the two different pipeline
+/// implementations are consistent with each other.
+import 'dart:async';
+
+import 'package:test/test.dart';
+import 'package:modular_test/src/suite.dart';
+import 'package:modular_test/src/pipeline.dart';
+
+export 'package:modular_test/src/suite.dart';
+export 'package:modular_test/src/pipeline.dart';
+
+/// A strategy to create the steps and pipelines used by the pipeline test. This
+/// is implemented in `memory_pipeline_test.dart` and `io_pipeline_test.dart`.
+abstract class PipelineTestStrategy<S extends ModularStep> {
+  /// Root URI where test sources are found.
+  Uri get testRootUri;
+
+  /// Creates a pipeline with the given sources and steps. Steps will be created
+  /// by other methods in this strategy to ensure they are compatible with to
+  /// the pipeline created here.
+  FutureOr<Pipeline<S>> createPipeline(Map<Uri, String> sources, List<S> steps);
+
+  /// Create a step that concatenates all contents of the sources in a module.
+  S createConcatStep({bool requestSources: true});
+
+  /// Create a step that consumes the concat step result and converts the
+  /// contents to lower-case.
+  S createLowerCaseStep({bool requestModuleData: true});
+
+  /// Create a step that consumes the concat and lower-case steps and does a
+  /// replace and join operation as expected in the tests below.
+  ///
+  /// This step consumes it's own data from dependencies.
+  S createReplaceAndJoinStep({bool requestDependenciesData: true});
+
+  /// Create a step that consumes the concat and lower-case steps and does a
+  /// replace and join operation as expected in the tests below.
+  ///
+  /// This step consumes the lower-case step data from dependencies.
+  S createReplaceAndJoinStep2({bool requestDependenciesData: true});
+
+  /// Return the result data produced by a modular step.
+  String getResult(Pipeline<S> pipeline, Module m, DataId dataId);
+
+  /// Do any cleanup work needed after pipeline is completed. Needed because
+  /// some implementations retain data around to be able to answer [getResult]
+  /// queries.
+  FutureOr<void> cleanup(Pipeline<S> pipeline);
+}
+
+runPipelineTest<S extends ModularStep>(PipelineTestStrategy<S> testStrategy) {
+  var sources = {
+    testStrategy.testRootUri.resolve("a1.dart"): 'A1',
+    testStrategy.testRootUri.resolve("a2.dart"): 'A2',
+    testStrategy.testRootUri.resolve("b/b1.dart"): 'B1',
+    testStrategy.testRootUri.resolve("b/b2.dart"): 'B2',
+  };
+
+  var m1 = Module("a", const [], testStrategy.testRootUri,
+      [Uri.parse("a1.dart"), Uri.parse("a2.dart")], null);
+  var m2 = Module("b", [m1], testStrategy.testRootUri.resolve('b/'),
+      [Uri.parse("b1.dart"), Uri.parse("b2.dart")], null);
+
+  var singleModuleInput = ModularTest([m1], m1);
+  var multipleModulesInput = ModularTest([m1, m2], m2);
+
+  test('can read source data if requested', () async {
+    var concatStep = testStrategy.createConcatStep();
+    var pipeline = await testStrategy.createPipeline(sources, <S>[concatStep]);
+    await pipeline.run(singleModuleInput);
+    expect(testStrategy.getResult(pipeline, m1, concatStep.resultKind),
+        "a1.dart: A1\na2.dart: A2\n");
+    await testStrategy.cleanup(pipeline);
+  });
+
+  test('cannot read source data if not requested', () async {
+    var concatStep = testStrategy.createConcatStep(requestSources: false);
+    var pipeline = await testStrategy.createPipeline(sources, <S>[concatStep]);
+    await pipeline.run(singleModuleInput);
+    expect(testStrategy.getResult(pipeline, m1, concatStep.resultKind),
+        "a1.dart: null\na2.dart: null\n");
+    await testStrategy.cleanup(pipeline);
+  });
+
+  test('step is applied to all modules', () async {
+    var concatStep = testStrategy.createConcatStep();
+    var pipeline = await testStrategy.createPipeline(sources, <S>[concatStep]);
+    await pipeline.run(multipleModulesInput);
+    expect(testStrategy.getResult(pipeline, m1, concatStep.resultKind),
+        "a1.dart: A1\na2.dart: A2\n");
+    expect(testStrategy.getResult(pipeline, m2, concatStep.resultKind),
+        "b1.dart: B1\nb2.dart: B2\n");
+    await testStrategy.cleanup(pipeline);
+  });
+
+  test('can read previous step results if requested', () async {
+    var concatStep = testStrategy.createConcatStep();
+    var lowercaseStep = testStrategy.createLowerCaseStep();
+    var pipeline = await testStrategy
+        .createPipeline(sources, <S>[concatStep, lowercaseStep]);
+    await pipeline.run(multipleModulesInput);
+    expect(testStrategy.getResult(pipeline, m1, lowercaseStep.resultKind),
+        "a1.dart: a1\na2.dart: a2\n");
+    expect(testStrategy.getResult(pipeline, m2, lowercaseStep.resultKind),
+        "b1.dart: b1\nb2.dart: b2\n");
+    await testStrategy.cleanup(pipeline);
+  });
+
+  test('cannot read previous step results if not requested', () async {
+    var concatStep = testStrategy.createConcatStep();
+    var lowercaseStep =
+        testStrategy.createLowerCaseStep(requestModuleData: false);
+    var pipeline = await testStrategy
+        .createPipeline(sources, <S>[concatStep, lowercaseStep]);
+    await pipeline.run(multipleModulesInput);
+    expect(testStrategy.getResult(pipeline, m1, lowercaseStep.resultKind),
+        "data for [module a] was null");
+    expect(testStrategy.getResult(pipeline, m2, lowercaseStep.resultKind),
+        "data for [module b] was null");
+    await testStrategy.cleanup(pipeline);
+  });
+
+  test('can read same-step results of dependencies if requested', () async {
+    var concatStep = testStrategy.createConcatStep();
+    var lowercaseStep = testStrategy.createLowerCaseStep();
+    var replaceJoinStep = testStrategy.createReplaceAndJoinStep();
+    var pipeline = await testStrategy.createPipeline(
+        sources, <S>[concatStep, lowercaseStep, replaceJoinStep]);
+    await pipeline.run(multipleModulesInput);
+    expect(testStrategy.getResult(pipeline, m1, replaceJoinStep.resultKind),
+        "a1 a1\na2 a2\n");
+    expect(testStrategy.getResult(pipeline, m2, replaceJoinStep.resultKind),
+        "a1 a1\na2 a2\n\nb1 b1\nb2 b2\n");
+    await testStrategy.cleanup(pipeline);
+  });
+
+  test('cannot read same-step results of dependencies if not requested',
+      () async {
+    var concatStep = testStrategy.createConcatStep();
+    var lowercaseStep = testStrategy.createLowerCaseStep();
+    var replaceJoinStep =
+        testStrategy.createReplaceAndJoinStep(requestDependenciesData: false);
+    var pipeline = await testStrategy.createPipeline(
+        sources, <S>[concatStep, lowercaseStep, replaceJoinStep]);
+    await pipeline.run(multipleModulesInput);
+    expect(testStrategy.getResult(pipeline, m1, replaceJoinStep.resultKind),
+        "a1 a1\na2 a2\n");
+    expect(testStrategy.getResult(pipeline, m2, replaceJoinStep.resultKind),
+        "null\nb1 b1\nb2 b2\n");
+    await testStrategy.cleanup(pipeline);
+  });
+
+  test('can read prior step results of dependencies if requested', () async {
+    var concatStep = testStrategy.createConcatStep();
+    var lowercaseStep = testStrategy.createLowerCaseStep();
+    var replaceJoinStep = testStrategy.createReplaceAndJoinStep2();
+    var pipeline = await testStrategy.createPipeline(
+        sources, <S>[concatStep, lowercaseStep, replaceJoinStep]);
+    await pipeline.run(multipleModulesInput);
+    expect(testStrategy.getResult(pipeline, m1, replaceJoinStep.resultKind),
+        "a1 a1\na2 a2\n");
+    expect(testStrategy.getResult(pipeline, m2, replaceJoinStep.resultKind),
+        "a1.dart: a1\na2.dart: a2\n\nb1 b1\nb2 b2\n");
+    await testStrategy.cleanup(pipeline);
+  });
+
+  test('cannot read prior step results of dependencies if not requested',
+      () async {
+    var concatStep = testStrategy.createConcatStep();
+    var lowercaseStep = testStrategy.createLowerCaseStep();
+    var replaceJoinStep =
+        testStrategy.createReplaceAndJoinStep2(requestDependenciesData: false);
+    var pipeline = await testStrategy.createPipeline(
+        sources, <S>[concatStep, lowercaseStep, replaceJoinStep]);
+    await pipeline.run(multipleModulesInput);
+    expect(testStrategy.getResult(pipeline, m1, replaceJoinStep.resultKind),
+        "a1 a1\na2 a2\n");
+    expect(testStrategy.getResult(pipeline, m2, replaceJoinStep.resultKind),
+        "null\nb1 b1\nb2 b2\n");
+    await testStrategy.cleanup(pipeline);
+  });
+}
diff --git a/pkg/modular_test/test/validate_test.dart b/pkg/modular_test/test/validate_test.dart
new file mode 100644
index 0000000..6858544
--- /dev/null
+++ b/pkg/modular_test/test/validate_test.dart
@@ -0,0 +1,80 @@
+// Copyright (c) 2019, 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.
+
+/// Unit test for validation of modular steps in a pipeline.
+import 'package:test/test.dart';
+import 'package:modular_test/src/suite.dart';
+import 'package:modular_test/src/pipeline.dart';
+
+main() {
+  test('no steps is OK', () {
+    validateSteps([]);
+  });
+
+  test('no errors', () {
+    var id1 = DataId("data_a");
+    var id2 = DataId("data_b");
+    var id3 = DataId("data_c");
+    validateSteps([
+      ModularStep(
+          needsSources: true, dependencyDataNeeded: [id1], resultKind: id1),
+      ModularStep(moduleDataNeeded: [id1], resultKind: id2),
+      ModularStep(
+          moduleDataNeeded: [id2],
+          dependencyDataNeeded: [id1, id3],
+          resultKind: id3),
+    ]);
+  });
+
+  test('circular dependency is not allowed', () {
+    var id1 = DataId("data_a");
+    expect(
+        () => validateSteps([
+              ModularStep(moduleDataNeeded: [id1], resultKind: id1),
+            ]),
+        throwsA(TypeMatcher<InvalidPipelineError>()));
+  });
+
+  test('out of order dependencies are not allowed', () {
+    var id1 = DataId("data_a");
+    var id2 = DataId("data_b");
+    validateSteps([
+      ModularStep(
+          resultKind: id1), // id1 must be produced before it is consumed.
+      ModularStep(dependencyDataNeeded: [id1], resultKind: id2),
+    ]);
+
+    expect(
+        () => validateSteps([
+              ModularStep(dependencyDataNeeded: [id1], resultKind: id2),
+              ModularStep(resultKind: id1),
+            ]),
+        throwsA(TypeMatcher<InvalidPipelineError>()));
+  });
+
+  test('same data cannot be produced by two steps', () {
+    var id1 = DataId("data_a");
+    expect(
+        () => validateSteps([
+              ModularStep(resultKind: id1),
+              ModularStep(resultKind: id1),
+            ]),
+        throwsA(TypeMatcher<InvalidPipelineError>()));
+  });
+}
+
+validateSteps(List<ModularStep> steps) {
+  new _NoopPipeline(steps);
+}
+
+/// An implementation of [Pipeline] that simply validates the steps, but doesn't
+/// do anything else.
+class _NoopPipeline extends Pipeline {
+  _NoopPipeline(List<ModularStep> steps) : super(steps);
+
+  @override
+  Future<void> runStep(ModularStep step, Module module,
+          Map<Module, Set<DataId>> visibleData) =>
+      null;
+}
diff --git a/pkg/pkg.status b/pkg/pkg.status
index d26e5ee..5ad6e12 100644
--- a/pkg/pkg.status
+++ b/pkg/pkg.status
@@ -130,6 +130,7 @@
 [ $runtime != vm ]
 dev_compiler/test/options/*: SkipByDesign
 front_end/test/hot_reload_e2e_test: Skip
+modular_test/test/io_pipeline_test: SkipByDesign
 vm/test/*: SkipByDesign # Only meant to run on vm
 
 [ $system == linux ]