[CFE] DartDoc tests support expecting throws

With this CL one could do a test like
```
DartDocTestThrows(LocalStack<int>([]).current)
```

which would then expect the call to throw and fail if it doesn't throw
in which case you might get something like

```
Failure:
Test from [...]/pkg/front_end/lib/src/util/local_stack.dart:19:25 failed with this message:
Expected a crash, but didn't get one.

Processed 19 test(s) in 33 ms.
18 OK; 1 bad; 0 crashed; 0 parse errors.
```

Change-Id: I52deb657cb95b9b1f866a58f7433f0c57162b5c9
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/378480
Commit-Queue: Jens Johansen <jensj@google.com>
Reviewed-by: Johnni Winther <johnniwinther@google.com>
diff --git a/pkg/front_end/lib/src/util/local_stack.dart b/pkg/front_end/lib/src/util/local_stack.dart
index fde11da..e8e5617 100644
--- a/pkg/front_end/lib/src/util/local_stack.dart
+++ b/pkg/front_end/lib/src/util/local_stack.dart
@@ -16,6 +16,7 @@
   /// Return the current top of the stack.
   ///
   /// ```
+  /// DartDocTestThrows(LocalStack<int>([]).current)
   /// DartDocTest(LocalStack<int>([0]).current, 0)
   /// DartDocTest(LocalStack<int>([0, 1]).current, 1)
   /// ```
@@ -33,6 +34,8 @@
   /// Returns the second-most element on the stack.
   ///
   /// ```
+  /// DartDocTestThrows(LocalStack<int>([]).previous)
+  /// DartDocTestThrows(LocalStack<int>([0]).previous)
   /// DartDocTest(LocalStack<int>([0, 1]).previous, 0)
   /// DartDocTest(LocalStack<int>([0, 1, 2]).previous, 1)
   /// ```
@@ -55,6 +58,7 @@
   /// Pops the top of the stack.
   ///
   /// ```
+  /// DartDocTestThrows(LocalStack<int>([]).pop())
   /// DartDocTest(LocalStack<int>([0]).pop(), 0)
   /// DartDocTest((LocalStack<int>([0, 1, 2])..pop()).pop(), 1)
   /// ```
diff --git a/pkg/front_end/test/dartdoc_test_test.dart b/pkg/front_end/test/dartdoc_test_test.dart
index 339898e..4588e5f 100644
--- a/pkg/front_end/test/dartdoc_test_test.dart
+++ b/pkg/front_end/test/dartdoc_test_test.dart
@@ -176,6 +176,39 @@
   ];
   memoryFileSystem.entityForUri(test7).writeAsStringSync(test);
   expect(await dartDocTest.process(test7), expected);
