flutter_compact_formatter.dart 8.39 KB
Newer Older
Dan Field's avatar
Dan Field committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131
// Copyright 2019 The Flutter 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:convert';
import 'dart:io';

import 'package:meta/meta.dart';

final Stopwatch _stopwatch = Stopwatch();

/// A wrapper around package:test's JSON reporter.
///
/// This class behaves similarly to the compact reporter, but supresses all
/// output except for progress until the end of testing. In other words, errors,
/// [print] calls, and skipped test messages will not be printed during the run
/// of the suite.
///
/// It also processes the JSON data into a collection of [TestResult]s for any
/// other post processing needs, e.g. sending data to analytics.
class FlutterCompactFormatter {
  FlutterCompactFormatter() {
    _stopwatch.start();
  }

  /// Whether to use color escape codes in writing to stdout.
  final bool useColor = stdout.supportsAnsiEscapes;

  /// The terminal escape for green text, or the empty string if this is Windows
  /// or not outputting to a terminal.
  String get _green => useColor ? '\u001b[32m' : '';

  /// The terminal escape for red text, or the empty string if this is Windows
  /// or not outputting to a terminal.
  String get _red => useColor ? '\u001b[31m' : '';

  /// The terminal escape for yellow text, or the empty string if this is
  /// Windows or not outputting to a terminal.
  String get _yellow => useColor ? '\u001b[33m' : '';

  /// The terminal escape for gray text, or the empty string if this is
  /// Windows or not outputting to a terminal.
  String get _gray => useColor ? '\u001b[1;30m' : '';

  /// The terminal escape for bold text, or the empty string if this is
  /// Windows or not outputting to a terminal.
  String get _bold => useColor ? '\u001b[1m' : '';

  /// The terminal escape for removing test coloring, or the empty string if
  /// this is Windows or not outputting to a terminal.
  String get _noColor => useColor ? '\u001b[0m' : '';

  /// The termianl escape for clearing the line, or a carriage return if
  /// this is Windows or not outputting to a termianl.
  String get _clearLine => useColor ? '\x1b[2K\r' : '\r';

  final Map<int, TestResult> _tests = <int, TestResult>{};

  /// The test results from this run.
  Iterable<TestResult> get tests => _tests.values;

  /// The number of tests that were started.
  int started = 0;

  /// The number of test failures.
  int failures = 0;

  /// The number of skipped tests.
  int skips = 0;

  /// The number of successful tests.
  int successes = 0;

  /// Process a single line of JSON output from the JSON test reporter.
  ///
  /// Callers are responsible for splitting multiple lines before calling this
  /// method.
  TestResult processRawOutput(String raw) {
    assert(raw != null);
    // We might be getting messages from Flutter Tool about updating/building.
    if (!raw.startsWith('{')) {
      print(raw);
      return null;
    }
    final Map<String, dynamic> decoded = json.decode(raw);
    final TestResult originalResult = _tests[decoded['testID']];
    switch (decoded['type']) {
      case 'done':
        stdout.write(_clearLine);
        stdout.write('$_bold${_stopwatch.elapsed}$_noColor ');
        stdout.writeln(
            '$_green+$successes $_yellow~$skips $_red-$failures:$_bold$_gray Done.$_noColor');
        break;
      case 'testStart':
        final Map<String, dynamic> testData = decoded['test'];
        if (testData['url'] == null) {
          started += 1;
          stdout.write(_clearLine);
          stdout.write('$_bold${_stopwatch.elapsed}$_noColor ');
          stdout.write(
              '$_green+$successes $_yellow~$skips $_red-$failures: $_gray${testData['name']}$_noColor');
          break;
        }
        _tests[testData['id']] = TestResult(
          id: testData['id'],
          name: testData['name'],
          line: testData['root_line'] ?? testData['line'],
          column: testData['root_column'] ?? testData['column'],
          path: testData['root_url'] ?? testData['url'],
          startTime: decoded['time'],
        );
        break;
      case 'testDone':
        if (originalResult == null) {
          break;
        }
        originalResult.endTime = decoded['time'];
        if (decoded['skipped'] == true) {
          skips += 1;
          originalResult.status = TestStatus.skipped;
        } else {
          if (decoded['result'] == 'success') {
            originalResult.status =TestStatus.succeeded;
            successes += 1;
          } else {
            originalResult.status = TestStatus.failed;
            failures += 1;
          }
        }
        break;
      case 'error':
132 133
        final String error = decoded['error'];
        final String stackTrace = decoded['stackTrace'];
Dan Field's avatar
Dan Field committed
134
        if (originalResult != null) {
135 136 137 138 139 140 141
          originalResult.errorMessage = error;
          originalResult.stackTrace = stackTrace;
        } else {
          if (error != null)
            stderr.writeln(error);
          if (stackTrace != null)
            stderr.writeln(stackTrace);
Dan Field's avatar
Dan Field committed
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270
        }
        break;
      case 'print':
        if (originalResult != null) {
          originalResult.messages.add(decoded['message']);
        }
        break;
      case 'group':
      case 'allSuites':
      case 'start':
      case 'suite':
      default:
        break;
    }
    return originalResult;
  }

