Add custom-html-template-path config option (#1127)

This allows for reusing one template file across all tests in use cases
where external scripts or html elements are required for all tests. The
possibility to still use local html files per test file is retained.

Fixes https://github.com/dart-lang/test/issues/39
diff --git a/pkgs/test/CHANGELOG.md b/pkgs/test/CHANGELOG.md
index be7ec19..594d75b 100644
--- a/pkgs/test/CHANGELOG.md
+++ b/pkgs/test/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 1.10.0-dev
+
+* Add `customHtmlTemplateFile` configuration option to allow sharing an
+  html template between tests
+
 ## 1.9.5
 
 * Internal cleanup.
diff --git a/pkgs/test/README.md b/pkgs/test/README.md
index 5bd7e1d..0bc0df2 100644
--- a/pkgs/test/README.md
+++ b/pkgs/test/README.md
@@ -10,6 +10,7 @@
 * [Asynchronous Tests](#asynchronous-tests)
   * [Stream Matchers](#stream-matchers)
 * [Running Tests With Custom HTML](#running-tests-with-custom-html)
+  * [Providing a custom HTML template](#providing-a-custom-html-template)
 * [Configuring Tests](#configuring-tests)
   * [Skipping Tests](#skipping-tests)
   * [Timeouts](#timeouts)
@@ -493,7 +494,9 @@
 tests. However, tests that need custom HTML can create their own files. These
 files have three requirements:
 
-* They must have the same name as the test, with `.dart` replaced by `.html`.
+* They must have the same name as the test, with `.dart` replaced by `.html`. You can also
+  provide a configuration path to an html file if you want it to be reused across all tests.
+  See [Providing a custom HTML template](#providing-a-custom-html-template) below.
 
 * They must contain a `link` tag with `rel="x-dart-test"` and an `href`
   attribute pointing to the test script.
@@ -518,6 +521,40 @@
 </html>
 ```
 
+### Providing a custom HTML template
+
+If you want to share the same HTML file across all tests, you can provide a
+`custom-html-template-path` configuration option to your configuration file.
+This file should follow the rules above, except that instead of the link tag
+add exactly one `{{testScript}}` in the place where you want the template processor to insert it.
+
+You can also optionally use any number of `{{testName}}` placeholders which will be replaced by the test filename.
+
+The template can't be named like any test file, as that would clash with using the
+custom HTML mechanics. In such a case, an error will be thrown.
+
+For example:
+
+```yaml
+custom-html-template-path: html_template.html.tpl
+```
+
+```html
+<!doctype html>
+<!-- html_template.html.tpl -->
+<html>
+  <head>
+    <title>{{testName}} Test</title>
+    {{testScript}}
+    <script src="packages/test/dart.js"></script>
+  </head>
+  <body>
+    // ...
+  </body>
+</html>
+```
+
+
 ## Configuring Tests
 
 ### Skipping Tests
diff --git a/pkgs/test/doc/configuration.md b/pkgs/test/doc/configuration.md
index 25514de..0c0a3f0 100644
--- a/pkgs/test/doc/configuration.md
+++ b/pkgs/test/doc/configuration.md
@@ -47,6 +47,7 @@
   * [`pub_serve`](#pub_serve)
   * [`reporter`](#reporter)
   * [`fold_stack_frames`](#fold_stack_frames)
+  * [`custom_html_template_path`](#custom_html_template_path)
 * [Configuring Tags](#configuring-tags)
   * [`tags`](#tags)
   * [`add_tags`](#add_tags)
@@ -463,6 +464,11 @@
 test/sample_test.dart 5:27  main.<fn>
 ```
 
+### `custom_html_template_path`
+
+This field specifies the path of the HTML template file to be used for tests run in an HTML environment.
+Any HTML file that is named the same as the test and in the same directory will take precedence over the template.
+For more information about the usage of this option see [Providing a custom HTML template](https://github.com/dart-lang/test/blob/master/README.md#providing-a-custom-html-template)
 
 ## Configuring Tags
 
diff --git a/pkgs/test/lib/src/runner/browser/platform.dart b/pkgs/test/lib/src/runner/browser/platform.dart
index a2af0b3..199ee3b 100644
--- a/pkgs/test/lib/src/runner/browser/platform.dart
+++ b/pkgs/test/lib/src/runner/browser/platform.dart
@@ -55,6 +55,8 @@
         Configuration.current,
         p.fromUri(await Isolate.resolvePackageUri(
             Uri.parse('package:test/src/runner/browser/static/favicon.ico'))),
+        p.fromUri(await Isolate.resolvePackageUri(Uri.parse(
+            'package:test/src/runner/browser/static/default.html.tpl'))),
         root: root);
   }
 
@@ -126,7 +128,11 @@
   /// Mappers for Dartifying stack traces, indexed by test path.
   final _mappers = <String, StackTraceMapper>{};
 
+  /// The default template for html tests.
+  final String _defaultTemplatePath;
+
   BrowserPlatform._(this._server, Configuration config, String faviconPath,
+      this._defaultTemplatePath,
       {String root})
       : _config = config,
         _root = root ?? p.current,
@@ -179,22 +185,17 @@
 
     if (path.endsWith('.html')) {
       var test = p.withoutExtension(path) + '.dart';
-
-      // Link to the Dart wrapper on Dartium and the compiled JS version
-      // elsewhere.
       var scriptBase = htmlEscape.convert(p.basename(test));
       var link = '<link rel="x-dart-test" href="$scriptBase">';
-
-      return shelf.Response.ok('''
-        <!DOCTYPE html>
-        <html>
-        <head>
-          <title>${htmlEscape.convert(test)} Test</title>
-          $link
-          <script src="packages/test/dart.js"></script>
-        </head>
-        </html>
-      ''', headers: {'Content-Type': 'text/html'});
+      var testName = htmlEscape.convert(test);
+      var template = _config.customHtmlTemplatePath ?? _defaultTemplatePath;
+      var contents = File(template).readAsStringSync();
+      var processedContents = contents
+          // Checked during loading phase that there is only one {{testScript}} placeholder.
+          .replaceFirst('{{testScript}}', link)
+          .replaceAll('{{testName}}', testName);
+      return shelf.Response.ok(processedContents,
+          headers: {'Content-Type': 'text/html'});
     }
 
     return shelf.Response.notFound('Not found.');
@@ -231,13 +232,30 @@
       throw ArgumentError('$browser is not a browser.');
     }
 
-    var htmlPath = p.withoutExtension(path) + '.html';
-    if (File(htmlPath).existsSync() &&
-        !File(htmlPath).readAsStringSync().contains('packages/test/dart.js')) {
-      throw LoadException(
-          path,
-          '"${htmlPath}" must contain <script src="packages/test/dart.js">'
-          '</script>.');
+    var htmlPathFromTestPath = p.withoutExtension(path) + '.html';
+    if (File(htmlPathFromTestPath).existsSync()) {
+      if (_config.customHtmlTemplatePath != null &&
+          p.basename(htmlPathFromTestPath) ==
+              p.basename(_config.customHtmlTemplatePath)) {
+        throw LoadException(
+            path,
+            'template file "${p.basename(_config.customHtmlTemplatePath)}" cannot be named '
+            'like the test file.');
+      }
+      _checkHtmlCorrectness(htmlPathFromTestPath, path);
+    } else if (_config.customHtmlTemplatePath != null) {
+      var htmlTemplatePath = _config.customHtmlTemplatePath;
+      if (!File(htmlTemplatePath).existsSync()) {
+        throw LoadException(
+            path, '"${htmlTemplatePath}" does not exist or is not readable');
+      }
+
+      final templateFileContents = File(htmlTemplatePath).readAsStringSync();
+      if ('{{testScript}}'.allMatches(templateFileContents).length != 1) {
+        throw LoadException(path,
+            '"${htmlTemplatePath}" must contain exactly one {{testScript}} placeholder');
+      }
+      _checkHtmlCorrectness(htmlTemplatePath, path);
     }
 
     Uri suiteUrl;
@@ -276,6 +294,15 @@
     return suite;
   }
 
+  void _checkHtmlCorrectness(String htmlPath, String path) {
+    if (!File(htmlPath).readAsStringSync().contains('packages/test/dart.js')) {
+      throw LoadException(
+          path,
+          '"${htmlPath}" must contain <script src="packages/test/dart.js">'
+          '</script>.');
+    }
+  }
+
   @override
   StreamChannel loadChannel(String path, SuitePlatform platform) =>
       throw UnimplementedError();
diff --git a/pkgs/test/lib/src/runner/browser/static/default.html.tpl b/pkgs/test/lib/src/runner/browser/static/default.html.tpl
new file mode 100644
index 0000000..a92f529
--- /dev/null
+++ b/pkgs/test/lib/src/runner/browser/static/default.html.tpl
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>{{testName}} Test</title>
+    {{testScript}}
+    <script src="packages/test/dart.js"></script>
+  </head>
+</html>
diff --git a/pkgs/test/pubspec.yaml b/pkgs/test/pubspec.yaml
index 31b1f6e..0177e3e 100644
--- a/pkgs/test/pubspec.yaml
+++ b/pkgs/test/pubspec.yaml
@@ -1,5 +1,5 @@
 name: test
-version: 1.9.5-dev
+version: 1.10.0-dev
 author: Dart Team <misc@dartlang.org>
 description: A full featured library for writing and running Dart tests.
 homepage: https://github.com/dart-lang/test/blob/master/pkgs/test
diff --git a/pkgs/test/test/runner/browser/runner_test.dart b/pkgs/test/test/runner/browser/runner_test.dart
index 5c78f89..cbfd8f5 100644
--- a/pkgs/test/test/runner/browser/runner_test.dart
+++ b/pkgs/test/test/runner/browser/runner_test.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 @TestOn('vm')
+import 'dart:convert';
 
 import 'package:test_descriptor/test_descriptor.dart' as d;
 
@@ -207,8 +208,168 @@
       await test.shouldExit(1);
     }, tags: 'chrome');
 
-    // TODO(nweiz): test what happens when a test file is unreadable once issue
-    // 15078 is fixed.
+    test(
+        'still errors even with a custom HTML template set since it will take precedence',
+        () async {
+      await d.file('test.dart', 'void main() {}').create();
+
+      await d.file('test.html', '''
+<html>
+<head>
+  <link rel="x-dart-test" href="test.dart">
+</head>
+</html>
+''').create();
+
+      await d
+          .file(
+              'global_test.yaml',
+              jsonEncode(
+                  {'custom_html_template_path': 'html_template.html.tpl'}))
+          .create();
+
+      await d.file('html_template.html.tpl', '''
+<html>
+<head>
+  {{testScript}}
+  <script src="packages/test/dart.js"></script>
+</head>
+<body>
+  <div id="foo"></div>
+</body>
+</html>
+''').create();
+
+      var test = await runTest(['-p', 'chrome', 'test.dart'],
+          environment: {'DART_TEST_CONFIG': 'global_test.yaml'});
+      expect(
+          test.stdout,
+          containsInOrder([
+            '-1: compiling test.dart [E]',
+            'Failed to load "test.dart": "test.html" must contain '
+                '<script src="packages/test/dart.js"></script>.'
+          ]));
+      await test.shouldExit(1);
+    }, tags: 'chrome');
+
+    group('with a custom HTML template', () {
+      setUp(() async {
+        await d.file('test.dart', _success).create();
+        await d
+            .file(
+                'global_test.yaml',
+                jsonEncode(
+                    {'custom_html_template_path': 'html_template.html.tpl'}))
+            .create();
+      });
+
+      test('that does not exist', () async {
+        var test = await runTest(['-p', 'chrome', 'test.dart'],
+            environment: {'DART_TEST_CONFIG': 'global_test.yaml'});
+        expect(
+            test.stdout,
+            containsInOrder([
+              '-1: compiling test.dart [E]',
+              'Failed to load "test.dart": "html_template.html.tpl" does not exist or is not readable'
+            ]));
+        await test.shouldExit(1);
+      }, tags: 'chrome');
+
+      test("that doesn't contain the {{testScript}} tag", () async {
+        await d.file('html_template.html.tpl', '''
+<html>
+<head>
+  <script src="packages/test/dart.js"></script>
+</head>
+<body>
+  <div id="foo"></div>
+</body>
+</html>
+''').create();
+
+        var test = await runTest(['-p', 'chrome', 'test.dart'],
+            environment: {'DART_TEST_CONFIG': 'global_test.yaml'});
+        expect(
+            test.stdout,
+            containsInOrder([
+              '-1: compiling test.dart [E]',
+              'Failed to load "test.dart": "html_template.html.tpl" must contain exactly one {{testScript}} placeholder'
+            ]));
+        await test.shouldExit(1);
+      }, tags: 'chrome');
+
+      test('that contains more than one {{testScript}} tag', () async {
+        await d.file('html_template.html.tpl', '''
+<html>
+<head>
+  {{testScript}}
+  {{testScript}}
+  <script src="packages/test/dart.js"></script>
+</head>
+<body>
+  <div id="foo"></div>
+</body>
+</html>
+''').create();
+
+        var test = await runTest(['-p', 'chrome', 'test.dart'],
+            environment: {'DART_TEST_CONFIG': 'global_test.yaml'});
+        expect(
+            test.stdout,
+            containsInOrder([
+              '-1: compiling test.dart [E]',
+              'Failed to load "test.dart": "html_template.html.tpl" must contain exactly one {{testScript}} placeholder'
+            ]));
+        await test.shouldExit(1);
+      }, tags: 'chrome');
+
+      test('that has no script tag', () async {
+        await d.file('html_template.html.tpl', '''
+<html>
+<head>
+  {{testScript}}
+</head>
+</html>
+''').create();
+
+        var test = await runTest(['-p', 'chrome', 'test.dart'],
+            environment: {'DART_TEST_CONFIG': 'global_test.yaml'});
+        expect(
+            test.stdout,
+            containsInOrder([
+              '-1: compiling test.dart [E]',
+              'Failed to load "test.dart": "html_template.html.tpl" must contain '
+                  '<script src="packages/test/dart.js"></script>.'
+            ]));
+        await test.shouldExit(1);
+      }, tags: 'chrome');
+
+      test('that is named like the test file', () async {
+        await d.file('test.html', '''
+<html>
+<head>
+  {{testScript}}
+  <script src="packages/test/dart.js"></script>
+</head>
+</html>
+''').create();
+
+        await d
+            .file('global_test_2.yaml',
+                jsonEncode({'custom_html_template_path': 'test.html'}))
+            .create();
+        var test = await runTest(['-p', 'chrome', 'test.dart'],
+            environment: {'DART_TEST_CONFIG': 'global_test_2.yaml'});
+        expect(
+            test.stdout,
+            containsInOrder([
+              '-1: compiling test.dart [E]',
+              'Failed to load "test.dart": template file "test.html" cannot be named '
+                  'like the test file.'
+            ]));
+        await test.shouldExit(1);
+      });
+    });
   });
 
   group('runs successful tests', () {
@@ -263,6 +424,94 @@
       await test.shouldExit(0);
     }, tags: 'chrome');
 
+    group('with a custom HTML template file', () {
+      group('without a {{testName}} tag', () {
+        setUp(() async {
+          await d
+              .file(
+                  'global_test.yaml',
+                  jsonEncode(
+                      {'custom_html_template_path': 'html_template.html.tpl'}))
+              .create();
+          await d.file('html_template.html.tpl', '''
+  <html>
+  <head>
+    {{testScript}}
+    <script src="packages/test/dart.js"></script>
+  </head>
+  <body>
+    <div id="foo"></div>
+  </body>
+  </html>
+  ''').create();
+
+          await d.file('test.dart', '''
+  import 'dart:html';
+
+  import 'package:test/test.dart';
+
+  void main() {
+    test("success", () {
+      expect(document.querySelector('#foo'), isNotNull);
+    });
+  }
+  ''').create();
+        });
+
+        test('on Chrome', () async {
+          var test = await runTest(['-p', 'chrome', 'test.dart'],
+              environment: {'DART_TEST_CONFIG': 'global_test.yaml'});
+          expect(test.stdout, emitsThrough(contains('+1: All tests passed!')));
+          await test.shouldExit(0);
+        }, tags: 'chrome');
+      });
+
+      group('with a {{testName}} tag', () {
+        setUp(() async {
+          await d
+              .file(
+                  'global_test.yaml',
+                  jsonEncode(
+                      {'custom_html_template_path': 'html_template.html.tpl'}))
+              .create();
+          await d.file('html_template.html.tpl', '''
+  <html>
+  <head>
+    <title>{{testName}}</title>
+    {{testScript}}
+    <script src="packages/test/dart.js"></script>
+  </head>
+  <body>
+    <div id="foo"></div>
+  </body>
+  </html>
+  ''').create();
+
+          await d.file('test-with-title.dart', '''
+  import 'dart:html';
+
+  import 'package:test/test.dart';
+
+  void main() {
+    test("success", () {
+      expect(document.querySelector('#foo'), isNotNull);
+    });
+    test("title", () {
+      expect(document.title, 'test-with-title.dart');
+    });
+  }
+  ''').create();
+        });
+
+        test('on Chrome', () async {
+          var test = await runTest(['-p', 'chrome', 'test-with-title.dart'],
+              environment: {'DART_TEST_CONFIG': 'global_test.yaml'});
+          expect(test.stdout, emitsThrough(contains('+2: All tests passed!')));
+          await test.shouldExit(0);
+        }, tags: 'chrome');
+      });
+    });
+
     group('with a custom HTML file', () {
       setUp(() async {
         await d.file('test.dart', '''
@@ -316,6 +565,31 @@
         expect(test.stdout, emitsThrough(contains('+1: All tests passed!')));
         await test.shouldExit(0);
       }, tags: 'chrome');
+
+      test('takes precedence over provided HTML template', () async {
+        await d
+            .file(
+                'global_test.yaml',
+                jsonEncode(
+                    {'custom_html_template_path': 'html_template.html.tpl'}))
+            .create();
+        await d.file('html_template.html.tpl', '''
+<html>
+<head>
+  {{testScript}}
+  <script src="packages/test/dart.js"></script>
+</head>
+<body>
+  <div id="not-foo"></div>
+</body>
+</html>
+''').create();
+
+        var test = await runTest(['-p', 'chrome', 'test.dart'],
+            environment: {'DART_TEST_CONFIG': 'global_test.yaml'});
+        expect(test.stdout, emitsThrough(contains('+1: All tests passed!')));
+        await test.shouldExit(0);
+      }, tags: 'chrome');
     });
   });
 
diff --git a/pkgs/test_core/CHANGELOG.md b/pkgs/test_core/CHANGELOG.md
index 8fcd0c9..fbe9a65 100644
--- a/pkgs/test_core/CHANGELOG.md
+++ b/pkgs/test_core/CHANGELOG.md
@@ -1,6 +1,8 @@
 ## 0.2.16-dev
 
 * Internal cleanup.
+* Add `customHtmlTemplateFile` configuration option to allow sharing an
+  html template between tests
 
 ## 0.2.15
 
diff --git a/pkgs/test_core/lib/src/runner/configuration.dart b/pkgs/test_core/lib/src/runner/configuration.dart
index 7af26e7..22ff258 100644
--- a/pkgs/test_core/lib/src/runner/configuration.dart
+++ b/pkgs/test_core/lib/src/runner/configuration.dart
@@ -43,6 +43,9 @@
   bool get help => _help ?? false;
   final bool _help;
 
+  /// Custom HTML template file.
+  final String customHtmlTemplatePath;
+
   /// Whether `--version` was passed.
   bool get version => _version ?? false;
   final bool _version;
@@ -216,6 +219,7 @@
 
   factory Configuration(
       {bool help,
+      String customHtmlTemplatePath,
       bool version,
       bool pauseAfterLoad,
       bool debug,
@@ -263,6 +267,7 @@
     var chosenPresetSet = chosenPresets?.toSet();
     var configuration = Configuration._(
         help: help,
+        customHtmlTemplatePath: customHtmlTemplatePath,
         version: version,
         pauseAfterLoad: pauseAfterLoad,
         debug: debug,
@@ -323,6 +328,7 @@
   /// Unlike [new Configuration], this assumes [presets] is already resolved.
   Configuration._(
       {bool help,
+      String customHtmlTemplatePath,
       bool version,
       bool pauseAfterLoad,
       bool debug,
@@ -346,6 +352,7 @@
       bool noRetry,
       SuiteConfiguration suiteDefaults})
       : _help = help,
+        customHtmlTemplatePath = customHtmlTemplatePath,
         _version = version,
         _pauseAfterLoad = pauseAfterLoad,
         _debug = debug,
@@ -470,6 +477,8 @@
 
     var result = Configuration._(
         help: other._help ?? _help,
+        customHtmlTemplatePath:
+            other.customHtmlTemplatePath ?? customHtmlTemplatePath,
         version: other._version ?? _version,
         pauseAfterLoad: other._pauseAfterLoad ?? _pauseAfterLoad,
         color: other._color ?? _color,
@@ -511,6 +520,7 @@
   /// always replaced by the new one.
   Configuration change(
       {bool help,
+      String customHtmlTemplatePath,
       bool version,
       bool pauseAfterLoad,
       bool color,
@@ -554,6 +564,8 @@
       Iterable<String> addTags}) {
     var config = Configuration._(
         help: help ?? _help,
+        customHtmlTemplatePath:
+            customHtmlTemplatePath ?? this.customHtmlTemplatePath,
         version: version ?? _version,
         pauseAfterLoad: pauseAfterLoad ?? _pauseAfterLoad,
         color: color ?? _color,
diff --git a/pkgs/test_core/lib/src/runner/configuration/load.dart b/pkgs/test_core/lib/src/runner/configuration/load.dart
index cf47a75..732cd11 100644
--- a/pkgs/test_core/lib/src/runner/configuration/load.dart
+++ b/pkgs/test_core/lib/src/runner/configuration/load.dart
@@ -251,8 +251,11 @@
 
     var overrideRuntimes = _loadOverrideRuntimes();
 
+    var customHtmlTemplatePath = _getString('custom_html_template_path');
+
     return Configuration(
         pauseAfterLoad: pauseAfterLoad,
+        customHtmlTemplatePath: customHtmlTemplatePath,
         runSkipped: runSkipped,
         reporter: reporter,
         concurrency: concurrency,