+
+  // Throws test
+  Uri test8 = new Uri(scheme: "darttest", path: "/test8.dart");
+  test = """
+  // DartDocTestThrows(1~/0)
+  main() {
+    print("Hello from main");
+  }
+  """;
+  tests = extractTests(test, test8);
+  expect(tests.length, 1);
+  expected = [
+    new impl.TestResult(tests[0], impl.TestOutcome.Pass),
+  ];
+  memoryFileSystem.entityForUri(test8).writeAsStringSync(test);
+  expect(await dartDocTest.process(test8), expected);
+
+  // Good throws case using await.
+  Uri test9 = new Uri(scheme: "darttest", path: "/test9.dart");
+  test = """
+// DartDocTestThrows(await _internal())
+Future<void> _internal() async {
+  await Future.delayed(new Duration(milliseconds: 1));
+  if (1+1==2) throw "I threw!";
+}
+""";
+  tests = extractTests(test, test9);
+  expect(tests.length, 1);
+  expected = [
+    new impl.TestResult(tests[0], impl.TestOutcome.Pass),
+  ];
+  memoryFileSystem.entityForUri(test9).writeAsStringSync(test);
+  expect(await dartDocTest.process(test9), expected);
 }
 
 void testTestExtraction() {
@@ -303,6 +336,31 @@
 """), <impl.Test>[
     new impl.ExpectTest('await foo()', '42', "darttest:/foo.dart:1:16"),
   ]);
+
+  // One throws test.
+  expect(extractTests("""
+    // not a test comment
+    void foo_bar_long_name() {}
+
+    // DartDocTestThrows(1~/0)"""), <impl.Test>[
+    new impl.ThrowsTest("1~/0", "darttest:/foo.dart:4:26"),
+  ]);
+
+  // Mixture of expect and throws tests.
+  expect(extractTests("""
+    // not a test comment
+    void foo_bar_long_name() {}
+
+    // DartDocTestThrows(1~/0)
+    // DartDocTest(1+1, 2)
+    // DartDocTest(2+2, 4)
+    // DartDocTestThrows(2~/0)"""), <impl.Test>[
+    // For now the order is expect tests first.
+    new impl.ExpectTest("1+1", "2", "darttest:/foo.dart:5:20"),
+    new impl.ExpectTest("2+2", "4", "darttest:/foo.dart:6:20"),
+    new impl.ThrowsTest("1~/0", "darttest:/foo.dart:4:26"),
+    new impl.ThrowsTest("2~/0", "darttest:/foo.dart:7:26"),
+  ]);
 }
 
 void testCommentExtraction() {
@@ -311,7 +369,9 @@
 
   // Simple line comment at position 0.
   expect(
-      extractFirstComment("// Hello"), new impl.CommentString("   Hello", 0));
+    extractFirstComment("// Hello"),
+    new impl.CommentString("   Hello", 0),
+  );
 
   // Simple line comment at position 5.
   expect(extractFirstComment("     // Hello"),
diff --git a/pkg/front_end/test/spell_checking_list_tests.txt b/pkg/front_end/test/spell_checking_list_tests.txt
index cc95db5..8e02c02 100644
--- a/pkg/front_end/test/spell_checking_list_tests.txt
+++ b/pkg/front_end/test/spell_checking_list_tests.txt
@@ -517,6 +517,7 @@
 misspelled
 mistake
 mistakes
+mixture
 mixups
 ml
 mmethod
diff --git a/pkg/front_end/tool/dart_doctest_impl.dart b/pkg/front_end/tool/dart_doctest_impl.dart
index cf32984..cbccbda 100644
--- a/pkg/front_end/tool/dart_doctest_impl.dart
+++ b/pkg/front_end/tool/dart_doctest_impl.dart
@@ -115,17 +115,23 @@
       sb.writeln(
           r"Future<void> $dart$doc$test$tester(dynamic dartDocTest) async {");
       for (Test test in tests) {
-        if (test is TestParseError) {
-          sb.writeln(
-              "dartDocTest.parseError(\"Parse error @ ${test.position}\");");
-        } else if (test is ExpectTest) {
-          sb.writeln("try {");
-          sb.writeln("  dartDocTest.test(${test.call}, ${test.result});");
-          sb.writeln("} catch (e) {");
-          sb.writeln("  dartDocTest.crash(e);");
-          sb.writeln("}");
-        } else {
-          throw "Unknown test type: ${test.runtimeType}";
+        switch (test) {
+          case TestParseError():
+            sb.writeln(
+                "dartDocTest.parseError(\"Parse error @ ${test.position}\");");
+          case ExpectTest():
+            sb.writeln("try {");
+            sb.writeln("  dartDocTest.test(${test.call}, ${test.result});");
+            sb.writeln("} catch (e) {");
+            sb.writeln("  dartDocTest.crash(e);");
+            sb.writeln("}");
+          case ThrowsTest():
+            sb.writeln("try {");
+            sb.writeln(
+                "  await dartDocTest.throws(() async { ${test.call}; });");
+            sb.writeln("} catch (e) {");
+            sb.writeln("  dartDocTest.crash(e);");
+            sb.writeln("}");
         }
       }
       sb.writeln("}");
@@ -357,6 +363,16 @@
     }
   }
 
+  Future<void> throws(Function() computation) async {
+    port.send("$_portMessageTest");
+    try {
+      await computation();
+      port.send("$_portMessageBad: Expected a crash, but didn't get one.");
+    } catch(e) {
+      port.send("$_portMessageGood");
+    }
+  }
+
   void crash(dynamic error) {
     port.send("$_portMessageTest");
     port.send("$_portMessageCrash: \$error");
@@ -486,6 +502,27 @@
   }
 }
 
