Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

initial implementation of a github actions reporter #1678

Merged
merged 15 commits into from Mar 30, 2022
14 changes: 11 additions & 3 deletions pkgs/test_core/lib/src/runner/configuration/reporters.dart
Expand Up @@ -11,6 +11,7 @@ import '../engine.dart';
import '../reporter.dart';
import '../reporter/compact.dart';
import '../reporter/expanded.dart';
import '../reporter/github.dart';
import '../reporter/json.dart';

/// Constructs a reporter for the provided engine with the provided
Expand Down Expand Up @@ -43,6 +44,11 @@ final _allReporters = <String, ReporterDetails>{
printPath: config.paths.length > 1 ||
Directory(config.paths.single.testPath).existsSync(),
printPlatform: config.suiteDefaults.runtimes.length > 1)),
'github': ReporterDetails(
'Custom output for Github Actions.',
(config, engine, sink) => GithubReporter.watch(engine, sink,
printPath: config.paths.length > 1 ||
Directory(config.paths.single.testPath).existsSync())),
'json': ReporterDetails(
'A machine-readable format (see '
'https://dart.dev/go/test-docs/json_reporter.md).',
Expand All @@ -52,9 +58,11 @@ final _allReporters = <String, ReporterDetails>{

final defaultReporter = inTestTests
? 'expanded'
: canUseSpecialChars
? 'compact'
: 'expanded';
: githubContext
? 'github'
: canUseSpecialChars
? 'compact'
: 'expanded';

/// **Do not call this function without express permission from the test package
/// authors**.
Expand Down
196 changes: 196 additions & 0 deletions pkgs/test_core/lib/src/runner/reporter/github.dart
@@ -0,0 +1,196 @@
// Copyright (c) 2022, 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.

// ignore_for_file: implementation_imports

import 'dart:async';

import 'package:test_api/src/backend/live_test.dart';
import 'package:test_api/src/backend/message.dart';
import 'package:test_api/src/backend/state.dart';
import 'package:test_api/src/backend/util/pretty_print.dart';

import '../engine.dart';
import '../load_suite.dart';
import '../reporter.dart';

/// A reporter that prints test output using formatting for Github Actions.
///
/// See
/// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
/// for a description of the output format, and
/// https://github.com/dart-lang/test/issues/1415 for discussions about this
/// implementation.
class GithubReporter implements Reporter {
/// The engine used to run the tests.
final Engine _engine;

/// Whether the path to each test's suite should be printed.
final bool _printPath;

/// Whether the reporter is paused.
var _paused = false;

/// The set of all subscriptions to various streams.
final _subscriptions = <StreamSubscription>{};

final StringSink _sink;
final _helper = _GithubHelper();

final Map<LiveTest, List<Message>> _testMessages = {};

/// Watches the tests run by [engine] and prints their results as JSON.
static GithubReporter watch(
Engine engine,
StringSink sink, {
required bool printPath,
}) =>
GithubReporter._(engine, sink, printPath);

GithubReporter._(this._engine, this._sink, this._printPath) {
_subscriptions.add(_engine.onTestStarted.listen(_onTestStarted));
_subscriptions.add(_engine.success.asStream().listen(_onDone));
}

@override
void pause() {
if (_paused) return;
_paused = true;

for (var subscription in _subscriptions) {
subscription.pause();
}
}

@override
void resume() {
if (!_paused) return;
_paused = false;

for (var subscription in _subscriptions) {
subscription.resume();
}
}

void _cancel() {
for (var subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
}

/// A callback called when the engine begins running [liveTest].
void _onTestStarted(LiveTest liveTest) {
// Convert the future to a stream so that the subscription can be paused or
// canceled.
_subscriptions.add(
liveTest.onComplete.asStream().listen((_) => _onComplete(liveTest)));

// Collect messages from tests as they are emitted.
_subscriptions.add(liveTest.onMessage.listen((message) {
_testMessages.putIfAbsent(liveTest, () => []).add(message);
}));
}

/// A callback called when [liveTest] finishes running.
void _onComplete(LiveTest test) {
final errors = test.errors;
final messages = _testMessages[test] ?? [];
final skipped = test.state.result == Result.skipped;
final failed = errors.isNotEmpty;

void emitMessages(List<Message> messages) {
for (var message in messages) {
_sink.writeln(message.text);
}
}

void emitErrors(List<AsyncError> errors) {
for (var error in errors) {
_sink.writeln('${error.error}');
_sink.writeln(error.stackTrace.toString().trimRight());
}
}

final isLoadSuite = test.suite is LoadSuite;
if (isLoadSuite) {
// Don't emit any info for 'loadSuite' tests, unless they contain errors.
if (errors.isNotEmpty || messages.isNotEmpty) {
_sink.writeln('${test.suite.path}:');
devoncarew marked this conversation as resolved.
Show resolved Hide resolved
emitMessages(messages);
emitErrors(errors);
}

return;
}

final prefix = failed
? _GithubHelper.failedIcon
: skipped
? _GithubHelper.skippedIcon
: _GithubHelper.passedIcon;
final statusSuffix = failed
? ' (failed)'
: skipped
? ' (skipped)'
: '';

var name = test.test.name;
if (_printPath && test.suite.path != null) {
name = '${test.suite.path}: $name';
}
_sink.writeln(_helper.startGroup('$prefix $name$statusSuffix'));
emitMessages(messages);
emitErrors(errors);
_sink.writeln(_helper.endGroup);
}

void _onDone(bool? success) {
_cancel();

_sink.writeln();

final hadFailures = _engine.failed.isNotEmpty;
String message =
devoncarew marked this conversation as resolved.
Show resolved Hide resolved
'${_engine.passed.length} ${pluralize('test', _engine.passed.length)} passed';
if (_engine.failed.isNotEmpty) {
message += ', ${_engine.failed.length} failed';
}
if (_engine.skipped.isNotEmpty) {
message += ', ${_engine.skipped.length} skipped';
}
message += '.';
_sink.writeln(
hadFailures
? _helper.error(message)
: '${_GithubHelper.celebrationIcon} $message',
);
}

// todo: do we need to bake in awareness about tests that haven't completed
// yet?

// ignore: unused_element
String _normalizeTestResult(LiveTest liveTest) {
// For backwards-compatibility, report skipped tests as successes.
if (liveTest.state.result == Result.skipped) return 'success';
// if test is still active, it was probably cancelled
if (_engine.active.contains(liveTest)) return 'error';
return liveTest.state.result.toString();
}
}

class _GithubHelper {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just make this a abstract class w/ everything static?

Planning on having state at some point?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or just make these top level private members

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to keep these grouped with the other github markup code (startGroup(), error(), ...) as they're related. No real opinions other than that; static members works for me.

static const String passedIcon = '✅';
static const String failedIcon = '❌';
static const String skippedIcon = '❎';
static const String celebrationIcon = '🎉';

_GithubHelper();

String startGroup(String title) => '::group::${title.replaceAll('\n', ' ')}';
final String endGroup = '::endgroup::';

String error(String message) => '::error::$message';
}
6 changes: 6 additions & 0 deletions pkgs/test_core/lib/src/util/io.dart
Expand Up @@ -87,6 +87,12 @@ final _tempDir = Platform.environment.containsKey('_UNITTEST_TEMP_DIR')
bool get canUseSpecialChars =>
(!Platform.isWindows || stdout.supportsAnsiEscapes) && !inTestTests;

/// Detect whether we're running in a Github Actions context.
///
/// See
/// https://docs.github.com/en/actions/learn-github-actions/environment-variables.
bool get githubContext => Platform.environment['GITHUB_ACTIONS'] == 'true';
devoncarew marked this conversation as resolved.
Show resolved Hide resolved

/// Creates a temporary directory and returns its path.
String createTempDir() =>
Directory(_tempDir).createTempSync('dart_test_').resolveSymbolicLinksSync();
Expand Down