blob: c45e3c48922c69989bb293654c0a8f32a18bc3cf [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:fxtest/fxtest.dart';
import 'package:io/ansi.dart';
import 'exit_code.dart';
/// Main entry-point for all Fuchsia tests, both host and on-device.
///
/// [FuchsiaTestCommand] receives a combination of test names and feature flags,
/// and due to the nature of Fuchsia tests, needs to run each passed test name
/// against the full set of feature flags. For example, if the way to invoke
/// this command is `fx test <...args>`, and the developer executes
/// `... test1 test2 -flag1 -flag2`, the desired behavior for our user lies
/// behind pretending the user entered `... test1 -flag1 -flag2` and
/// `... test2 -flag1 flag2` separately. Note that the individual tests indicated
/// by these two imaginary commands are then rolled back into one singular test
/// suite, so end users will be none the wiser.
///
/// The following is a high level road map of how [FuchsiaTestCommand] works:
///
/// - Parse commands into testNames and flags
/// - Produce a list of each individual `testName` paired with all provided flags
/// - loop over this list `testName` and flag combinations, matching tests
/// out of `out/default/tests.json`
/// - Load each matching test's raw JSON into a [TestDefinition] instance which
/// handles determining all the runtime invocation details for a given test
/// - Loop over the list of [TestDefinition] instances, listening to emitted
/// [TestEvent] instances with a [FormattedOutput] class
/// - Every time a [TestEvent] instance is emitted, our [FormattedOutput]
/// class captures it and flushes appropriate text to [stdout].
/// - Once the [TestDefinition] list has been completely processed, flush any
/// captured stderr from individual test processes to show errors / stacktraces
class FuchsiaTestCommand {
// ignore: close_sinks
final _eventStreamController = StreamController<TestEvent>();
final AnalyticsReporter analyticsReporter;
/// Bundle of configuration options for this invocation.
final TestsConfig testsConfig;
/// Translators between [TestEvent] instances and output for the user.
final List<OutputFormatter> outputFormatters;
/// Function that yields disposable wrappers around tests and [Process]
/// instances.
final TestRunner Function(TestsConfig) testRunnerBuilder;
/// Helper which verifies that we actually want to run tests (not a dry run,
/// for example) and are set up to even be successful (device and package
/// server are available, for example).
final Checklist checklist;
final ExitCodeSetter _exitCodeSetter;
/// Used to create any new directories needed to house test output / artifacts.
final DirectoryBuilder directoryBuilder;
int _numberOfTests;
FuchsiaTestCommand({
required this.analyticsReporter,
required this.outputFormatters,
required this.checklist,
required this.testsConfig,
required this.testRunnerBuilder,
required this.directoryBuilder,
ExitCodeSetter? exitCodeSetter,
}) : _exitCodeSetter = exitCodeSetter ?? setExitCode,
_numberOfTests = 0 {
if (outputFormatters.isEmpty) {
throw AssertionError('Must provide at least one OutputFormatter');
}
stream.listen((output) {
for (var formatter in outputFormatters) {
formatter.update(output);
}
});
}
factory FuchsiaTestCommand.fromConfig(
TestsConfig testsConfig, {
required TestRunner Function(TestsConfig) testRunnerBuilder,
DirectoryBuilder? directoryBuilder,
ExitCodeSetter? exitCodeSetter,
OutputFormatter? outputFormatter,
}) {
var _outputFormatter =
outputFormatter ?? OutputFormatter.fromConfig(testsConfig);
var _fileFormatter = FileFormatter.fromConfig(testsConfig);
return FuchsiaTestCommand(
analyticsReporter: testsConfig.flags.dryRun
? AnalyticsReporter.noop()
: AnalyticsReporter(fxEnv: testsConfig.fxEnv),
checklist: PreChecker.fromConfig(
testsConfig,
eventSink: _outputFormatter.update,
),
directoryBuilder:
directoryBuilder ?? (path, {required recursive}) => null,
outputFormatters: [
_outputFormatter,
if (_fileFormatter != null) _fileFormatter
],
testRunnerBuilder: testRunnerBuilder,
testsConfig: testsConfig,
exitCodeSetter: exitCodeSetter,
);
}
Stream<TestEvent> get stream => _eventStreamController.stream;
void emitEvent(TestEvent event) {
if (!_eventStreamController.isClosed) {
_eventStreamController.sink.add(event);
}
}
void dispose() {
for (var formatter in outputFormatters) {
formatter.close();
}
_eventStreamController.close();
}
Future<void> runTestSuite([TestsManifestReader? manifestReader]) async {
manifestReader ??= TestsManifestReader();
advertiseLogFile();
var parsedManifest = await readManifest(manifestReader);
manifestReader.reportOnTestBundles(
userFriendlyBuildDir: testsConfig.fxEnv.userFriendlyOutputDir!,
eventEmitter: emitEvent,
parsedManifest: parsedManifest,
testsConfig: testsConfig,
);
if (parsedManifest.testBundles.isEmpty) {
_exitCodeSetter(noTestFoundExitCode);
return noMatchesHelp(
manifestReader: manifestReader,
testDefinitions: parsedManifest.testDefinitions,
testsConfig: testsConfig,
);
} else if (parsedManifest.unusedConfigs?.isNotEmpty ?? false) {
_exitCodeSetter(noTestFoundExitCode);
return unusedConfigsHelp(
manifestReader: manifestReader,
testDefinitions: parsedManifest.testDefinitions,
unusedConfigs: parsedManifest.unusedConfigs!,
);
}
if (testsConfig.flags.shouldRandomizeTestOrder) {
parsedManifest.testBundles.shuffle();
}
if (testsConfig.flags.shouldRebuild) {
Set<String> buildArgs = TestBundle.calculateMinimalBuildTargets(
testsConfig, parsedManifest.testBundles);
emitEvent(TestInfo(testsConfig
.wrapWith('> fx build ${buildArgs.join(' ')}', [green, styleBold])));
try {
await fxCommandRun(testsConfig.fxEnv.fx, 'build', buildArgs.toList());
} on FxRunException {
emitEvent(FatalError(
'\'fx test\' could not perform a successful build. Try to run \'fx build\' manually or use the \'--no-build\' flag'));
_exitCodeSetter(failureExitCode);
return;
}
// Re-parse the manifest in case it has changed as a side effect of building
parsedManifest = await readManifest(manifestReader);
}
try {
// Let the output formatter know that we're done parsing and
// emitting preliminary events
emitEvent(BeginningTests());
await runTests(parsedManifest.testBundles);
} on FailFastException catch (_) {
// Non-zero exit code indicates generic but fatal failure
_exitCodeSetter(failureExitCode);
}
emitEvent(AllTestsCompleted());
}
Future<ParsedManifest> readManifest(
TestsManifestReader manifestReader,
) async {
List<TestDefinition> testDefinitions = await manifestReader.loadTestsJson(
buildDir: testsConfig.fxEnv.outputDir!,
fxLocation: testsConfig.fxEnv.fx,
manifestFileName: 'tests.json',
);
return manifestReader.aggregateTests(
eventEmitter: emitEvent,
matchLength: testsConfig.flags.matchLength,
testBundleBuilder: testBundleBuilder,
testDefinitions: testDefinitions,
testsConfig: testsConfig,
);
}
void noMatchesHelp({
required TestsManifestReader manifestReader,
required List<TestDefinition> testDefinitions,
required TestsConfig testsConfig,
}) {
emitEvent(GeneratingHintsEvent());
emitEvent(TestInfo(
testsConfig.wrapWith(
'Could not find any tests to run with the '
'arguments you provided.',
[lightYellow]),
));
var manifestOfHints = manifestReader.aggregateTests(
comparer: FuzzyComparer(threshold: testsConfig.flags.fuzzyThreshold),
eventEmitter: (TestEvent event) => null,
matchLength: testsConfig.flags.matchLength,
testBundleBuilder: testBundleBuilder,
testDefinitions: testDefinitions,
testsConfig: testsConfig,
);
if (manifestOfHints.testBundles.isNotEmpty) {
manifestOfHints.testBundles.sort(
(TestBundle bundle1, TestBundle bundle2) {
return bundle1.confidence.compareTo(bundle2.confidence);
},
);
var hints = manifestOfHints.testBundles.length > 1 ? 'hints' : 'hint';
emitEvent(TestInfo(
'Did you mean... (${manifestOfHints.testBundles.length} $hints)?',
requiresPadding: false,
));
for (TestBundle bundle in manifestOfHints.testBundles) {
emitEvent(TestInfo(
' -- ${bundle.testDefinition.name}',
requiresPadding: false,
));
}
} else {
emitEvent(TestInfo(
'Make sure this test is transitively in your \'fx set\' arguments. See https://fuchsia.dev/fuchsia-src/development/testing/faq for more information.',
requiresPadding: false,
));
}
}
void unusedConfigsHelp({
required TestsManifestReader manifestReader,
required List<TestDefinition> testDefinitions,
required List<PermutatedTestsConfig> unusedConfigs,
}) {
emitEvent(GeneratingHintsEvent());
String unusedConfigsString = unusedConfigs
.map(
(config) => config.testNameGroup?.map((name) => name.arg).join(','))
.join(';');
emitEvent(TestInfo(
testsConfig.wrapWith(
'Could not find any tests that matched these arguments: $unusedConfigsString.',
[lightYellow]),
));
emitEvent(TestInfo(
'Make sure this test is transitively in your \'fx set\' arguments. See https://fuchsia.dev/fuchsia-src/development/testing/faq for more information.',
requiresPadding: false,
));
}
TestBundle testBundleBuilder(
TestDefinition testDefinition, [
double? confidence,
]) =>
TestBundle.build(
confidence: confidence ?? 1,
directoryBuilder: directoryBuilder,
fxPath: testsConfig.fxEnv.fx,
realtimeOutputSink: (String val) => emitEvent(TestOutputEvent(val)),
timeElapsedSink: (Duration duration, String cmd, String output) =>
emitEvent(TimeElapsedEvent(duration, cmd, output)),
testRunnerBuilder: testRunnerBuilder,
testDefinition: testDefinition,
testsConfig: testsConfig,
workingDirectory: testsConfig.fxEnv.outputDir!,
);
bool _maybeAddPackageHash(
TestBundle testBundle, PackageRepository? repository) {
if (testsConfig.flags.shouldUsePackageHash &&
testBundle.testDefinition.packageUrl != null) {
if (repository == null) {
emitEvent(TestResult(
runtime: Duration(seconds: 0),
exitCode: failureExitCode,
message:
'Package repository is not available. Run "fx serve-updates" again or use the "--no-use-package-hash" flag.',
testName: testBundle.testDefinition.name,
command: ''));
return false;
} else {
String packageName = testBundle.testDefinition.packageUrl!.packageName;
if (repository[packageName] == null) {
emitEvent(TestResult(
runtime: Duration(seconds: 0),
exitCode: failureExitCode,
message:
'Package $packageName is not in the package repository, check if it was correctly built or use the "--no-use-package-hash" flag.',
testName: testBundle.testDefinition.name,
command: ''));
return false;
}
testBundle.testDefinition.hash = repository[packageName]!.merkle;
}
}
return true;
}
Future<void> runTests(List<TestBundle> testBundles) async {
// Enforce a limit
var _testBundles = testsConfig.flags.limit > 0 &&
testsConfig.flags.limit < testBundles.length
? testBundles.sublist(0, testsConfig.flags.limit)
: testBundles;
if (!testsConfig.flags.infoOnly &&
!await checklist.isDeviceReady(_testBundles)) {
emitEvent(FatalError('Device is not ready for running device tests'));
_exitCodeSetter(failureExitCode);
return;
}
// set merkle root hash on component tests
// TODO: This should not require checking if `buildDir != null`, as that is
// a temporary workaround to get tests passing on CQ. The correct
// implementation is to abstract file-reading just as we have process-launching.
PackageRepository? packageRepository;
if (testsConfig.flags.shouldUsePackageHash &&
testsConfig.fxEnv.outputDir != null) {
packageRepository = await PackageRepository.fromManifest(
buildDir: testsConfig.fxEnv.outputDir!);
}
for (TestBundle testBundle in _testBundles) {
if (!testsConfig.flags.infoOnly &&
!_maybeAddPackageHash(testBundle, packageRepository)) {
_exitCodeSetter(failureExitCode);
continue;
}
await testBundle.run().forEach((TestEvent event) {
emitEvent(event);
if (event is FatalError) {
_exitCodeSetter(failureExitCode);
} else if (event is TestResult && !event.isSuccess) {
_exitCodeSetter(event.exitCode);
}
});
_numberOfTests += 1;
}
}
/// Function guaranteed to be called at the end of execution, whether that is
/// natural or the result of a SIGINT.
Future<void> cleanUp() async {
await _reportAnalytics();
}
Future<void> _reportAnalytics() async {
final bool _actuallyRanTests = _numberOfTests > 0;
if (!testsConfig.flags.dryRun && _actuallyRanTests) {
await analyticsReporter.report(
subcommand: 'test',
action: 'number',
label: '$_numberOfTests',
);
}
}
void advertiseLogFile() {
for (var outputFormatter in outputFormatters) {
if (outputFormatter is FileFormatter) {
// ignore: avoid_as
(outputFormatter.buffer.stdout as FileStandardOut).initPath();
// ignore: avoid_as
var path = (outputFormatter.buffer.stdout as FileStandardOut).path;
emitEvent(TestInfo(testsConfig.wrapWith(
'Logging all output to: $path\n'
'Use the `--logpath` argument to specify a log location or '
'`--no-log` to disable\n',
[darkGray],
)));
}
}
}
}