blob: 3d861e22244a73bc3c0fe7b5239498bb18d699ec [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 'package:web_test_utils/skia_client.dart';
import '../browser.dart';
import '../common.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.browserName,
required this.isDebug,
required this.doUpdateScreenshotGoldens,
this.testFiles,
}) : _browserEnvironment = getBrowserEnvironment(browserName);
final String browserName;
final List<FilePath>? testFiles;
final bool isDebug;
final bool doUpdateScreenshotGoldens;
final BrowserEnvironment _browserEnvironment;
/// 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 {
await _prepareTestResultsDirectory();
await _browserEnvironment.prepareEnvironment();
final SkiaGoldClient? skiaClient = await _createSkiaClient();
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,
skiaClient: skiaClient,
);
}
// Run non-screenshot tests with high concurrency.
if (unitTestFiles.isNotEmpty) {
await _runTestBatch(
testFiles: unitTestFiles,
browserEnvironment: _browserEnvironment,
concurrency: _testConcurrency,
expectFailure: false,
isDebug: isDebug,
doUpdateScreenshotGoldens: doUpdateScreenshotGoldens,
skiaClient: skiaClient,
);
_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,
skiaClient: skiaClient,
);
_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<SkiaGoldClient?> _createSkiaClient() async {
final SkiaGoldClient skiaClient = SkiaGoldClient(
environment.webUiSkiaGoldDirectory,
browserName: browserName,
);
if (!await _checkSkiaClient(skiaClient)) {
print('WARNING: Unable to use Skia Client in this environment.');
return null;
}
return skiaClient;
}
/// Checks whether the Skia Client is usable in this environment.
Future<bool> _checkSkiaClient(SkiaGoldClient skiaClient) async {
// Now let's check whether Skia Gold is reachable or not.
if (isLuci) {
if (SkiaGoldClient.isAvailable) {
try {
await skiaClient.auth();
return true;
} catch (e) {
print(e);
}
}
} else {
try {
// Check if we can reach Gold.
await skiaClient.getExpectationForTest('');
return true;
} on io.OSError catch (_) {
print('OSError occurred, could not reach Gold.');
} on io.SocketException catch (_) {
print('SocketException occurred, could not reach Gold.');
}
}
return false;
}
}
Future<void> _prepareTestResultsDirectory() async {
if (environment.webUiTestResultsDirectory.existsSync()) {
environment.webUiTestResultsDirectory.deleteSync(recursive: true);
}
environment.webUiTestResultsDirectory.createSync(recursive: true);
}
/// 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,
required SkiaGoldClient? skiaClient,
}) 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,
skiaClient: skiaClient,
);
});
// 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() {}
}