+class ThrowsTest implements Test {
+  final String call;
+  @override
+  final String location;
+
+  ThrowsTest(this.call, this.location);
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! ThrowsTest) return false;
+    if (other.call != call) return false;
+    if (other.location != location) return false;
+    return true;
+  }
+
+  @override
+  String toString() {
+    return "ThrowsTest[$call, $location]";
+  }
+}
+
 class TestParseError implements Test {
   final String message;
   final int position;
@@ -548,11 +585,7 @@
     CommentToken comment, String rawString, kernel.Source source) {
   CommentString commentsData = extractComments(comment, rawString);
   final String comments = commentsData.string;
-  List<Test> result = [];
-  int index = comments.indexOf("DartDocTest(");
-  if (index < 0) {
-    return result;
-  }
+  int index = -1;
 
   String getLocation(int offset) {
     return source
@@ -560,7 +593,11 @@
         .toString();
   }
 
-  Test scanDartDoc(int scanOffset) {
+  Test scanVariableDartDoc(
+      int scanOffset,
+      String expectedLexeme,
+      int expressionCount,
+      Test Function(List<String> expressions, String location) testCreator) {
     final Token firstToken =
         scanRawBytes(utf8.encode(comments.substring(scanOffset)));
     final ErrorListener listener = new ErrorListener();
@@ -570,7 +607,7 @@
 
     final Token pastErrors = parser.skipErrorTokens(firstToken);
     assert(pastErrors.isIdentifier);
-    assert(pastErrors.lexeme == "DartDocTest");
+    assert(pastErrors.lexeme == expectedLexeme);
 
     final Token startParen = pastErrors.next!;
     assert(identical("(", startParen.stringValue));
@@ -578,68 +615,75 @@
     // Advance index so we don't parse the same thing again (for error cases).
     index = scanOffset + startParen.charEnd;
 
-    final Token beforeComma = parser.parseExpression(startParen);
-    final Token comma = beforeComma.next!;
+    Token parseFrom = startParen;
+    final Token firstExpressionToken = parseFrom.next!;
+    List<String> expressionsText = [];
 
-    if (listener.hasErrors) {
-      StringBuffer sb = new StringBuffer();
-      int firstPosition = _createParseErrorMessages(
-          listener, sb, commentsData, scanOffset, source);
-      return new TestParseError(
-        sb.toString(),
-        firstPosition,
-        getLocation(firstPosition),
-      );
-    } else if (!identical(",", comma.stringValue)) {
-      int position = commentsData.charOffset + scanOffset + comma.charOffset;
-      Message message = codes.templateExpectedButGot.withArguments(',');
-      return new TestParseError(
-        _createParseErrorMessage(source, position, comma, comma, message),
-        position,
-        getLocation(position),
-      );
+    for (int i = 1; i <= expressionCount; i++) {
+      final Token expressionFirstToken = parseFrom.next!;
+      final Token beforeNextSeparator = parser.parseExpression(parseFrom);
+      final Token nextSeparator = parseFrom = beforeNextSeparator.next!;
+      final String expectedSeparator = i == expressionCount ? ")" : ",";
+
+      if (listener.hasErrors) {
+        StringBuffer sb = new StringBuffer();
+        int firstPosition = _createParseErrorMessages(
+            listener, sb, commentsData, scanOffset, source);
+        return new TestParseError(
+          sb.toString(),
+          firstPosition,
+          getLocation(firstPosition),
+        );
+      } else if (!identical(expectedSeparator, nextSeparator.stringValue)) {
+        int position =
+            commentsData.charOffset + scanOffset + nextSeparator.charOffset;
+        Message message =
+            codes.templateExpectedButGot.withArguments(expectedSeparator);
+        return new TestParseError(
+          _createParseErrorMessage(
+              source, position, nextSeparator, nextSeparator, message),
+          position,
+          getLocation(position),
+        );
+      } else {
+        // Good.
+        expressionsText.add(comments.substring(
+            scanOffset + expressionFirstToken.charOffset,
+            scanOffset + beforeNextSeparator.charEnd));
+      }
     }
 
-    Token beforeEndParen = parser.parseExpression(comma);
-    Token endParen = beforeEndParen.next!;
+    assert(expressionsText.length == expressionCount);
 
-    if (listener.hasErrors) {
-      StringBuffer sb = new StringBuffer();
-      int firstPosition = _createParseErrorMessages(
-          listener, sb, commentsData, scanOffset, source);
-      return new TestParseError(
-        sb.toString(),
-        firstPosition,
-        getLocation(firstPosition),
-      );
-    } else if (!identical(")", endParen.stringValue)) {
-      int position = commentsData.charOffset + scanOffset + endParen.charOffset;
-      Message message = codes.templateExpectedButGot.withArguments(')');
-      return new TestParseError(
-        _createParseErrorMessage(source, position, comma, comma, message),
-        position,
-        getLocation(position),
-      );
-    }
-
-    // Advance index so we don't parse the same thing again (success case).
-    index = scanOffset + endParen.charEnd;
-
-    int startPos = scanOffset + startParen.next!.charOffset;
-    int midEndPos = scanOffset + beforeComma.charEnd;
-    int midStartPos = scanOffset + comma.next!.charOffset;
-    int endPos = scanOffset + beforeEndParen.charEnd;
-    return new ExpectTest(
-      comments.substring(startPos, midEndPos),
-      comments.substring(midStartPos, endPos),
-      getLocation(commentsData.charOffset + startPos),
+    return testCreator(
+      expressionsText,
+      getLocation(
+        commentsData.charOffset + scanOffset + firstExpressionToken.charOffset,
+      ),
     );
   }
 
+  List<Test> result = [];
+  index = comments.indexOf("DartDocTest(");
   while (index >= 0) {
-    result.add(scanDartDoc(index));
+    result.add(scanVariableDartDoc(
+        index,
+        "DartDocTest",
+        2,
+        (List<String> expressions, String location) =>
+            new ExpectTest(expressions[0], expressions[1], location)));
     index = comments.indexOf("DartDocTest(", index);
   }
+  index = comments.indexOf("DartDocTestThrows(");
+  while (index >= 0) {
+    result.add(scanVariableDartDoc(
+        index,
+        "DartDocTestThrows",
+        1,
+        (List<String> expressions, String location) =>
+            new ThrowsTest(expressions[0], location)));
+    index = comments.indexOf("DartDocTestThrows(", index);
+  }
   return result;
 }