Add support for flutter_test_config.dart (#17141)

This enables support for a `flutter_test_config.dart` configuration file,
which will be discovered and handed the responsibility of running the
test file (thus allowing it to run pre-test setup on a project level).

https://github.com/flutter/flutter/issues/16859
diff --git a/packages/flutter_test/lib/flutter_test.dart b/packages/flutter_test/lib/flutter_test.dart
index cd52e74..43c44d6 100644
--- a/packages/flutter_test/lib/flutter_test.dart
+++ b/packages/flutter_test/lib/flutter_test.dart
@@ -3,6 +3,45 @@
 // found in the LICENSE file.
 
 /// Testing library for flutter, built on top of `package:test`.
+///
+/// ## Test Configuration
+///
+/// The testing library exposes a few constructs by which projects may configure
+/// their tests.
+///
+/// ### Per test or per file
+///
+/// Due to its use of `package:test` as a foundation, the testing library
+/// allows for tests to be initialized using the existing constructs found in
+/// `package:test`. These include the [setUp] and [setUpAll] methods.
+///
+/// ### Per directory hierarchy
+///
+/// In addition to the constructs provided by `package:test`, this library
+/// supports the configuration of tests at the directory level.
+///
+/// Before a test file is executed, the Flutter test framework will scan up the
+/// directory hierarchy, starting from the directory in which the test file
+/// resides, looking for a file named `flutter_test_config.dart`. If it finds
+/// such a configuration file, the file will be assumed to have a `main` method
+/// with the following signature:
+///
+/// ```dart
+/// void main(FutureOr<void> testMain());
+/// ```
+///
+/// The test framework will execute that method and pass it the `main()` method
+/// of the test. It is then the responsibility of the configuration file's
+/// `main()` method to invoke the test's `main()` method.
+///
+/// After the test framework finds a configuration file, it will stop scanning
+/// the directory hierarchy. In other words, the test configuration file that
+/// lives closest to the test file will be selected, and all other test
+/// configuration files will be ignored. Likewise, it will stop scanning the
+/// directory hierarchy when it finds a `pubspec.yaml`, since that signals the
+/// root of the project.
+///
+/// If no configuration file is located, the test will be executed like normal.
 library flutter_test;
 
 export 'dart:async' show Future;
diff --git a/packages/flutter_test/lib/src/goldens.dart b/packages/flutter_test/lib/src/goldens.dart
index 4e99e78..2227b5f 100644
--- a/packages/flutter_test/lib/src/goldens.dart
+++ b/packages/flutter_test/lib/src/goldens.dart
@@ -56,10 +56,16 @@
 /// encoded PNGs, returning true only if there's an exact match.
 ///
 /// Callers may choose to override the default comparator by setting this to a
-/// custom comparator during test set-up. For example, some projects may wish to
-/// install a more intelligent comparator that knows how to decode the PNG
-/// images to raw pixels and compare pixel vales, reporting specific differences
-/// between the images.
+/// custom comparator during test set-up (or using directory-level test
+/// configuration). For example, some projects may wish to install a more
+/// intelligent comparator that knows how to decode the PNG images to raw
+/// pixels and compare pixel vales, reporting specific differences between the
+/// images.
+///
+/// See also:
+///
+///  * [flutter_test] for more information about how to configure tests at the
+///    directory-level.
 GoldenFileComparator goldenFileComparator = const _UninitializedComparator();
 
 /// Whether golden files should be automatically updated during tests rather
diff --git a/packages/flutter_test/test/test_config/child_directory/config_test.dart b/packages/flutter_test/test/test_config/child_directory/config_test.dart
new file mode 100644
index 0000000..c9bd0bf
--- /dev/null
+++ b/packages/flutter_test/test/test_config/child_directory/config_test.dart
@@ -0,0 +1,9 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../config_test_utils.dart';
+
+void main() {
+  testConfig('flutter_test_config initializes tests in child folder', '/test_config');
+}
diff --git a/packages/flutter_test/test/test_config/child_directory/grandchild_directory/config_test.dart b/packages/flutter_test/test/test_config/child_directory/grandchild_directory/config_test.dart
new file mode 100644
index 0000000..54ad75f
--- /dev/null
+++ b/packages/flutter_test/test/test_config/child_directory/grandchild_directory/config_test.dart
@@ -0,0 +1,9 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../../config_test_utils.dart';
+
+void main() {
+  testConfig('flutter_test_config initializes tests in grandchild folder', '/test_config');
+}
diff --git a/packages/flutter_test/test/test_config/config_test.dart b/packages/flutter_test/test/test_config/config_test.dart
new file mode 100644
index 0000000..40ef2e0
--- /dev/null
+++ b/packages/flutter_test/test/test_config/config_test.dart
@@ -0,0 +1,9 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'config_test_utils.dart';
+
+void main() {
+  testConfig('flutter_test_config initializes tests in same folder', '/test_config');
+}
diff --git a/packages/flutter_test/test/test_config/config_test_utils.dart b/packages/flutter_test/test/test_config/config_test_utils.dart
new file mode 100644
index 0000000..e909c22
--- /dev/null
+++ b/packages/flutter_test/test/test_config/config_test_utils.dart
@@ -0,0 +1,27 @@
+// Copyright 2018 The Chromium Authors. 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 'package:flutter_test/flutter_test.dart';
+
+void testConfig(
+  String description,
+  String expectedStringValue, {
+  Map<Type, dynamic> otherExpectedValues: const <Type, dynamic>{int: isNull},
+}) {
+  final String actualStringValue = Zone.current[String];
+  final Map<Type, dynamic> otherActualValues = otherExpectedValues.map<Type, dynamic>(
+    (Type key, dynamic value) {
+      return new MapEntry<Type, dynamic>(key, Zone.current[key]);
+    },
+  );
+
+  test(description, () {
+    expect(actualStringValue, expectedStringValue);
+    for (Type key in otherExpectedValues.keys) {
+      expect(otherActualValues[key], otherExpectedValues[key]);
+    }
+  });
+}
diff --git a/packages/flutter_test/test/test_config/flutter_test_config.dart b/packages/flutter_test/test/test_config/flutter_test_config.dart
new file mode 100644
index 0000000..226d5bb
--- /dev/null
+++ b/packages/flutter_test/test/test_config/flutter_test_config.dart
@@ -0,0 +1,11 @@
+// Copyright 2018 The Chromium Authors. 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';
+
+void main(FutureOr<void> testMain()) async {
+  await runZoned<dynamic>(testMain, zoneValues: <Type, String>{
+    String: '/test_config',
+  });
+}
diff --git a/packages/flutter_test/test/test_config/nested_config/config_test.dart b/packages/flutter_test/test/test_config/nested_config/config_test.dart
new file mode 100644
index 0000000..9974d1a
--- /dev/null
+++ b/packages/flutter_test/test/test_config/nested_config/config_test.dart
@@ -0,0 +1,13 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../config_test_utils.dart';
+
+void main() {
+  testConfig(
+    'cwd config takes precedence over parent config',
+    '/test_config/nested_config',
+    otherExpectedValues: <Type, dynamic>{int: 123},
+  );
+}
diff --git a/packages/flutter_test/test/test_config/nested_config/flutter_test_config.dart b/packages/flutter_test/test/test_config/nested_config/flutter_test_config.dart
new file mode 100644
index 0000000..e52918c
--- /dev/null
+++ b/packages/flutter_test/test/test_config/nested_config/flutter_test_config.dart
@@ -0,0 +1,12 @@
+// Copyright 2018 The Chromium Authors. 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';
+
+void main(FutureOr<void> testMain()) async {
+  await runZoned<dynamic>(testMain, zoneValues: <Type, dynamic>{
+    String: '/test_config/nested_config',
+    int: 123,
+  });
+}
diff --git a/packages/flutter_test/test/test_config/project_root/config_test.dart b/packages/flutter_test/test/test_config/project_root/config_test.dart
new file mode 100644
index 0000000..49d7ff8
--- /dev/null
+++ b/packages/flutter_test/test/test_config/project_root/config_test.dart
@@ -0,0 +1,9 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../config_test_utils.dart';
+
+void main() {
+  testConfig('pubspec.yaml causes config scanning to stop', null);
+}
diff --git a/packages/flutter_test/test/test_config/project_root/pubspec.yaml b/packages/flutter_test/test/test_config/project_root/pubspec.yaml
new file mode 100644
index 0000000..c4503f2
--- /dev/null
+++ b/packages/flutter_test/test/test_config/project_root/pubspec.yaml
@@ -0,0 +1,5 @@
+# This file exists to simulate a project root. The testing library should stop
+# scanning for a `flutter_test_config.dart` file when it reaches this directory
+name: dummy
+
+# PUBSPEC CHECKSUM: 0
diff --git a/packages/flutter_tools/lib/src/test/flutter_platform.dart b/packages/flutter_tools/lib/src/test/flutter_platform.dart
index 1427463..2c94872 100644
--- a/packages/flutter_tools/lib/src/test/flutter_platform.dart
+++ b/packages/flutter_tools/lib/src/test/flutter_platform.dart
@@ -41,6 +41,14 @@
 /// hold that against the test.
 const String _kStartTimeoutTimerMessage = 'sky_shell test process has entered main method';
 
+/// The name of the test configuration file that will be discovered by the
+/// test harness if it exists in the project directory hierarchy.
+const String _kTestConfigFileName = 'flutter_test_config.dart';
+
+/// The name of the file that signals the root of the project and that will
+/// cause the test harness to stop scanning for configuration files.
+const String _kProjectRootSentinel = 'pubspec.yaml';
+
 /// The address at which our WebSocket server resides and at which the sky_shell
 /// processes will host the Observatory server.
 final Map<InternetAddressType, InternetAddress> _kHosts = <InternetAddressType, InternetAddress>{
@@ -623,7 +631,25 @@
     Uri testUrl,
     String encodedWebsocketUrl,
   }) {
-    return '''
+    assert(testUrl.scheme == 'file');
+    File testConfigFile;
+    Directory directory = fs.file(testUrl).parent;
+    while (directory.path != directory.parent.path) {
+      final File configFile = directory.childFile(_kTestConfigFileName);
+      if (configFile.existsSync()) {
+        printTrace('Discovered $_kTestConfigFileName in ${directory.path}');
+        testConfigFile = configFile;
+        break;
+      }
+      if (directory.childFile(_kProjectRootSentinel).existsSync()) {
+        printTrace('Stopping scan for $_kTestConfigFileName; '
+                   'found project root at ${directory.path}');
+        break;
+      }
+      directory = directory.parent;
+    }
+    final StringBuffer buffer = new StringBuffer();
+    buffer.write('''
 import 'dart:convert';
 import 'dart:io';  // ignore: dart_io_import
 
@@ -637,6 +663,15 @@
 import 'package:test/src/runner/vm/catch_isolate_errors.dart';
 
 import '$testUrl' as test;
+'''
+    );
+    if (testConfigFile != null) {
+      buffer.write('''
+import '${new Uri.file(testConfigFile.path)}' as test_config;
+'''
+      );
+    }
+    buffer.write('''
 
 void main() {
   print('$_kStartTimeoutTimerMessage');
@@ -646,7 +681,20 @@
     catchIsolateErrors();
     goldenFileComparator = new LocalFileComparator(Uri.parse('$testUrl'));
     autoUpdateGoldenFiles = $updateGoldens;
+'''
+    );
+    if (testConfigFile != null) {
+      buffer.write('''
+    return () {
+      test_config.main(test.main);
+    };
+''');
+    } else {
+      buffer.write('''
     return test.main;
+''');
+    }
+    buffer.write('''
   });
   WebSocket.connect(server).then((WebSocket socket) {
     socket.map((dynamic x) {
@@ -656,7 +704,9 @@
     socket.addStream(channel.stream.map(json.encode));
   });
 }
-''';
+'''
+    );
+    return buffer.toString();
   }
 
   File _cachedFontConfig;