blob: 6981089b9f07a1149194adfa86f1137c9b06f805 [file] [log] [blame]
// Copyright 2019 The Fuchsia 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 'dart:io';
import 'package:io/ansi.dart';
import 'package:fxtest/fxtest.dart';
import 'package:meta/meta.dart';
/// Container that holds enough information about a test to execute it and
/// report back on output / events.
class TestBundle {
/// Wrapper around the individual strings needed to run this test.
final TestDefinition testDefinition;
/// That which actually launches a process to run the test.
final TestRunner testRunner;
/// Sink for realtime updates from the running test process.
final Function(String) _realtimeOutputSink;
/// Copy of all output, used to send to user when a timeout happens and
/// [_realtimeOutputSink] is null.
final StringBuffer _outputBuffer;
/// Sink for clock updates as a test runs, so the user's expectations can be
/// managed.
///
/// `Duration` is the test runtime, the first `String` is the test's
/// invocation command, and the second `String` is any output.
final Function(Duration, String, String) timeElapsedSink;
/// The directory from which our test assumes it was invoked.
final String workingDirectory;
/// Tokens to pass through to the test runners.
final List<String> runnerFlags;
/// Tokens to pass through to individual tests.
final List<String> extraFlags;
/// Flag to disable actually running the test.
final bool isDryRun;
/// Flag to raise an exception on any test failure which will bubble all the
/// way up and halt suite execution. Useful when users want "fail-fast"
/// behavior.
final bool raiseOnFailure;
/// Optional. Trailing substring of the path to fx. If provided, used to detect
/// and condense full fx paths in output.
final String fxSuffix;
TestBundle(
this.testDefinition, {
@required this.testRunner,
@required this.timeElapsedSink,
@required this.workingDirectory,
this.extraFlags = const [],
this.fxSuffix,
this.isDryRun = false,
this.raiseOnFailure = false,
this.runnerFlags = const [],
Function(String) realtimeOutputSink,
}) : _realtimeOutputSink = realtimeOutputSink,
_outputBuffer = StringBuffer() {
if (testRunner == null) {
throw AssertionError('`testRunnerBuilder` must not equal `null`');
}
}
factory TestBundle.build({
@required TestDefinition testDefinition,
@required TestsConfig testsConfig,
@required Function(Duration, String, String) timeElapsedSink,
@required TestRunner Function(TestsConfig) testRunnerBuilder,
@required String workingDirectory,
Function(String) realtimeOutputSink,
String fxSuffix,
}) =>
TestBundle(
testDefinition,
extraFlags: testsConfig.testArguments.passThroughArgs,
isDryRun: testsConfig.flags.dryRun,
fxSuffix: fxSuffix,
raiseOnFailure: testsConfig.flags.shouldFailFast,
runnerFlags: testsConfig.runnerTokens,
realtimeOutputSink: realtimeOutputSink ?? (String val) => null,
testRunner: testRunnerBuilder(testsConfig),
timeElapsedSink: timeElapsedSink,
workingDirectory: workingDirectory,
);
Function(String) get realtimeOutputSink => (String val) {
_outputBuffer.writeln(val);
if (_realtimeOutputSink != null) {
_realtimeOutputSink(val);
}
};
/// Invokes the actual test that this class wraps.
///
/// Returns a stream of test events that send feedback to the user.
Stream<TestEvent> run() async* {
var testType = testDefinition.executionHandle.testType;
if (testType == TestType.unsupportedDeviceTest) {
var greyTestName =
wrapWith(testDefinition.executionHandle.handle, [styleBold]);
yield TestInfo(
'Skipping unrunnable legacy test: "$greyTestName". '
'All device tests must be component-tests, but this is a binary',
);
return;
}
CommandTokens commandTokens =
testDefinition.executionHandle.getInvocationTokens(runnerFlags);
// Unparsed tests imply a major problem with `fx test`, so we
// definitely want to throw an exception
if (commandTokens.tokens == null || commandTokens.tokens.isEmpty) {
throw UnrunnableTestException(
'Failed to determine run context for test:\n$testDefinition',
);
}
// Defer this check to now (as opposed to when the test is compiled), so the
// feedback can be synced to when tests are executed.
if (commandTokens.warning != null && commandTokens.warning != '') {
yield TestInfo(commandTokens.warning);
}
String fullCommandDisplay = commandTokens.fullCommandDisplay(fxSuffix);
yield TestStarted(
testDefinition: testDefinition,
testName: fullCommandDisplay,
);
if (isDryRun) {
yield TestResult.skipped(testName: fullCommandDisplay);
return;
}
yield* _runTestWithStopwatch(commandTokens, fullCommandDisplay);
}
Stream<TestEvent> _runTestWithStopwatch(
CommandTokens commandTokens,
String fullCommandDisplay,
) async* {
var start = DateTime.now();
Completer timingEvents = Completer();
Timer.periodic(
Duration(milliseconds: 100),
(Timer timer) {
if (timingEvents.isCompleted) {
timer.cancel();
} else {
timeElapsedSink(
DateTime.now().difference(start),
fullCommandDisplay,
_outputBuffer.toString(),
);
}
},
);
TestResult result = await _runTest(commandTokens, fullCommandDisplay);
if (!timingEvents.isCompleted) {
timingEvents.complete();
}
yield result;
if (raiseOnFailure && result.exitCode != 0) {
throw FailFastException();
}
}
Future<TestResult> _runTest(
CommandTokens commandTokens,
String fullCommandDisplay,
) async {
var start = DateTime.now();
testRunner.output.listen(realtimeOutputSink);
ProcessResult result = await testRunner.run(
commandTokens.command,
commandTokens.args..addAll(extraFlags),
workingDirectory: workingDirectory,
);
return TestResult(
testName: fullCommandDisplay,
exitCode: result.exitCode,
runtime: DateTime.now().difference(start),
message: result.exitCode == 0
? result.stdout
: _formatError(
fullCommandDisplay,
result,
),
);
}
String _formatError(String cmd, ProcessResult result) {
List<String> resultStdout = result.stdout != ''
? [...result.stdout.split('\n'), '\n']
.where((var val) => val != '')
.cast<String>()
.toList()
: [];
return [
...resultStdout,
result.stderr,
].join('\n');
}
@override
String toString() => [
'<TestBundle',
' testName: ${testDefinition.name}',
' extraFlags: $extraFlags',
' runnerFlags: $runnerFlags',
'/>',
].join('\n');
}