  /// Print summary of test results.
  void finish() {
    final List<String> skipped = <String>[];
    final List<String> failed = <String>[];
    for (TestResult result in _tests.values) {
      switch (result.status) {
        case TestStatus.started:
          failed.add('${_red}Unexpectedly failed to complete a test!');
          failed.add(result.toString() + _noColor);
          break;
        case TestStatus.skipped:
          skipped.add(
              '${_yellow}Skipped ${result.name} (${result.pathLineColumn}).$_noColor');
          break;
        case TestStatus.failed:
          failed.addAll(<String>[
            '$_bold${_red}Failed ${result.name} (${result.pathLineColumn}):',
            result.errorMessage,
            _noColor + _red,
            result.stackTrace,
          ]);
          failed.addAll(result.messages);
          failed.add(_noColor);
          break;
        case TestStatus.succeeded:
          break;
      }
    }
    skipped.forEach(print);
    failed.forEach(print);
    if (failed.isEmpty) {
      print('${_green}Completed, $successes test(s) passing ($skips skipped).$_noColor');
    } else {
      print('$_gray$failures test(s) failed.$_noColor');
    }
  }
}

/// The state of a test received from the JSON reporter.
enum TestStatus {
  /// Test execution has started.
  started,
  /// Test completed successfully.
  succeeded,
  /// Test failed.
  failed,
  /// Test was skipped.
  skipped,
}

/// The detailed status of a test run.
class TestResult {
  TestResult({
    @required this.id,
    @required this.name,
    @required this.line,
    @required this.column,
    @required this.path,
    @required this.startTime,
    this.status = TestStatus.started,
  })  : assert(id != null),
        assert(name != null),
        assert(line != null),
        assert(column != null),
        assert(path != null),
        assert(startTime != null),
        assert(status != null),
        messages = <String>[];

  /// The state of the test.
  TestStatus status;

  /// The internal ID of the test used by the JSON reporter.
  final int id;

  /// The name of the test, specified via the `test` method.
  final String name;

  /// The line number from the original file.
  final int line;

  /// The column from the original file.
  final int column;

  /// The path of the original test file.
  final String path;

  /// A friendly print out of the [path], [line], and [column] of the test.
  String get pathLineColumn => '$path:$line:$column';

  /// The start time of the test, in milliseconds relative to suite startup.
  final int startTime;

  /// The stdout of the test.
  final List<String> messages;

  /// The error message from the test, from an `expect`, an [Exception] or
  /// [Error].
  String errorMessage;

  /// The stacktrace from a test failure.
  String stackTrace;

  /// The time, in milliseconds relative to suite startup, that the test ended.
  int endTime;

  /// The total time, in milliseconds, that the test took.
  int get totalTime => (endTime ?? _stopwatch.elapsedMilliseconds) - startTime;

  @override
  String toString() => '{$runtimeType: {$id, $name, ${totalTime}ms, $pathLineColumn}}';
}