blob: ce47068050985d243f6b7e14e8b522e48b30ada8 [file] [log] [blame]
// Copyright 2013 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:io' as io;
import 'package:path/path.dart' as pathlib;
// TODO(yjbanov): remove hacks when this is fixed:
// https://github.com/dart-lang/test/issues/1521
import 'package:test_api/src/backend/group.dart' as hack;
import 'package:test_api/src/backend/live_test.dart' as hack;
import 'package:test_api/src/backend/runtime.dart' as hack;
import 'package:test_core/src/executable.dart' as test;
import 'package:test_core/src/runner/configuration/reporters.dart' as hack;
import 'package:test_core/src/runner/engine.dart' as hack;
import 'package:test_core/src/runner/hack_register_platform.dart' as hack;
import 'package:test_core/src/runner/reporter.dart' as hack;
import '../browser.dart';
import '../environment.dart';
import '../exceptions.dart';
import '../pipeline.dart';
import '../test_platform.dart';
import '../utils.dart';
// Maximum number of tests that run concurrently.
const int _testConcurrency = int.fromEnvironment('FELT_TEST_CONCURRENCY', defaultValue: 10);
/// Runs web tests.
///
/// Assumes the artifacts from [CompileTestsStep] are available, either from
/// running it prior to this step locally, or by having the build graph copy
/// them from another bot.
class RunTestsStep implements PipelineStep {
RunTestsStep({
required this.browserEnvironment,
required this.isDebug,
required this.doUpdateScreenshotGoldens,
this.testFiles,
});
final BrowserEnvironment browserEnvironment;
final List<FilePath>? testFiles;
final bool isDebug;
final bool doUpdateScreenshotGoldens;
/// Global list of shards that failed.
///
/// This is used to make sure that when there's a test failure anywhere we
/// exit with a non-zero exit code.
///
/// Shards must never be removed from this list, only added.
List<String> failedShards = <String>[];
/// Whether all test shards succeeded.
bool get allShardsPassed => failedShards.isEmpty;
@override
String get description => 'run_tests';
@override
bool get isSafeToInterrupt => true;
@override
Future<void> interrupt() async {}
@override
Future<void> run() async {
_copyTestFontsIntoWebUi();
await _prepareTestResultsDirectory();
await browserEnvironment.prepareEnvironment();
final List<FilePath> testFiles = this.testFiles ?? findAllTests();
// Separate screenshot tests from unit-tests. Screenshot tests must run
// one at a time. Otherwise, they will end up screenshotting each other.
// This is not an issue for unit-tests.
final FilePath failureSmokeTestPath = FilePath.fromWebUi(
'test/golden_tests/golden_failure_smoke_test.dart',
);
final List<FilePath> screenshotTestFiles = <FilePath>[];
final List<FilePath> unitTestFiles = <FilePath>[];
for (final FilePath testFilePath in testFiles) {
if (!testFilePath.absolute.endsWith('_test.dart')) {
// Not a test file at all. Skip.
continue;
}
if (testFilePath == failureSmokeTestPath) {
// A smoke test that fails on purpose. Skip.
continue;
}
// All files under test/golden_tests are considered golden tests.
final bool isUnderGoldenTestsDirectory =
pathlib.split(testFilePath.relativeToWebUi).contains('golden_tests');
// Any file whose name ends with "_golden_test.dart" is run as a golden test.
final bool isGoldenTestFile = pathlib
.basename(testFilePath.relativeToWebUi)
.endsWith('_golden_test.dart');
if (isUnderGoldenTestsDirectory || isGoldenTestFile) {
screenshotTestFiles.add(testFilePath);
} else {
unitTestFiles.add(testFilePath);
}
}
// This test returns a non-zero exit code on purpose. Run it separately.
if (testFiles.contains(failureSmokeTestPath)) {
await _runTestBatch(
testFiles: <FilePath>[failureSmokeTestPath],
browserEnvironment: browserEnvironment,
concurrency: 1,
expectFailure: true,
isDebug: isDebug,
doUpdateScreenshotGoldens: doUpdateScreenshotGoldens,
);
}
// Run non-screenshot tests with high concurrency.
if (unitTestFiles.isNotEmpty) {
await _runTestBatch(
testFiles: unitTestFiles,
browserEnvironment: browserEnvironment,
concurrency: _testConcurrency,
expectFailure: false,
isDebug: isDebug,
doUpdateScreenshotGoldens: doUpdateScreenshotGoldens,
);
_checkExitCode('Unit tests');
}
// Run screenshot tests one at a time to prevent tests from screenshotting
// each other.
if (screenshotTestFiles.isNotEmpty) {
await _runTestBatch(
testFiles: screenshotTestFiles,
browserEnvironment: browserEnvironment,
concurrency: 1,
expectFailure: false,
isDebug: isDebug,
doUpdateScreenshotGoldens: doUpdateScreenshotGoldens,
);
_checkExitCode('Golden tests');
}
if (!allShardsPassed) {
throw ToolExit(_createFailedShardsMessage());
}
}
void _checkExitCode(String shard) {
if (io.exitCode != 0) {
failedShards.add(shard);
}
}
String _createFailedShardsMessage() {
final StringBuffer message = StringBuffer(
'The following test shards failed:\n',
);
for (final String failedShard in failedShards) {
message.writeln(' - $failedShard');
}
return message.toString();
}
}
Future<void> _prepareTestResultsDirectory() async {
if (environment.webUiTestResultsDirectory.existsSync()) {
environment.webUiTestResultsDirectory.deleteSync(recursive: true);
}
environment.webUiTestResultsDirectory.createSync(recursive: true);
}
const List<String> _kTestFonts = <String>[
'ahem.ttf',
'Roboto-Regular.ttf',
'NotoNaskhArabic-Regular.ttf',
'NotoColorEmoji.ttf',
];
void _copyTestFontsIntoWebUi() {
final String fontsPath = pathlib.join(
environment.flutterDirectory.path,
'third_party',
'txt',
'third_party',
'fonts',
);
for (final String fontFile in _kTestFonts) {
final io.File sourceTtf = io.File(pathlib.join(fontsPath, fontFile));
final String destinationTtfPath =
pathlib.join(environment.webUiRootDir.path, 'lib', 'assets', fontFile);
sourceTtf.copySync(destinationTtfPath);
}
}
/// Runs a batch of tests.
///
/// Unless [expectFailure] is set to false, sets [io.exitCode] to a non-zero
/// value if any tests fail.
Future<void> _runTestBatch({
required List<FilePath> testFiles,
required bool isDebug,
required BrowserEnvironment browserEnvironment,
required bool doUpdateScreenshotGoldens,
required int concurrency,
required bool expectFailure,
}) async {
final String configurationFilePath = pathlib.join(
environment.webUiRootDir.path,
browserEnvironment.packageTestConfigurationYamlFile,
);
final List<String> testArgs = <String>[
...<String>['-r', 'compact'],
'--concurrency=$concurrency',
if (isDebug) '--pause-after-load',
// Don't pollute logs with output from tests that are expected to fail.
if (expectFailure)
'--reporter=name-only',
'--platform=${browserEnvironment.packageTestRuntime.identifier}',
'--precompiled=${environment.webUiBuildDir.path}',
'--configuration=$configurationFilePath',
'--',
...testFiles.map((FilePath f) => f.relativeToWebUi).toList(),
];
if (expectFailure) {
hack.registerReporter(
'name-only',
hack.ReporterDetails(
'Prints the name of the test, but suppresses all other test output.',
(_, hack.Engine engine, __) => NameOnlyReporter(engine)),
);
}
hack.registerPlatformPlugin(<hack.Runtime>[
browserEnvironment.packageTestRuntime,
], () {
return BrowserPlatform.start(
browserEnvironment: browserEnvironment,
// It doesn't make sense to update a screenshot for a test that is
// expected to fail.
doUpdateScreenshotGoldens: !expectFailure && doUpdateScreenshotGoldens,
);
});
// We want to run tests with `web_ui` as a working directory.
final dynamic originalCwd = io.Directory.current;
io.Directory.current = environment.webUiRootDir.path;
try {
await test.main(testArgs);
} finally {
io.Directory.current = originalCwd;
}
if (expectFailure) {
if (io.exitCode != 0) {
// It failed, as expected.
print('Test successfully failed, as expected.');
io.exitCode = 0;
} else {
io.stderr.writeln(
'Tests ${testFiles.join(', ')} did not fail. Expected failure.',
);
io.exitCode = 1;
}
}
}
/// Prints the name of the test, but suppresses all other test output.
///
/// This is useful to prevent pollution of logs by tests that are expected to
/// fail.
class NameOnlyReporter implements hack.Reporter {
NameOnlyReporter(hack.Engine testEngine) {
testEngine.onTestStarted.listen(_printTestName);
}
void _printTestName(hack.LiveTest test) {
print('Running ${test.groups.map((hack.Group group) => group.name).join(' ')} ${test.individualName}');
}
@override
void pause() {}
@override
void resume() {}
}